add reports module with controller, service, and repository

This commit is contained in:
Marshal
2026-08-03 21:43:23 +00:00
parent e68bdb7a1a
commit c3979b08b6
26 changed files with 1771 additions and 136 deletions

View File

@@ -1,90 +0,0 @@
import { useNavigate } from "react-router-dom";
import { ArrowRight, FileText, Train, Users } from "lucide-react";
import { Card, Group, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
const links = [
{
title: "Booking requests",
description: "Review and action incoming freight bookings",
href: "/dashboard/booking-requests",
icon: FileText,
permission: [FREIGHT_PERMS.bookings.view],
},
{
title: "Train scheduling v2",
description: "Full allocation workflow — assign, pin wagons, finalize",
href: "/dashboard/operations/train-scheduling-v2",
icon: Train,
permission: [FREIGHT_PERMS.trainScheduling.view],
},
{
title: "Trains",
description: "Manage train master data and fleet status",
href: "/dashboard/trains",
icon: Train,
permission: [FREIGHT_PERMS.fleet.view, FREIGHT_PERMS.trains.view],
},
{
title: "User management",
description: "Employees, roles, and permissions",
href: "/user-management",
icon: Users,
permission: [
FREIGHT_PERMS.admin,
FREIGHT_PERMS.staff.roles.view,
FREIGHT_PERMS.staff.employeeRegistration.view,
FREIGHT_PERMS.staff.roleAssignment.view,
],
},
];
export function OverviewQuickLinks() {
const navigate = useNavigate();
const { user } = useAuth();
const visible = links.filter((link) =>
link.permission.some((key) => hasPermission(user, key)),
);
if (!visible.length) return null;
return (
<Stack gap="md" h="100%">
<Text fw={600}>Quick links</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
{visible.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="edr-green" size="lg" radius="md">
<Icon size={18} />
</ThemeIcon>
<Stack gap={2}>
<Text fw={600} size="sm">
{link.title}
</Text>
<Text size="xs" c="dimmed">
{link.description}
</Text>
</Stack>
</Group>
<ArrowRight size={16} color="var(--mantine-color-gray-5)" />
</Group>
</Card>
);
})}
</SimpleGrid>
</Stack>
);
}

View File

@@ -14,6 +14,7 @@ import { OverviewBillingTabPanel } from "./tabs/OverviewBillingTabPanel";
import { OverviewBookingsTabPanel } from "./tabs/OverviewBookingsTabPanel";
import { OverviewContractsTabPanel } from "./tabs/OverviewContractsTabPanel";
import { OverviewCustomersTabPanel } from "./tabs/OverviewCustomersTabPanel";
import { OverviewFleetTabPanel } from "./tabs/OverviewFleetTabPanel";
import { OverviewOperationsTabPanel } from "./tabs/OverviewOperationsTabPanel";
import { OverviewStaffTabPanel } from "./tabs/OverviewStaffTabPanel";
@@ -36,7 +37,12 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
const bookings = useOverviewBookingsTab(range, tab === "bookings");
const contracts = useOverviewContractsTab(range, tab === "contracts");
const billing = useOverviewBillingTab(range, tab === "billing");
const operations = useOverviewOperationsTab(range, tab === "operations");
// Fleet reuses the operations dataset — same query key, so switching between
// the two tabs costs one fetch.
const operations = useOverviewOperationsTab(
range,
tab === "operations" || tab === "fleet",
);
const customers = useOverviewCustomersTab(range, tab === "customers");
const staff = useOverviewStaffTab(range, tab === "staff");
@@ -47,7 +53,7 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
? contracts
: tab === "billing"
? billing
: tab === "operations"
: tab === "operations" || tab === "fleet"
? operations
: tab === "customers"
? customers
@@ -99,6 +105,9 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
{tab === "operations" && operations.data && (
<OverviewOperationsTabPanel data={operations.data} />
)}
{tab === "fleet" && operations.data && (
<OverviewFleetTabPanel data={operations.data} />
)}
{tab === "customers" && customers.data && (
<OverviewCustomersTabPanel data={customers.data} />
)}

View File

