mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08: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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
DragDropContext,
|
||||
Draggable,
|
||||
Droppable,
|
||||
type DraggableProvided,
|
||||
type DraggableStateSnapshot,
|
||||
type DropResult,
|
||||
} from "@hello-pangea/dnd";
|
||||
import { GripVertical, Loader2 } from "lucide-react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
|
||||
import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources";
|
||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||
|
||||
import { getOrderItemLabel, getOrderValue } from "./ruleEngineOrder.utils";
|
||||
|
||||
interface OrderDraftItem {
|
||||
id: string;
|
||||
label: string;
|
||||
code?: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface ManageRuleEngineOrderDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
config: RuleEngineResourceConfig;
|
||||
items: RuleEngineRecord[];
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
onSave: (payload: { ids: string[]; requiresDirectorApproval?: boolean }) => void;
|
||||
}
|
||||
|
||||
const toDraftItems = (
|
||||
rows: RuleEngineRecord[],
|
||||
config: RuleEngineResourceConfig,
|
||||
): OrderDraftItem[] => {
|
||||
const field = config.orderConfig!.field;
|
||||
return [...rows]
|
||||
.sort((a, b) => getOrderValue(a, field) - getOrderValue(b, field))
|
||||
.map((row) => ({
|
||||
id: String(row.id),
|
||||
label: getOrderItemLabel(row, config.slug),
|
||||
code: row.code ? String(row.code) : undefined,
|
||||
order: getOrderValue(row, field),
|
||||
}));
|
||||
};
|
||||
|
||||
/** Reparent dragged row to body — fixes position:fixed inside Modal transforms. */
|
||||
const PortalAwareRow = ({
|
||||
snapshot,
|
||||
children,
|
||||
}: {
|
||||
snapshot: DraggableStateSnapshot;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
if (snapshot.isDragging) {
|
||||
return createPortal(children, document.body);
|
||||
}
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const OrderRow = ({
|
||||
item,
|
||||
index,
|
||||
dragProvided,
|
||||
snapshot,
|
||||
}: {
|
||||
item: OrderDraftItem;
|
||||
index: number;
|
||||
dragProvided: DraggableProvided;
|
||||
snapshot: DraggableStateSnapshot;
|
||||
}) => (
|
||||
<PortalAwareRow snapshot={snapshot}>
|
||||
<Group
|
||||
ref={dragProvided.innerRef}
|
||||
{...dragProvided.draggableProps}
|
||||
{...dragProvided.dragHandleProps}
|
||||
gap="sm"
|
||||
wrap="nowrap"
|
||||
p="sm"
|
||||
style={{
|
||||
...dragProvided.draggableProps.style,
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
background: snapshot.isDragging ? "var(--mantine-color-gray-0)" : "white",
|
||||
boxShadow: snapshot.isDragging ? "0 8px 24px rgba(0, 0, 0, 0.12)" : undefined,
|
||||
cursor: snapshot.isDragging ? "grabbing" : "grab",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
<Box c="dimmed" style={{ display: "flex", alignItems: "center" }}>
|
||||
<GripVertical size={18} />
|
||||
</Box>
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{index + 1}
|
||||
</Badge>
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.code ? (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{item.code}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
</PortalAwareRow>
|
||||
);
|
||||
|
||||
const ManageRuleEngineOrderDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
config,
|
||||
items,
|
||||
isLoading,
|
||||
isSaving,
|
||||
onSave,
|
||||
}: ManageRuleEngineOrderDialogProps) => {
|
||||
const isScoped = config.orderConfig?.scopeField === "requiresDirectorApproval";
|
||||
const [tab, setTab] = useState<"standard" | "director">("standard");
|
||||
const [filter, setFilter] = useState("");
|
||||
const [standardItems, setStandardItems] = useState<OrderDraftItem[]>([]);
|
||||
const [directorItems, setDirectorItems] = useState<OrderDraftItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (isScoped) {
|
||||
setStandardItems(
|
||||
toDraftItems(
|
||||
items.filter((row) => !row.requiresDirectorApproval),
|
||||
config,
|
||||
),
|
||||
);
|
||||
setDirectorItems(
|
||||
toDraftItems(
|
||||
items.filter((row) => row.requiresDirectorApproval),
|
||||
config,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setStandardItems(toDraftItems(items, config));
|
||||
}
|
||||
setFilter("");
|
||||
}, [open, items, config, isScoped]);
|
||||
|
||||
const activeItems = isScoped
|
||||
? tab === "director"
|
||||
? directorItems
|
||||
: standardItems
|
||||
: standardItems;
|
||||
|
||||
const setActiveItems = isScoped
|
||||
? tab === "director"
|
||||
? setDirectorItems
|
||||
: setStandardItems
|
||||
: setStandardItems;
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const q = filter.trim().toLowerCase();
|
||||
if (!q) return activeItems;
|
||||
return activeItems.filter(
|
||||
(item) =>
|
||||
item.label.toLowerCase().includes(q) ||
|
||||
(item.code?.toLowerCase().includes(q) ?? false),
|
||||
);
|
||||
}, [activeItems, filter]);
|
||||
|
||||
const droppableId = isScoped
|
||||
? `rule-engine-order-${tab}`
|
||||
: "rule-engine-order-list";
|
||||
|
||||
const onDragEnd = (result: DropResult) => {
|
||||
if (!result.destination || filter.trim()) return;
|
||||
const sourceIndex = result.source.index;
|
||||
const destIndex = result.destination.index;
|
||||
if (sourceIndex === destIndex) return;
|
||||
|
||||
setActiveItems((prev) => {
|
||||
const next = [...prev];
|
||||
const [removed] = next.splice(sourceIndex, 1);
|
||||
next.splice(destIndex, 0, removed!);
|
||||
return next.map((item, index) => ({ ...item, order: index + 1 }));
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (isScoped) {
|
||||
onSave({
|
||||
ids: (tab === "director" ? directorItems : standardItems).map((item) => item.id),
|
||||
requiresDirectorApproval: tab === "director",
|
||||
});
|
||||
return;
|
||||
}
|
||||
onSave({ ids: standardItems.map((item) => item.id) });
|
||||
};
|
||||
|
||||
const renderList = (listItems: OrderDraftItem[]) => (
|
||||
<Droppable droppableId={droppableId}>
|
||||
{(provided) => (
|
||||
<Stack
|
||||
gap="xs"
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
style={{ minHeight: 120 }}
|
||||
>
|
||||
{listItems.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
No items to reorder.
|
||||
</Text>
|
||||
) : (
|
||||
listItems.map((item, index) => (
|
||||
<Draggable
|
||||
key={item.id}
|
||||
draggableId={item.id}
|
||||
index={index}
|
||||
isDragDisabled={Boolean(filter.trim())}
|
||||
>
|
||||
{(dragProvided, snapshot) => (
|
||||
<OrderRow
|
||||
item={item}
|
||||
index={index}
|
||||
dragProvided={dragProvided}
|
||||
snapshot={snapshot}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
))
|
||||
)}
|
||||
{provided.placeholder}
|
||||
</Stack>
|
||||
)}
|
||||
</Droppable>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
title={`Manage order · ${config.label}`}
|
||||
centered
|
||||
size="lg"
|
||||
radius="lg"
|
||||
transitionProps={{ duration: 0, transition: "fade" }}
|
||||
styles={{
|
||||
content: {
|
||||
transform: "none",
|
||||
overflow: "visible",
|
||||
},
|
||||
body: {
|
||||
overflow: "visible",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Drag items anywhere in the list to set display order. Changes apply when you save.
|
||||
</Text>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader2 size={28} style={{ animation: "spin 1s linear infinite" }} />
|
||||
</Group>
|
||||
) : isScoped ? (
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(value) => setTab((value as "standard" | "director") ?? "standard")}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="standard">Standard chain ({standardItems.length})</Tabs.Tab>
|
||||
<Tabs.Tab value="director">Director chain ({directorItems.length})</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="standard" pt="md">
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
placeholder="Filter items…"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.currentTarget.value)}
|
||||
/>
|
||||
<Box style={{ maxHeight: "50vh", overflowY: "auto", paddingRight: 4 }}>
|
||||
{renderList(filteredItems)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="director" pt="md">
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
placeholder="Filter items…"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.currentTarget.value)}
|
||||
/>
|
||||
<Box style={{ maxHeight: "50vh", overflowY: "auto", paddingRight: 4 }}>
|
||||
{renderList(filteredItems)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
) : (
|
||||
<>
|
||||
<TextInput
|
||||
placeholder="Filter items…"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.currentTarget.value)}
|
||||
/>
|
||||
<Box style={{ maxHeight: "50vh", overflowY: "auto", paddingRight: 4 }}>
|
||||
{renderList(filteredItems)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{filter.trim() ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Clear the filter to drag and reorder items.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="green"
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || isSaving}
|
||||
leftSection={
|
||||
isSaving ? (
|
||||
<Loader2 size={16} style={{ animation: "spin 1s linear infinite" }} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{isSaving ? "Saving…" : "Save order"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</DragDropContext>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManageRuleEngineOrderDialog;
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Stack, Group, Text, Pagination, Card, SimpleGrid } from "@mantine/core";
|
||||
import type { OnChangeFn, PaginationState } from "@tanstack/react-table";
|
||||
import { Stack, Group, Text, Card, SimpleGrid } from "@mantine/core";
|
||||
|
||||
import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources";
|
||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||
|
||||
import RuleEngineListFooter from "./RuleEngineListFooter";
|
||||
import RuleEngineRecordActions from "./RuleEngineRecordActions";
|
||||
import { cardInitials, resolveCardPresentation } from "./ruleEngineCardMeta";
|
||||
import { formatCell } from "./ruleEngineFormat";
|
||||
@@ -13,12 +15,10 @@ export interface RuleEngineCardGridProps {
|
||||
status: "loading" | "error" | "success";
|
||||
emptyMessage: string;
|
||||
itemLabel: string;
|
||||
pagination: {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
pageCount: number;
|
||||
totalCount: number;
|
||||
};
|
||||
pagination: PaginationState;
|
||||
pageCount: number;
|
||||
totalCount: number;
|
||||
onPaginationChange: OnChangeFn<PaginationState>;
|
||||
onEdit?: (record: RuleEngineRecord) => void;
|
||||
onDelete?: (record: RuleEngineRecord) => void;
|
||||
readOnly?: boolean;
|
||||
@@ -71,6 +71,9 @@ const RuleEngineCardGrid = ({
|
||||
emptyMessage,
|
||||
itemLabel,
|
||||
pagination,
|
||||
pageCount,
|
||||
totalCount,
|
||||
onPaginationChange,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onViewChain,
|
||||
@@ -249,19 +252,13 @@ const RuleEngineCardGrid = ({
|
||||
})}
|
||||
</SimpleGrid>
|
||||
|
||||
{pagination.pageCount > 1 && (
|
||||
<Group justify="space-between" align="center" p="md" style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}>
|
||||
<Text size="sm" c="dimmed">
|
||||
Showing {Math.min(rows.length, pagination.pageSize)} of {pagination.totalCount} {itemLabel}
|
||||
</Text>
|
||||
<Pagination
|
||||
value={pagination.pageIndex + 1}
|
||||
total={pagination.pageCount}
|
||||
size="sm"
|
||||
radius="md"
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
<RuleEngineListFooter
|
||||
pagination={pagination}
|
||||
pageCount={pageCount}
|
||||
totalCount={totalCount}
|
||||
itemLabel={itemLabel}
|
||||
onPaginationChange={onPaginationChange}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type FormFieldDef,
|
||||
} from "@/pages/ruleEngine/config/resources";
|
||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||
import { RULE_ENGINE_POSITION_END } from "./ruleEngineOrder.utils";
|
||||
|
||||
export interface RuleEngineFormDialogProps {
|
||||
open: boolean;
|
||||
@@ -30,6 +31,8 @@ export interface RuleEngineFormDialogProps {
|
||||
initialRecord?: RuleEngineRecord | null;
|
||||
isSubmitting: boolean;
|
||||
selectOptionsLoading?: boolean;
|
||||
positionOptions?: { label: string; value: string }[];
|
||||
positionLoading?: boolean;
|
||||
onSubmit: (values: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
@@ -146,15 +149,19 @@ const RuleEngineFormDialog = ({
|
||||
initialRecord,
|
||||
isSubmitting,
|
||||
selectOptionsLoading = false,
|
||||
positionOptions,
|
||||
positionLoading = false,
|
||||
onSubmit,
|
||||
}: RuleEngineFormDialogProps) => {
|
||||
const [values, setValues] = useState<Record<string, unknown>>(() =>
|
||||
buildInitialValues(fields, initialRecord),
|
||||
);
|
||||
const [position, setPosition] = useState(RULE_ENGINE_POSITION_END);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setValues(buildInitialValues(fields, initialRecord));
|
||||
setPosition(RULE_ENGINE_POSITION_END);
|
||||
}
|
||||
}, [open, fields, initialRecord]);
|
||||
|
||||
@@ -192,6 +199,10 @@ const RuleEngineFormDialog = ({
|
||||
payload.code = String(payload.code).toUpperCase();
|
||||
}
|
||||
|
||||
if (!initialRecord && positionOptions && position !== RULE_ENGINE_POSITION_END) {
|
||||
payload.insertAfterId = position;
|
||||
}
|
||||
|
||||
onSubmit(payload);
|
||||
};
|
||||
|
||||
@@ -315,6 +326,23 @@ const RuleEngineFormDialog = ({
|
||||
<Stack gap="lg">
|
||||
<Box style={{ maxHeight: "calc(65vh - 120px)", overflowY: "auto", paddingRight: 4 }}>
|
||||
<Stack gap="md">
|
||||
{!initialRecord && positionOptions ? (
|
||||
<Select
|
||||
label="Position"
|
||||
description="New items are appended to the end by default."
|
||||
value={position}
|
||||
onChange={(value) => setPosition(value ?? RULE_ENGINE_POSITION_END)}
|
||||
data={[
|
||||
{ label: "At end (default)", value: RULE_ENGINE_POSITION_END },
|
||||
...positionOptions,
|
||||
]}
|
||||
searchable
|
||||
disabled={positionLoading}
|
||||
size="md"
|
||||
radius="md"
|
||||
styles={inputStyles}
|
||||
/>
|
||||
) : null}
|
||||
{formRows.map((row) =>
|
||||
row.kind === "pair" ? (
|
||||
<SimpleGrid key={`${row.fields[0].name}-${row.fields[1].name}`} cols={2} spacing="md">
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { OnChangeFn, PaginationState } from "@tanstack/react-table";
|
||||
import { Group, Pagination, Select, Text } from "@mantine/core";
|
||||
|
||||
export interface RuleEngineListFooterProps {
|
||||
pagination: PaginationState;
|
||||
pageCount: number;
|
||||
totalCount: number;
|
||||
itemLabel: string;
|
||||
onPaginationChange: OnChangeFn<PaginationState>;
|
||||
}
|
||||
|
||||
const PAGE_SIZE_OPTIONS = ["5", "10", "25", "50"];
|
||||
|
||||
const RuleEngineListFooter = ({
|
||||
pagination,
|
||||
pageCount,
|
||||
totalCount,
|
||||
itemLabel,
|
||||
onPaginationChange,
|
||||
}: RuleEngineListFooterProps) => {
|
||||
const { pageIndex, pageSize } = pagination;
|
||||
const start = totalCount === 0 ? 0 : pageIndex * pageSize + 1;
|
||||
const end = Math.min((pageIndex + 1) * pageSize, totalCount);
|
||||
|
||||
const setPageIndex = (nextIndex: number) => {
|
||||
onPaginationChange({ pageIndex: nextIndex, pageSize });
|
||||
};
|
||||
|
||||
const setPageSize = (nextSize: number) => {
|
||||
onPaginationChange({ pageIndex: 0, pageSize: nextSize });
|
||||
};
|
||||
|
||||
return (
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="wrap"
|
||||
p="md"
|
||||
style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Group gap="md" align="center">
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="sm" c="dimmed">
|
||||
Rows per page
|
||||
</Text>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onChange={(value) => value && setPageSize(Number(value))}
|
||||
data={PAGE_SIZE_OPTIONS}
|
||||
size="xs"
|
||||
w={70}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
Showing {start}–{end} of {totalCount} {itemLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{pageCount > 1 && (
|
||||
<Pagination
|
||||
value={pageIndex + 1}
|
||||
total={pageCount}
|
||||
size="sm"
|
||||
radius="md"
|
||||
onChange={(page) => setPageIndex(page - 1)}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default RuleEngineListFooter;
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ActionIcon, Group, Tooltip } from "@mantine/core";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
import type { RuleEngineOrderConfig } from "@/pages/ruleEngine/config/resources";
|
||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||
|
||||
import { getOrderValue } from "./ruleEngineOrder.utils";
|
||||
|
||||
export interface RuleEngineOrderControlsProps {
|
||||
record: RuleEngineRecord;
|
||||
orderConfig: RuleEngineOrderConfig;
|
||||
totalCount: number;
|
||||
disabled?: boolean;
|
||||
onMove: (id: string, direction: "up" | "down") => void;
|
||||
}
|
||||
|
||||
const RuleEngineOrderControls = ({
|
||||
record,
|
||||
orderConfig,
|
||||
totalCount,
|
||||
disabled = false,
|
||||
onMove,
|
||||
}: RuleEngineOrderControlsProps) => {
|
||||
const id = String(record.id);
|
||||
const order = getOrderValue(record, orderConfig.field);
|
||||
|
||||
const canMoveUp = order > 1;
|
||||
const canMoveDown = orderConfig.scopeField ? true : order < totalCount;
|
||||
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Tooltip label="Move up">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
disabled={disabled || !canMoveUp}
|
||||
onClick={() => onMove(id, "up")}
|
||||
aria-label="Move up"
|
||||
>
|
||||
<ChevronUp size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Move down">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
disabled={disabled || !canMoveDown}
|
||||
onClick={() => onMove(id, "down")}
|
||||
aria-label="Move down"
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default RuleEngineOrderControls;
|
||||
@@ -1,42 +1,50 @@
|
||||
import { LayoutGrid, Plus, Search, Table2 } from "lucide-react";
|
||||
import { LayoutGrid, ListOrdered, Plus, Search, Table2 } from "lucide-react";
|
||||
import { Button, TextInput, Group, SegmentedControl } from "@mantine/core";
|
||||
|
||||
import type { RuleEngineViewMode } from "./useRuleEngineViewMode";
|
||||
|
||||
export interface RuleEngineToolbarProps {
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
searchPlaceholder: string;
|
||||
search?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
showSearch?: boolean;
|
||||
onAdd?: () => void;
|
||||
addLabel?: string;
|
||||
onManageOrder?: () => void;
|
||||
viewMode: RuleEngineViewMode;
|
||||
onViewModeChange: (mode: RuleEngineViewMode) => void;
|
||||
}
|
||||
|
||||
const RuleEngineToolbar = ({
|
||||
search,
|
||||
search = "",
|
||||
onSearchChange,
|
||||
searchPlaceholder,
|
||||
searchPlaceholder = "Search…",
|
||||
showSearch = true,
|
||||
onAdd,
|
||||
addLabel = "Add",
|
||||
onManageOrder,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
}: RuleEngineToolbarProps) => (
|
||||
<Group gap="md" justify="space-between" align="center" wrap="nowrap">
|
||||
<TextInput
|
||||
placeholder={searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.currentTarget.value)}
|
||||
leftSection={<Search size={18} />}
|
||||
size="md"
|
||||
radius="lg"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
styles={{
|
||||
input: {
|
||||
borderColor: "var(--mantine-color-gray-3)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{showSearch && onSearchChange ? (
|
||||
<TextInput
|
||||
placeholder={searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.currentTarget.value)}
|
||||
leftSection={<Search size={18} />}
|
||||
size="md"
|
||||
radius="lg"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
styles={{
|
||||
input: {
|
||||
borderColor: "var(--mantine-color-gray-3)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ flex: 1 }} />
|
||||
)}
|
||||
|
||||
<Group gap="md" align="center" justify="flex-end" wrap="nowrap">
|
||||
<SegmentedControl
|
||||
@@ -72,6 +80,21 @@ const RuleEngineToolbar = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
{onManageOrder ? (
|
||||
<Button
|
||||
onClick={onManageOrder}
|
||||
leftSection={<ListOrdered size={18} />}
|
||||
size="sm"
|
||||
radius="lg"
|
||||
variant="light"
|
||||
color="gray"
|
||||
fw={600}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
Manage order
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{onAdd ? (
|
||||
<Button
|
||||
onClick={onAdd}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { RuleEngineRecord, RuleEngineResourceSlug } from "@/types/rule-engine";
|
||||
|
||||
export const RULE_ENGINE_POSITION_END = "__end__";
|
||||
|
||||
export function getOrderItemLabel(
|
||||
record: RuleEngineRecord,
|
||||
slug: RuleEngineResourceSlug,
|
||||
): string {
|
||||
const code = String(record.code ?? "").trim();
|
||||
switch (slug) {
|
||||
case "cargo-types":
|
||||
return String(record.cargoTypeName ?? (code || record.id));
|
||||
case "container-types":
|
||||
case "yards":
|
||||
case "shipping-lines":
|
||||
return String(record.label ?? (code || record.id));
|
||||
case "service-types":
|
||||
return String(record.serviceName ?? (code || record.id));
|
||||
case "approval-rules":
|
||||
return String(record.actionLabel ?? record.requiredRole ?? record.id);
|
||||
default:
|
||||
return String(record.label ?? record.code ?? record.id);
|
||||
}
|
||||
}
|
||||
|
||||
export function getOrderValue(
|
||||
record: RuleEngineRecord,
|
||||
field: "displayOrder" | "stepOrder",
|
||||
): number {
|
||||
const raw = record[field];
|
||||
return typeof raw === "number" ? raw : Number(raw ?? 0);
|
||||
}
|
||||
@@ -59,5 +59,17 @@ export const QUERY_KEYS = {
|
||||
resource: RuleEngineResourceSlug | string,
|
||||
params?: Record<string, unknown>,
|
||||
) => ["rule-engine", "select-options", resource, params ?? {}] as const,
|
||||
orderList: (resource: RuleEngineResourceSlug | string) =>
|
||||
["rule-engine", "order-list", resource] as const,
|
||||
},
|
||||
|
||||
OVERVIEW: {
|
||||
ROOT: ["overview"] as const,
|
||||
dashboard: (range?: string) => ["overview", "dashboard", range ?? "30d"] as const,
|
||||
bookingsTab: (range?: string) => ["overview", "bookings", range ?? "30d"] as const,
|
||||
billingTab: (range?: string) => ["overview", "billing", range ?? "30d"] as const,
|
||||
operationsTab: () => ["overview", "operations"] as const,
|
||||
customersTab: (range?: string) => ["overview", "customers", range ?? "30d"] as const,
|
||||
staffTab: (range?: string) => ["overview", "staff", range ?? "30d"] as const,
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -68,11 +68,20 @@ export const URL_CONSTANTS = {
|
||||
BY_ID: (id: string | number) => `/customers/${id}`,
|
||||
BOOKINGS: (id: string | number) => `/customers/${id}/bookings`,
|
||||
},
|
||||
|
||||
|
||||
CUSTOMERS_API: {
|
||||
BASE: "/api/customers",
|
||||
BY_ID: (id: string) => `/api/customers/${id}`,
|
||||
BY_USER_ID: (id: string) => `/api/customers/user/${id}`
|
||||
BY_USER_ID: (id: string) => `/api/customers/user/${id}`,
|
||||
},
|
||||
|
||||
OVERVIEW: {
|
||||
BASE: "/overview",
|
||||
BOOKINGS: "/overview/bookings",
|
||||
BILLING: "/overview/billing",
|
||||
OPERATIONS: "/overview/operations",
|
||||
CUSTOMERS: "/overview/customers",
|
||||
STAFF: "/overview/staff",
|
||||
},
|
||||
|
||||
BOOKINGS: {
|
||||
|
||||
@@ -26,6 +26,52 @@ export const useRuleEngineList = (
|
||||
queryFn: () => ruleEngineService.list(resource, params),
|
||||
});
|
||||
|
||||
const ORDER_LIST_PAGE_SIZE = 500;
|
||||
|
||||
export const useRuleEngineOrderList = (
|
||||
resource: RuleEngineResourceSlug,
|
||||
enabled: boolean,
|
||||
sortBy?: string,
|
||||
) =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.RULE_ENGINE.orderList(resource),
|
||||
queryFn: () =>
|
||||
ruleEngineService.list(resource, {
|
||||
page: 1,
|
||||
pageSize: ORDER_LIST_PAGE_SIZE,
|
||||
sortBy,
|
||||
sortOrder: "ASC",
|
||||
}),
|
||||
enabled,
|
||||
});
|
||||
|
||||
export const useRuleEngineOrderMutations = (resource: RuleEngineResourceSlug) => {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const reorder = useMutation({
|
||||
mutationFn: (payload: { ids: string[]; requiresDirectorApproval?: boolean }) =>
|
||||
ruleEngineService.reorder(resource, payload),
|
||||
onSuccess: async () => {
|
||||
toast.success("Order updated");
|
||||
await invalidateRuleEngineList(qc, resource);
|
||||
await qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.orderList(resource) });
|
||||
},
|
||||
onError: () => toast.error("Failed to update order"),
|
||||
});
|
||||
|
||||
const moveOrder = useMutation({
|
||||
mutationFn: ({ id, direction }: { id: string; direction: "up" | "down" }) =>
|
||||
ruleEngineService.moveOrder(resource, id, direction),
|
||||
onSuccess: async () => {
|
||||
await invalidateRuleEngineList(qc, resource);
|
||||
await qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.orderList(resource) });
|
||||
},
|
||||
onError: () => toast.error("Cannot move item further in that direction"),
|
||||
});
|
||||
|
||||
return { reorder, moveOrder };
|
||||
};
|
||||
|
||||
export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("cargo-types"),
|
||||
|
||||
52
apps/edr-freight-web/backoffice/src/hooks/useOverview.ts
Normal file
52
apps/edr-freight-web/backoffice/src/hooks/useOverview.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { overviewService } from "@/services/overview.service";
|
||||
import type { OverviewRange } from "@/types/overview";
|
||||
|
||||
export function useOverview(range: OverviewRange = "30d") {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.OVERVIEW.dashboard(range),
|
||||
queryFn: () => overviewService.getDashboard(range),
|
||||
});
|
||||
}
|
||||
|
||||
export function useOverviewBookingsTab(range: OverviewRange, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.OVERVIEW.bookingsTab(range),
|
||||
queryFn: () => overviewService.getBookingsTab(range),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useOverviewBillingTab(range: OverviewRange, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.OVERVIEW.billingTab(range),
|
||||
queryFn: () => overviewService.getBillingTab(range),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useOverviewOperationsTab(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.OVERVIEW.operationsTab(),
|
||||
queryFn: () => overviewService.getOperationsTab(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useOverviewCustomersTab(range: OverviewRange, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.OVERVIEW.customersTab(range),
|
||||
queryFn: () => overviewService.getCustomersTab(range),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useOverviewStaffTab(range: OverviewRange, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.OVERVIEW.staffTab(range),
|
||||
queryFn: () => overviewService.getStaffTab(range),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
@@ -1,11 +1,192 @@
|
||||
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Banknote,
|
||||
FileText,
|
||||
Train,
|
||||
UserCheck,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Container,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Tabs,
|
||||
} from "@mantine/core";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { OverviewPageHeader } from "@/components/overview/OverviewPageHeader";
|
||||
import { OverviewQuickLinks } from "@/components/overview/OverviewQuickLinks";
|
||||
import { OverviewTabContent } from "@/components/overview/OverviewTabContent";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { useOverview } from "@/hooks/useOverview";
|
||||
import type { OverviewRange, OverviewTabKey } from "@/types/overview";
|
||||
|
||||
const TAB_ITEMS: Array<{
|
||||
value: OverviewTabKey;
|
||||
label: string;
|
||||
icon: typeof FileText;
|
||||
kpiKey: "bookings" | "billing" | "operations" | "customers" | "staff";
|
||||
metricKey: string;
|
||||
}> = [
|
||||
{
|
||||
value: "bookings",
|
||||
label: "Bookings",
|
||||
icon: FileText,
|
||||
kpiKey: "bookings",
|
||||
metricKey: "totalActive",
|
||||
},
|
||||
{
|
||||
value: "billing",
|
||||
label: "Billing",
|
||||
icon: Banknote,
|
||||
kpiKey: "billing",
|
||||
metricKey: "successfulPaymentsMtd",
|
||||
},
|
||||
{
|
||||
value: "operations",
|
||||
label: "Operations",
|
||||
icon: Train,
|
||||
kpiKey: "operations",
|
||||
metricKey: "trainsActive",
|
||||
},
|
||||
{
|
||||
value: "customers",
|
||||
label: "Customers",
|
||||
icon: Users,
|
||||
kpiKey: "customers",
|
||||
metricKey: "totalCustomers",
|
||||
},
|
||||
{
|
||||
value: "staff",
|
||||
label: "Staff",
|
||||
icon: UserCheck,
|
||||
kpiKey: "staff",
|
||||
metricKey: "activeEmployees",
|
||||
},
|
||||
];
|
||||
|
||||
function HeaderSkeleton() {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Skeleton height={48} radius="md" />
|
||||
<Skeleton height={52} radius="lg" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const OverviewPage = () => {
|
||||
const [range, setRange] = useState<OverviewRange>("30d");
|
||||
const [activeTab, setActiveTab] = useState<OverviewTabKey>("bookings");
|
||||
const queryClient = useQueryClient();
|
||||
const { data: summary, isLoading, isError, refetch, isFetching } = useOverview(range);
|
||||
|
||||
const handleRefresh = () => {
|
||||
void refetch();
|
||||
void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.OVERVIEW.ROOT });
|
||||
};
|
||||
|
||||
const getTabBadge = (tab: (typeof TAB_ITEMS)[number]) => {
|
||||
if (!summary?.kpis) return 0;
|
||||
const group = summary.kpis[tab.kpiKey] as Record<string, number>;
|
||||
return group[tab.metricKey] ?? 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<FeaturePlaceholder
|
||||
title="Overview"
|
||||
description="Track internal freight operations, monitor account administration, and review the latest backoffice activity from a single operational dashboard."
|
||||
/>
|
||||
<Container fluid px="md" py="md">
|
||||
<Stack gap="lg">
|
||||
{isLoading && !summary ? (
|
||||
<HeaderSkeleton />
|
||||
) : (
|
||||
<OverviewPageHeader
|
||||
range={range}
|
||||
onRangeChange={setRange}
|
||||
generatedAt={summary?.generatedAt}
|
||||
onRefresh={handleRefresh}
|
||||
isRefreshing={isFetching && !isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<Alert
|
||||
icon={<AlertCircle size={16} />}
|
||||
color="red"
|
||||
title="Unable to load dashboard summary"
|
||||
variant="light"
|
||||
>
|
||||
<Stack gap="sm" align="flex-start">
|
||||
<span>Check your connection and try again.</span>
|
||||
<Button size="xs" variant="light" color="red" onClick={() => void refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper
|
||||
radius="lg"
|
||||
withBorder
|
||||
p="md"
|
||||
style={{
|
||||
background: "white",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(value) => setActiveTab((value as OverviewTabKey) ?? "bookings")}
|
||||
variant="pills"
|
||||
color="green"
|
||||
keepMounted={false}
|
||||
>
|
||||
<Tabs.List
|
||||
style={{
|
||||
flexWrap: "wrap",
|
||||
gap: 8,
|
||||
background: "var(--freight-brand-muted, #f0fdf4)",
|
||||
padding: 8,
|
||||
borderRadius: 12,
|
||||
}}
|
||||
>
|
||||
{TAB_ITEMS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<Tabs.Tab
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
leftSection={<Icon size={16} />}
|
||||
rightSection={
|
||||
summary ? (
|
||||
<Badge size="sm" variant="light" color="green">
|
||||
{getTabBadge(tab)}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
style={{ fontWeight: 600 }}
|
||||
>
|
||||
{tab.label}
|
||||
</Tabs.Tab>
|
||||
);
|
||||
})}
|
||||
</Tabs.List>
|
||||
|
||||
{TAB_ITEMS.map((tab) => (
|
||||
<Tabs.Panel key={tab.value} value={tab.value} pt="lg">
|
||||
<OverviewTabContent tab={tab.value} range={range} />
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
</Paper>
|
||||
|
||||
<Paper p="lg" radius="lg" withBorder>
|
||||
<OverviewQuickLinks />
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Navigate, useLocation, useParams } from "react-router-dom";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canAccessRuleEngineResource } from "@/lib/permissions";
|
||||
@@ -8,7 +8,10 @@ import { Card, Button, Modal, Stack, Group, Text, List } from "@mantine/core";
|
||||
|
||||
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
|
||||
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
||||
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
|
||||
import RuleEngineOrderControls from "@/components/ruleEngine/RuleEngineOrderControls";
|
||||
import RuleEngineRecordActions from "@/components/ruleEngine/RuleEngineRecordActions";
|
||||
import { getOrderItemLabel } from "@/components/ruleEngine/ruleEngineOrder.utils";
|
||||
import RuleEngineToolbar from "@/components/ruleEngine/RuleEngineToolbar";
|
||||
import { formatCell } from "@/components/ruleEngine/ruleEngineFormat";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
@@ -29,6 +32,8 @@ import {
|
||||
useRateWorkflow,
|
||||
useRuleEngineList,
|
||||
useRuleEngineMutations,
|
||||
useRuleEngineOrderList,
|
||||
useRuleEngineOrderMutations,
|
||||
} from "@/hooks/rule-engine/useRuleEngine";
|
||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||
import {
|
||||
@@ -65,6 +70,7 @@ const RuleEngineResourcePage = () => {
|
||||
const [editing, setEditing] = useState<RuleEngineRecord | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<RuleEngineRecord | null>(null);
|
||||
const [chainOpen, setChainOpen] = useState(false);
|
||||
const [orderDialogOpen, setOrderDialogOpen] = useState(false);
|
||||
const { viewMode, setViewMode } = useRuleEngineViewMode(
|
||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||
);
|
||||
@@ -81,10 +87,27 @@ const RuleEngineResourcePage = () => {
|
||||
search: config?.supportsSearch ? search.trim() || undefined : undefined,
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
...(config?.orderConfig
|
||||
? {
|
||||
sortBy: config.orderConfig.field,
|
||||
sortOrder: "ASC" as const,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
[config?.supportsSearch, search, pagination.pageIndex, pagination.pageSize],
|
||||
[
|
||||
config?.orderConfig,
|
||||
config?.supportsSearch,
|
||||
search,
|
||||
pagination.pageIndex,
|
||||
pagination.pageSize,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
|
||||
setSearch("");
|
||||
}, [config?.slug, setPagination]);
|
||||
|
||||
const { data, isLoading, isError, error } = useRuleEngineList(
|
||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||
listParams,
|
||||
@@ -93,6 +116,14 @@ const RuleEngineResourcePage = () => {
|
||||
const { create, update, remove } = useRuleEngineMutations(
|
||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||
);
|
||||
const { reorder, moveOrder } = useRuleEngineOrderMutations(
|
||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||
);
|
||||
const { data: orderListData, isLoading: orderListLoading } = useRuleEngineOrderList(
|
||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||
Boolean(orderDialogOpen && config?.orderConfig),
|
||||
config?.orderConfig?.field,
|
||||
);
|
||||
const { submit, approve } = useRateWorkflow();
|
||||
const { data: chainData, isLoading: chainLoading } = useApprovalChain(
|
||||
chainOpen && config?.slug === "approval-rules",
|
||||
@@ -149,25 +180,24 @@ const RuleEngineResourcePage = () => {
|
||||
const rows = data?.data ?? [];
|
||||
const meta = data?.meta;
|
||||
const pageCount = meta?.totalPages ?? 1;
|
||||
const totalCount = meta?.total ?? rows.length;
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
if (config?.supportsSearch || !search.trim()) return rows;
|
||||
const q = search.trim().toLowerCase();
|
||||
return rows.filter((row) =>
|
||||
JSON.stringify(row).toLowerCase().includes(q),
|
||||
);
|
||||
}, [rows, search, config?.supportsSearch]);
|
||||
|
||||
const paginationState = useMemo(
|
||||
() => ({
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: meta?.total ?? filteredRows.length,
|
||||
}),
|
||||
[filteredRows.length, meta?.total, pageCount, pagination.pageIndex, pagination.pageSize],
|
||||
const { data: createPositionList, isLoading: createPositionLoading } = useRuleEngineOrderList(
|
||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||
Boolean(formOpen && !editing && config?.orderConfig),
|
||||
config?.orderConfig?.field,
|
||||
);
|
||||
|
||||
const createPositionOptions = useMemo(() => {
|
||||
if (!config?.orderConfig || !createPositionList?.data?.length) return undefined;
|
||||
return createPositionList.data
|
||||
.filter((row) => row.id)
|
||||
.map((row) => ({
|
||||
label: getOrderItemLabel(row, config.slug),
|
||||
value: String(row.id),
|
||||
}));
|
||||
}, [config?.orderConfig, config?.slug, createPositionList?.data]);
|
||||
|
||||
|
||||
const handleApproveRate = useCallback(
|
||||
(record: RuleEngineRecord) => {
|
||||
@@ -176,6 +206,13 @@ const RuleEngineResourcePage = () => {
|
||||
[approve],
|
||||
);
|
||||
|
||||
const handleMoveOrder = useCallback(
|
||||
(id: string, direction: "up" | "down") => {
|
||||
moveOrder.mutate({ id, direction });
|
||||
},
|
||||
[moveOrder],
|
||||
);
|
||||
|
||||
const columns = useMemo((): ColumnDef<RuleEngineRecord>[] => {
|
||||
if (!config) return [];
|
||||
|
||||
@@ -192,36 +229,47 @@ const RuleEngineResourcePage = () => {
|
||||
base.push({
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
size: 140,
|
||||
minSize: 120,
|
||||
size: config.orderConfig ? 200 : 140,
|
||||
minSize: config.orderConfig ? 180 : 120,
|
||||
meta: {
|
||||
headerClassName,
|
||||
cellClassName: `${cellClassName} whitespace-nowrap`,
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<div onClick={(e) => e.stopPropagation()} data-stop-row-click>
|
||||
<RuleEngineRecordActions
|
||||
record={row.original}
|
||||
config={config}
|
||||
layout="row"
|
||||
readOnly={!canManage}
|
||||
onEdit={(record) => {
|
||||
setEditing(record);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
onDelete={setDeleteTarget}
|
||||
onViewChain={
|
||||
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
|
||||
}
|
||||
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
|
||||
onApproveRate={canManage ? handleApproveRate : undefined}
|
||||
/>
|
||||
<Group gap="xs" wrap="nowrap" justify="flex-end">
|
||||
{config.orderConfig && canManage ? (
|
||||
<RuleEngineOrderControls
|
||||
record={row.original}
|
||||
orderConfig={config.orderConfig}
|
||||
totalCount={totalCount}
|
||||
disabled={moveOrder.isPending}
|
||||
onMove={handleMoveOrder}
|
||||
/>
|
||||
) : null}
|
||||
<RuleEngineRecordActions
|
||||
record={row.original}
|
||||
config={config}
|
||||
layout="row"
|
||||
readOnly={!canManage}
|
||||
onEdit={(record) => {
|
||||
setEditing(record);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
onDelete={setDeleteTarget}
|
||||
onViewChain={
|
||||
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
|
||||
}
|
||||
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
|
||||
onApproveRate={canManage ? handleApproveRate : undefined}
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
return base;
|
||||
}, [canManage, config, submit, handleApproveRate]);
|
||||
}, [canManage, config, submit, handleApproveRate, handleMoveOrder, moveOrder.isPending, totalCount]);
|
||||
|
||||
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||
|
||||
@@ -276,13 +324,21 @@ const RuleEngineResourcePage = () => {
|
||||
<Stack gap="md">
|
||||
<RuleEngineToolbar
|
||||
search={search}
|
||||
onSearchChange={(v) => {
|
||||
setSearch(v);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
onSearchChange={
|
||||
config.supportsSearch
|
||||
? (v) => {
|
||||
setSearch(v);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
showSearch={Boolean(config.supportsSearch)}
|
||||
searchPlaceholder={config.searchPlaceholder}
|
||||
onAdd={canManage ? openCreate : undefined}
|
||||
addLabel={`Add ${config.label.replace(/s$/, "")}`}
|
||||
onManageOrder={
|
||||
canManage && config.orderConfig ? () => setOrderDialogOpen(true) : undefined
|
||||
}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
@@ -290,7 +346,7 @@ const RuleEngineResourcePage = () => {
|
||||
{viewMode === "table" ? (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredRows}
|
||||
data={rows}
|
||||
status={tableStatus}
|
||||
error={
|
||||
isError
|
||||
@@ -302,7 +358,12 @@ const RuleEngineResourcePage = () => {
|
||||
: undefined
|
||||
}
|
||||
emptyMessage={`No ${itemLabel} found.`}
|
||||
pagination={paginationState}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount,
|
||||
}}
|
||||
tableOptions={{
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
@@ -328,11 +389,14 @@ const RuleEngineResourcePage = () => {
|
||||
) : (
|
||||
<RuleEngineCardGrid
|
||||
config={config}
|
||||
rows={filteredRows}
|
||||
rows={rows}
|
||||
status={tableStatus}
|
||||
emptyMessage={`No ${itemLabel} found.`}
|
||||
itemLabel={itemLabel}
|
||||
pagination={paginationState}
|
||||
pagination={pagination}
|
||||
pageCount={pageCount}
|
||||
totalCount={totalCount}
|
||||
onPaginationChange={setPagination}
|
||||
readOnly={!canManage}
|
||||
onEdit={canManage ? openEdit : undefined}
|
||||
onDelete={canManage ? setDeleteTarget : undefined}
|
||||
@@ -363,9 +427,27 @@ const RuleEngineResourcePage = () => {
|
||||
(usesContainerTypeField && containerTypeOptionsLoading) ||
|
||||
(usesLiveRateField && liveRateOptionsLoading)
|
||||
}
|
||||
positionOptions={!editing ? createPositionOptions : undefined}
|
||||
positionLoading={createPositionLoading}
|
||||
onSubmit={handleFormSubmit}
|
||||
/>
|
||||
|
||||
{config.orderConfig ? (
|
||||
<ManageRuleEngineOrderDialog
|
||||
open={orderDialogOpen}
|
||||
onOpenChange={setOrderDialogOpen}
|
||||
config={config}
|
||||
items={orderListData?.data ?? []}
|
||||
isLoading={orderListLoading}
|
||||
isSaving={reorder.isPending}
|
||||
onSave={(payload) => {
|
||||
reorder.mutate(payload, {
|
||||
onSuccess: () => setOrderDialogOpen(false),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Modal
|
||||
opened={Boolean(deleteTarget)}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
|
||||
@@ -36,6 +36,12 @@ export interface FormFieldDef {
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface RuleEngineOrderConfig {
|
||||
field: "displayOrder" | "stepOrder";
|
||||
scopeField?: "requiresDirectorApproval";
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface RuleEngineResourceConfig {
|
||||
slug: RuleEngineResourceSlug;
|
||||
label: string;
|
||||
@@ -45,6 +51,7 @@ export interface RuleEngineResourceConfig {
|
||||
columns: ResourceColumn[];
|
||||
formFields: FormFieldDef[];
|
||||
supportsSearch?: boolean;
|
||||
orderConfig?: RuleEngineOrderConfig;
|
||||
/** Primary line on card view (inferred from columns when omitted). */
|
||||
cardTitleKey?: string;
|
||||
/** Secondary line under title on card view (inferred when omitted). */
|
||||
@@ -130,6 +137,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
subtitle: "Manage freight cargo classification and approval rules",
|
||||
searchPlaceholder: "Search cargo types by name or code...",
|
||||
supportsSearch: true,
|
||||
orderConfig: { field: "displayOrder", label: "Display order" },
|
||||
columns: [
|
||||
codeColumn("code"),
|
||||
{ id: "cargoTypeName", header: "Name", accessorKey: "cargoTypeName" },
|
||||
@@ -154,7 +162,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
{ name: "showFreeTextBox", label: "Show free text box", type: "boolean" },
|
||||
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
{ name: "displayOrder", label: "Display order", type: "number" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -163,9 +170,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
category: "configuration",
|
||||
subtitle: "Configure container sizes and wagon capacity",
|
||||
searchPlaceholder: "Search container types...",
|
||||
orderConfig: { field: "displayOrder", label: "Display order" },
|
||||
columns: [
|
||||
codeColumn("code"),
|
||||
{ id: "label", header: "Label", accessorKey: "label" },
|
||||
{ id: "displayOrder", header: "#", accessorKey: "displayOrder", format: "number" },
|
||||
{ id: "sizeFt", header: "Size (ft)", accessorKey: "sizeFt", format: "number" },
|
||||
{ id: "wagonsPerUnit", header: "Wagons / unit", accessorKey: "wagonsPerUnit", format: "number" },
|
||||
activeColumn,
|
||||
@@ -177,7 +186,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
{ name: "isReefer", label: "Reefer", type: "boolean" },
|
||||
{ name: "isOpenTop", label: "Open top", type: "boolean" },
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
{ name: "displayOrder", label: "Display order", type: "number" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -254,9 +262,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
subtitle: "Freight service offerings and booking options",
|
||||
searchPlaceholder: "Search service types...",
|
||||
supportsSearch: true,
|
||||
orderConfig: { field: "displayOrder", label: "Display order" },
|
||||
columns: [
|
||||
codeColumn("code"),
|
||||
{ id: "serviceName", header: "Service name", accessorKey: "serviceName" },
|
||||
{ id: "displayOrder", header: "#", accessorKey: "displayOrder", format: "number" },
|
||||
{ id: "priorityBonusPoints", header: "Bonus pts", accessorKey: "priorityBonusPoints", format: "number" },
|
||||
activeColumn,
|
||||
],
|
||||
@@ -269,7 +279,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
{ name: "includesCustoms", label: "Includes customs", type: "boolean" },
|
||||
{ name: "priorityBonusPoints", label: "Priority bonus points", type: "number" },
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
{ name: "displayOrder", label: "Display order", type: "number" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -350,6 +359,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
category: "configuration",
|
||||
subtitle: "Terminal and yard locations",
|
||||
searchPlaceholder: "Search yards...",
|
||||
orderConfig: { field: "displayOrder", label: "Display order" },
|
||||
columns: [
|
||||
codeColumn("code"),
|
||||
{ id: "label", header: "Label", accessorKey: "label" },
|
||||
@@ -361,7 +371,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
{ name: "label", label: "Label", type: "text", required: true },
|
||||
{ name: "country", label: "Country", type: "text", required: true },
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
{ name: "displayOrder", label: "Display order", type: "number" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -436,6 +445,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
cardSubtitleKey: "requiredRole",
|
||||
subtitle: "Multi-step booking approval chain",
|
||||
searchPlaceholder: "Search approval rules...",
|
||||
orderConfig: {
|
||||
field: "stepOrder",
|
||||
scopeField: "requiresDirectorApproval",
|
||||
label: "Step order",
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
id: "requiresDirectorApproval",
|
||||
@@ -450,7 +464,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
],
|
||||
formFields: [
|
||||
{ name: "requiresDirectorApproval", label: "Requires director approval chain", type: "boolean" },
|
||||
{ name: "stepOrder", label: "Step order", type: "number", required: true },
|
||||
{
|
||||
name: "requiredRole",
|
||||
label: "Required role",
|
||||
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
type RejectStepPayload,
|
||||
} from "./bookings.service";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import type { IOverviewDashboard, OverviewRange } from "@/types/overview";
|
||||
import { overviewService } from "./overview.service";
|
||||
|
||||
export const api = {
|
||||
fileUploadSettings: {
|
||||
@@ -233,6 +235,20 @@ export const api = {
|
||||
() => ruleEngineService.getApprovalChain(),
|
||||
() => QUERY_KEYS.RULE_ENGINE.chain,
|
||||
),
|
||||
|
||||
reorder: endpoint<
|
||||
{ resource: RuleEngineResourceSlug; payload: { ids: string[]; requiresDirectorApproval?: boolean } },
|
||||
void
|
||||
>("rule-engine", "reorder", ({ resource, payload }) =>
|
||||
ruleEngineService.reorder(resource, payload),
|
||||
),
|
||||
|
||||
moveOrder: endpoint<
|
||||
{ resource: RuleEngineResourceSlug; id: string; direction: "up" | "down" },
|
||||
void
|
||||
>("rule-engine", "moveOrder", ({ resource, id, direction }) =>
|
||||
ruleEngineService.moveOrder(resource, id, direction),
|
||||
),
|
||||
},
|
||||
|
||||
bookings: {
|
||||
@@ -329,4 +345,12 @@ export const api = {
|
||||
({ id, reason }) => bookingsService.cancel(id, reason),
|
||||
),
|
||||
},
|
||||
|
||||
overview: {
|
||||
get: endpoint<{ range?: OverviewRange }, IOverviewDashboard>(
|
||||
"overview",
|
||||
"get",
|
||||
({ range }) => overviewService.getDashboard(range),
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { api as client } from "../auth/http";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type {
|
||||
IOverviewBillingTab,
|
||||
IOverviewBookingsTab,
|
||||
IOverviewCustomersTab,
|
||||
IOverviewDashboard,
|
||||
IOverviewOperationsTab,
|
||||
IOverviewStaffTab,
|
||||
OverviewRange,
|
||||
} from "@/types/overview";
|
||||
|
||||
const O = URL_CONSTANTS.OVERVIEW;
|
||||
|
||||
export const overviewService = {
|
||||
getDashboard: async (range?: OverviewRange): Promise<IOverviewDashboard> => {
|
||||
const response = await client.get<IOverviewDashboard>(O.BASE, {
|
||||
params: range ? { range } : undefined,
|
||||
});
|
||||
return unwrap(response);
|
||||
},
|
||||
|
||||
getBookingsTab: async (range?: OverviewRange): Promise<IOverviewBookingsTab> => {
|
||||
const response = await client.get<IOverviewBookingsTab>(O.BOOKINGS, {
|
||||
params: range ? { range } : undefined,
|
||||
});
|
||||
return unwrap(response);
|
||||
},
|
||||
|
||||
getBillingTab: async (range?: OverviewRange): Promise<IOverviewBillingTab> => {
|
||||
const response = await client.get<IOverviewBillingTab>(O.BILLING, {
|
||||
params: range ? { range } : undefined,
|
||||
});
|
||||
return unwrap(response);
|
||||
},
|
||||
|
||||
getOperationsTab: async (): Promise<IOverviewOperationsTab> => {
|
||||
const response = await client.get<IOverviewOperationsTab>(O.OPERATIONS);
|
||||
return unwrap(response);
|
||||
},
|
||||
|
||||
getCustomersTab: async (range?: OverviewRange): Promise<IOverviewCustomersTab> => {
|
||||
const response = await client.get<IOverviewCustomersTab>(O.CUSTOMERS, {
|
||||
params: range ? { range } : undefined,
|
||||
});
|
||||
return unwrap(response);
|
||||
},
|
||||
|
||||
getStaffTab: async (range?: OverviewRange): Promise<IOverviewStaffTab> => {
|
||||
const response = await client.get<IOverviewStaffTab>(O.STAFF, {
|
||||
params: range ? { range } : undefined,
|
||||
});
|
||||
return unwrap(response);
|
||||
},
|
||||
};
|
||||
@@ -14,6 +14,14 @@ export interface RuleEngineListParams {
|
||||
pageSize?: number;
|
||||
isActive?: boolean;
|
||||
status?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
requiresDirectorApproval?: boolean;
|
||||
}
|
||||
|
||||
export interface RuleEngineReorderPayload {
|
||||
ids: string[];
|
||||
requiresDirectorApproval?: boolean;
|
||||
}
|
||||
|
||||
const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
|
||||
@@ -59,25 +67,39 @@ const byIdPath = (resource: RuleEngineResourceSlug, id: string): string => {
|
||||
}
|
||||
};
|
||||
|
||||
const defaultMeta = (dataLength: number, page = 1, pageSize = 20): RuleEngineListMeta => ({
|
||||
const defaultMeta = (dataLength: number, page = 1, pageSize = 10): RuleEngineListMeta => ({
|
||||
total: dataLength,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.max(1, Math.ceil(dataLength / pageSize)),
|
||||
});
|
||||
|
||||
const isPaginatedListResult = <T extends RuleEngineRecord>(
|
||||
value: unknown,
|
||||
): value is RuleEngineListResult<T> =>
|
||||
Boolean(value) &&
|
||||
typeof value === "object" &&
|
||||
"data" in value &&
|
||||
Array.isArray((value as RuleEngineListResult<T>).data);
|
||||
|
||||
const normalizeList = <T extends RuleEngineRecord>(
|
||||
payload: unknown,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
pageSize = 10,
|
||||
): RuleEngineListResult<T> => {
|
||||
if (isPaginatedListResult<T>(payload)) {
|
||||
return {
|
||||
data: payload.data,
|
||||
meta: payload.meta ?? defaultMeta(payload.data.length, page, pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
const body = unwrap(payload as { data: unknown }) as unknown;
|
||||
|
||||
if (body && typeof body === "object" && "data" in body && Array.isArray((body as RuleEngineListResult<T>).data)) {
|
||||
const typed = body as RuleEngineListResult<T>;
|
||||
if (isPaginatedListResult<T>(body)) {
|
||||
return {
|
||||
data: typed.data,
|
||||
meta: typed.meta ?? defaultMeta(typed.data.length, page, pageSize),
|
||||
data: body.data,
|
||||
meta: body.meta ?? defaultMeta(body.data.length, page, pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,7 +120,7 @@ export const ruleEngineService = {
|
||||
params?: RuleEngineListParams,
|
||||
): Promise<RuleEngineListResult<T>> => {
|
||||
const page = params?.page ?? 1;
|
||||
const pageSize = params?.pageSize ?? 20;
|
||||
const pageSize = params?.pageSize ?? 10;
|
||||
const response = await client.get(RESOURCE_BASE[resource], {
|
||||
params: {
|
||||
page,
|
||||
@@ -106,6 +128,9 @@ export const ruleEngineService = {
|
||||
search: params?.search,
|
||||
isActive: params?.isActive,
|
||||
status: params?.status,
|
||||
sortBy: params?.sortBy,
|
||||
sortOrder: params?.sortOrder,
|
||||
requiresDirectorApproval: params?.requiresDirectorApproval,
|
||||
},
|
||||
});
|
||||
return normalizeList<T>(response.data, page, pageSize);
|
||||
@@ -140,6 +165,21 @@ export const ruleEngineService = {
|
||||
await client.delete(byIdPath(resource, id));
|
||||
},
|
||||
|
||||
reorder: async (
|
||||
resource: RuleEngineResourceSlug,
|
||||
payload: RuleEngineReorderPayload,
|
||||
): Promise<void> => {
|
||||
await client.post(`${RESOURCE_BASE[resource]}/reorder`, payload);
|
||||
},
|
||||
|
||||
moveOrder: async (
|
||||
resource: RuleEngineResourceSlug,
|
||||
id: string,
|
||||
direction: "up" | "down",
|
||||
): Promise<void> => {
|
||||
await client.post(`${byIdPath(resource, id)}/move-order`, { direction });
|
||||
},
|
||||
|
||||
submitRate: async <T extends RuleEngineRecord>(id: string): Promise<T> => {
|
||||
const response = await client.post(URL_CONSTANTS.RULE_ENGINE.RATE_SUBMIT(id));
|
||||
return normalizeEntity<T>(response.data);
|
||||
|
||||
24
apps/edr-freight-web/backoffice/src/types/overview.ts
Normal file
24
apps/edr-freight-web/backoffice/src/types/overview.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export type {
|
||||
IOverviewDashboard,
|
||||
IOverviewKpis,
|
||||
IOverviewBookingKpis,
|
||||
IOverviewOperationsKpis,
|
||||
IOverviewCustomerKpis,
|
||||
IOverviewBillingKpis,
|
||||
IOverviewStaffKpis,
|
||||
IOverviewTrendPoint,
|
||||
IOverviewStatusCount,
|
||||
IOverviewPipelineCount,
|
||||
IOverviewPaymentTrendPoint,
|
||||
IOverviewRecentBooking,
|
||||
IOverviewLabelCount,
|
||||
IOverviewPaymentMethodBreakdown,
|
||||
IOverviewCurrencyAmount,
|
||||
IOverviewBookingsTab,
|
||||
IOverviewBillingTab,
|
||||
IOverviewOperationsTab,
|
||||
IOverviewCustomersTab,
|
||||
IOverviewStaffTab,
|
||||
OverviewRange,
|
||||
OverviewTabKey,
|
||||
} from "@edr/types/freight";
|
||||
Reference in New Issue
Block a user