Files
Paperless-Contracts/frontend/src/routes/Dashboard.tsx
2025-10-11 01:17:31 +02:00

198 lines
7.3 KiB
TypeScript

import { Grid, Paper, Skeleton, Stack, Typography } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ResponsiveContainer, BarChart, Bar, Tooltip, XAxis, YAxis } from "recharts";
import { fetchContracts, fetchUpcomingDeadlines } from "../api/contracts";
import DeadlineList from "../components/DeadlineList";
import PageHeader from "../components/PageHeader";
import StatCard from "../components/StatCard";
import { Contract, UpcomingDeadline } from "../types";
import { formatCurrency } from "../utils/date";
function buildDeadlineSeries(deadlines: UpcomingDeadline[]) {
const grouped = new Map<string, number>();
deadlines.forEach((deadline) => {
if (!deadline.terminationDeadline) return;
const month = deadline.terminationDeadline.slice(0, 7);
grouped.set(month, (grouped.get(month) ?? 0) + 1);
});
return Array.from(grouped.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, count]) => ({ month, count }));
}
function countActiveContracts(contracts: Contract[]): number {
const now = new Date();
return contracts.filter((contract) => {
if (!contract.contractEndDate) {
return true;
}
const end = new Date(`${contract.contractEndDate}T00:00:00Z`);
return end >= now;
}).length;
}
function calcMonthlySpend(contracts: Contract[]): number {
return contracts.reduce((total, contract) => {
if (!contract.price) return total;
return total + contract.price;
}, 0);
}
export default function Dashboard() {
const {
data: contracts,
isLoading: loadingContracts,
isError: errorContracts
} = useQuery({
queryKey: ["contracts", "dashboard"],
queryFn: () => fetchContracts({ limit: 200 })
});
const {
data: deadlines,
isLoading: loadingDeadlines,
isError: errorDeadlines
} = useQuery({
queryKey: ["deadlines", 60],
queryFn: () => fetchUpcomingDeadlines(60)
});
const normalizedContracts = useMemo(() => {
if (!contracts) return [] as Contract[];
if (Array.isArray(contracts)) return contracts as Contract[];
if (typeof (contracts as any).results === "object" && Array.isArray((contracts as any).results)) {
return (contracts as any).results as Contract[];
}
return [] as Contract[];
}, [contracts]);
const normalizedDeadlines = useMemo(() => {
if (!deadlines) return [] as UpcomingDeadline[];
if (Array.isArray(deadlines)) return deadlines as UpcomingDeadline[];
if (typeof (deadlines as any).results === "object" && Array.isArray((deadlines as any).results)) {
return (deadlines as any).results as UpcomingDeadline[];
}
return [] as UpcomingDeadline[];
}, [deadlines]);
const activeContracts = useMemo(() => countActiveContracts(normalizedContracts), [normalizedContracts]);
const monthlySpend = useMemo(() => calcMonthlySpend(normalizedContracts), [normalizedContracts]);
const deadlineSeries = useMemo(() => buildDeadlineSeries(normalizedDeadlines), [normalizedDeadlines]);
const { t } = useTranslation();
return (
<>
<PageHeader
title={t("dashboard.title")}
subtitle={t("dashboard.subtitle")}
/>
<Grid container spacing={3}>
<Grid item xs={12} md={4}>
{loadingContracts ? (
<Skeleton variant="rectangular" height={140} sx={{ borderRadius: 3 }} />
) : errorContracts ? (
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3 }}>
<Typography variant="body2" color="error">
{t("dashboard.contractsError")}
</Typography>
</Paper>
) : (
<StatCard
title={t("dashboard.totalContracts")}
value={contracts?.length ?? 0}
trend={contracts && contracts.length > 0 ? "up" : undefined}
trendLabel={contracts && contracts.length > 0 ? t("dashboard.totalContractsTrend") : undefined}
/>
)}
</Grid>
<Grid item xs={12} md={4}>
{loadingContracts ? (
<Skeleton variant="rectangular" height={140} sx={{ borderRadius: 3 }} />
) : errorContracts ? (
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3 }}>
<Typography variant="body2" color="error">
{t("dashboard.contractsError")}
</Typography>
</Paper>
) : (
<StatCard title={t("dashboard.activeContracts") } value={activeContracts} />
)}
</Grid>
<Grid item xs={12} md={4}>
{loadingContracts ? (
<Skeleton variant="rectangular" height={140} sx={{ borderRadius: 3 }} />
) : errorContracts ? (
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3 }}>
<Typography variant="body2" color="error">
{t("dashboard.contractsError")}
</Typography>
</Paper>
) : (
<StatCard
title={t("dashboard.monthlySpend")}
value={formatCurrency(monthlySpend)}
trend={monthlySpend > 0 ? "up" : undefined}
trendLabel={monthlySpend > 0 ? t("dashboard.monthlySpendTrend") : undefined}
/>
)}
</Grid>
</Grid>
<Grid container spacing={3} mt={1}>
<Grid item xs={12} md={7}>
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3, minHeight: 320 }}>
<Typography variant="h6" gutterBottom>
{t("dashboard.deadlineChartTitle")}
</Typography>
{loadingDeadlines ? (
<Stack spacing={2} mt={2}>
<Skeleton variant="rectangular" height={32} />
<Skeleton variant="rectangular" height={32} />
<Skeleton variant="rectangular" height={32} />
</Stack>
) : errorDeadlines ? (
<Typography variant="body2" color="error">
{t("dashboard.deadlinesError")}
</Typography>
) : deadlines && deadlines.length > 0 ? (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={deadlineSeries}>
<XAxis dataKey="month" />
<YAxis allowDecimals={false} />
<Tooltip />
<Bar dataKey="count" fill="#556cd6" radius={[8, 8, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<Typography variant="body2" color="text.secondary">
{t("dashboard.noDeadlines")}
</Typography>
)}
</Paper>
</Grid>
<Grid item xs={12} md={5}>
{loadingDeadlines ? (
<Stack spacing={2} mt={1}>
<Skeleton variant="rectangular" height={88} />
<Skeleton variant="rectangular" height={88} />
<Skeleton variant="rectangular" height={88} />
</Stack>
) : errorDeadlines ? (
<Paper variant="outlined" sx={{ borderRadius: 3, p: 3 }}>
<Typography variant="body2" color="error">
{t("dashboard.deadlinesError")}
</Typography>
</Paper>
) : (
<DeadlineList deadlines={deadlines ?? []} />
)}
</Grid>
</Grid>
</>
);
}