@@ -24,6 +24,13 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps
<Stack gap="lg">
<OverviewKpiStrip
items={[
{
label: "Total bookings",
value: data.kpis.total,
icon: FileText,
accent: "gold",
hint: "All time",
},
{
label: "Active bookings",
value: data.kpis.totalActive,

View File

@@ -41,6 +41,13 @@ export function OverviewContractsTabPanel({
<Stack gap="lg">
<OverviewKpiStrip
items={[
{
label: "Total contracts",
value: data.kpis.total,
icon: FileSignature,
accent: "gold",
hint: "All time",
},
{
label: "Active contracts",
value: data.kpis.totalActive,

View File

@@ -0,0 +1,115 @@
import { Train, Truck, Wrench } from "lucide-react";
import { Grid, Stack } from "@mantine/core";
import type { IOverviewOperationsTab, IOverviewStatusCount } from "@/types/overview";
import { OverviewDonutChart } from "../OverviewDonutChart";
import { OverviewHorizontalBarChart } from "../OverviewHorizontalBarChart";
import { OverviewKpiStrip } from "../OverviewKpiStrip";
function formatStatusLabel(status: string) {
return status
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (char) => char.toUpperCase());
}
function sumCounts(items: IOverviewStatusCount[]) {
return items.reduce((sum, item) => sum + item.count, 0);
}
function countByStatus(items: IOverviewStatusCount[], status: string) {
return items.find((item) => item.status === status)?.count ?? 0;
}
function toDonutData(items: IOverviewStatusCount[]) {
return items.map((item) => ({
name: formatStatusLabel(item.status),
value: item.count,
}));
}
interface OverviewFleetTabPanelProps {
data: IOverviewOperationsTab;
}
export function OverviewFleetTabPanel({ data }: OverviewFleetTabPanelProps) {
return (
<Stack gap="lg">
<OverviewKpiStrip
items={[
{
label: "Total trains",
value: sumCounts(data.trainStatusBreakdown),
icon: Train,
accent: "gold",
hint: "All time",
},
{
label: "Active trains",
value: data.kpis.trainsActive,
icon: Train,
accent: "emerald",
},
{
label: "Total wagons",
value: sumCounts(data.wagonStatusBreakdown),
icon: Truck,
accent: "gold",
hint: "All time",
},
{
label: "Wagons available",
value: data.kpis.wagonsAvailable,
icon: Truck,
accent: "emerald",
},
{
label: "In maintenance",
value: countByStatus(data.wagonStatusBreakdown, "MAINTENANCE"),
icon: Wrench,
accent: "amber",
},
]}
/>
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewHorizontalBarChart
title="Wagon fleet by type"
data={data.wagonsByType.map((item) => ({
label: item.label,
value: item.count,
}))}
valueLabel="Wagons"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewHorizontalBarChart
title="Wagons by yard"
data={data.wagonsByYard.map((item) => ({
label: item.label,
value: item.count,
}))}
valueLabel="Wagons"
emptyMessage="No wagons assigned to yards"
/>
</Grid.Col>
</Grid>
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="Train status"
data={toDonutData(data.trainStatusBreakdown)}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="Wagon status"
data={toDonutData(data.wagonStatusBreakdown)}
/>
</Grid.Col>
</Grid>
</Stack>
);
}

View File

@@ -105,30 +105,6 @@ export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelP
</Grid.Col>
</Grid>
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewHorizontalBarChart
title="Wagon fleet by type"
data={data.wagonsByType.map((item) => ({
label: item.label,
value: item.count,
}))}
valueLabel="Wagons"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewHorizontalBarChart
title="Wagons by yard"
data={data.wagonsByYard.map((item) => ({
label: item.label,
value: item.count,
}))}
valueLabel="Wagons"
emptyMessage="No wagons assigned to yards"
/>
</Grid.Col>
</Grid>
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewHorizontalBarChart
@@ -153,18 +129,6 @@ export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelP
</Grid>
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="Train status"
data={toDonutData(data.trainStatusBreakdown)}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="Wagon status"
data={toDonutData(data.wagonStatusBreakdown)}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="Container status"