198 lines
7.3 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|