initial
This commit is contained in:
197
frontend/src/routes/Dashboard.tsx
Normal file
197
frontend/src/routes/Dashboard.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user