Frontend: Update Admin Dashboard with new TimeserieBarChart and TimeserieBarChartStacked components for enhanced analytics visualization

This commit is contained in:
Marco Pedone 2025-08-05 19:23:55 +02:00
parent f08c1ef8d4
commit c426566cca
4 changed files with 268 additions and 50 deletions

View file

@ -4,7 +4,15 @@ import type { UsersId } from "~/schemas/public/Users";
import { ServizioContainer } from "~/components/servizio/main"; import { ServizioContainer } from "~/components/servizio/main";
import { AccordionComp } from "~/components/accordionComp"; import { AccordionComp } from "~/components/accordionComp";
import { useTranslation } from "~/providers/I18nProvider"; import { useTranslation } from "~/providers/I18nProvider";
import AnalyticsChart from "~/components/analyticsChart"; import TimeserieBarChart from "~/components/timeserieBarChart";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import TimeserieBarChartStacked from "~/components/timeserieBarChartStacked";
export const AdminDashboard = () => { export const AdminDashboard = () => {
const { data: stats, isLoading } = api.stats.adminDashStats.useQuery(); const { data: stats, isLoading } = api.stats.adminDashStats.useQuery();
@ -21,26 +29,36 @@ export const AdminDashboard = () => {
{!stats ? ( {!stats ? (
<div className="text-red-500">Errore caricamento statistiche</div> <div className="text-red-500">Errore caricamento statistiche</div>
) : ( ) : (
<div className="flex h-full w-full flex-col gap-4 px-2 pt-4"> <div className="grid grid-cols-1 gap-4 px-2 pt-4 sm:grid-cols-2 sm:px-4">
<div className="flex w-full justify-center"> <Card className="">
<AnalyticsChart <CardHeader>
<CardTitle>Utenti</CardTitle>
<CardDescription>
Numero totale di utenti registrati
</CardDescription>
</CardHeader>
<CardContent className="flex h-full items-center justify-center text-6xl font-bold">
{stats.userCount}
</CardContent>
</Card>
<TimeserieBarChart
title="Utenti registrati" title="Utenti registrati"
timeserie={stats.userTimeserie} timeserie={stats.userTimeserie}
/> />
</div>
<div className="flex w-full justify-center"> <TimeserieBarChartStacked
<AnalyticsChart title="Servizi creati"
title="Ordini creati" valueALabel={stats.serviziTimeserie.valueALabel}
timeserie={stats.orderTimeserie} valueBLabel={stats.serviziTimeserie.valueBLabel}
timeserie={stats.serviziTimeserie.data}
/> />
</div>
<div className="flex w-full justify-center"> <TimeserieBarChart
<AnalyticsChart
title="Contatti generati" title="Contatti generati"
timeserie={stats.contattiTimeserie} timeserie={stats.contattiTimeserie}
/> />
</div> </div>
</div>
)} )}
</div> </div>
</div> </div>

View file

@ -32,21 +32,21 @@ import { useTranslation } from "~/providers/I18nProvider";
const chartConfig = { const chartConfig = {
value: { value: {
label: "value", label: "Valore",
color: "var(--color-primary)", color: "var(--chart-2)",
}, },
} satisfies ChartConfig; } satisfies ChartConfig;
type AnalyticsChartProps = { type TimeserieBarChartProps = {
title: string; title: string;
description?: string; description?: string;
timeserie: { date: Date; value: number }[]; timeserie: { date: Date; value: number }[];
}; };
export default function AnalyticsChart({ export default function TimeserieBarChart({
timeserie, timeserie,
title, title,
description, description,
}: AnalyticsChartProps) { }: TimeserieBarChartProps) {
const { locale } = useTranslation(); const { locale } = useTranslation();
const startDate = timeserie[0] const startDate = timeserie[0]
@ -86,7 +86,7 @@ export default function AnalyticsChart({
<CalendarIcon /> <CalendarIcon />
{range?.from && range?.to {range?.from && range?.to
? `${range.from.toLocaleDateString()} - ${range.to.toLocaleDateString()}` ? `${range.from.toLocaleDateString()} - ${range.to.toLocaleDateString()}`
: "June 2025"} : "Seleziona un intervallo di date"}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="end"> <PopoverContent className="w-auto overflow-hidden p-0" align="end">
@ -137,15 +137,7 @@ export default function AnalyticsChart({
}); });
}} }}
/> />
<ChartTooltip <ChartTooltip content={<ChartTooltipContent hideLabel />} />
content={
<ChartTooltipContent
className="w-[150px]"
nameKey="value"
labelFormatter={() => "Utenti"}
/>
}
/>
<Bar dataKey="value" fill={`var(--color-value)`} radius={4} /> <Bar dataKey="value" fill={`var(--color-value)`} radius={4} />
</BarChart> </BarChart>
)} )}

View file

@ -0,0 +1,178 @@
"use client";
import * as React from "react";
import { CalendarIcon } from "lucide-react";
import { type DateRange } from "react-day-picker";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
import { Button } from "~/components/ui/button";
import { Calendar } from "~/components/ui/calendar";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "~/components/ui/chart";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import { cn } from "~/lib/utils";
import { enUS, it } from "date-fns/locale";
import { useTranslation } from "~/providers/I18nProvider";
const chartConfig = {
valueA: {
label: "valueA",
color: "var(--chart-2)",
},
valueB: {
label: "valueB",
color: "var(--chart-3)",
},
} satisfies ChartConfig;
export type StackedTimeserie = { date: Date; valueA: number; valueB: number };
type AnalyticsChartProps = {
title: string;
description?: string;
valueALabel: string;
valueBLabel: string;
timeserie: StackedTimeserie[];
};
export default function TimeserieBarChartStacked({
timeserie,
title,
valueALabel,
valueBLabel,
description,
}: AnalyticsChartProps) {
const { locale } = useTranslation();
const configs = chartConfig;
configs.valueA.label = valueALabel;
configs.valueB.label = valueBLabel;
const startDate = timeserie[0]
? new Date(timeserie[0].date)
: new Date(2025, 1, 1);
const endDate = timeserie[timeserie.length - 1]
? new Date(timeserie[timeserie.length - 1]!.date)
: new Date();
const [range, setRange] = React.useState<DateRange | undefined>({
from: startDate,
to: endDate,
});
const filteredData = React.useMemo(() => {
if (!range?.from && !range?.to) {
return timeserie;
}
return timeserie.filter((item) => {
return item.date >= range.from! && item.date <= range.to!;
});
}, [range]);
const DatePickerLocale = locale === "it" ? it : enUS;
return (
<Card className="@container/card w-full pb-4">
<CardHeader className="flex flex-col border-b @md/card:grid">
<CardTitle>{title}</CardTitle>
<CardDescription className={cn(!description && "sr-only")}>
{description || "Analytics for the selected period."}
</CardDescription>
<CardAction className="mt-2 @md/card:mt-0">
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">
<CalendarIcon />
{range?.from && range?.to
? `${range.from.toLocaleDateString()} - ${range.to.toLocaleDateString()}`
: "Seleziona un intervallo di date"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="end">
<Calendar
className="w-full"
locale={DatePickerLocale}
mode="range"
defaultMonth={range?.from}
selected={range}
onSelect={setRange}
fixedWeeks
showOutsideDays
/>
</PopoverContent>
</Popover>
</CardAction>
</CardHeader>
<CardContent className="px-4">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
{filteredData.length === 0 ? (
<div className="text-muted-foreground flex h-full items-center justify-center text-base">
Nessun dato disponibile per il periodo selezionato.
</div>
) : (
<BarChart
accessibilityLayer
data={filteredData}
margin={{
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={20}
tickFormatter={(value) => {
const date = new Date(String(value));
return date.toLocaleDateString("it-IT", {
day: "numeric",
month: "short",
});
}}
/>
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
<Bar
stackId="a"
dataKey="valueA"
fill={`var(--color-valueA)`}
radius={[0, 0, 4, 4]}
/>
<Bar
stackId="a"
dataKey="valueB"
fill={`var(--color-valueB)`}
radius={[4, 4, 0, 0]}
/>
</BarChart>
)}
</ChartContainer>
</CardContent>
<CardFooter className="flex justify-end border-t !pt-4">
<span className="text-muted-foreground text-sm">
Totale: <strong>{filteredData.reduce((acc) => acc + 1, 0)}</strong>
</span>
</CardFooter>
</Card>
);
}

View file

@ -1,6 +1,8 @@
import { adminProcedure, createTRPCRouter } from "~/server/api/trpc"; import { adminProcedure, createTRPCRouter } from "~/server/api/trpc";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { sql } from "kysely"; import { sql } from "kysely";
import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum";
import type { StackedTimeserie } from "~/components/timeserieBarChartStacked";
export const statsRouter = createTRPCRouter({ export const statsRouter = createTRPCRouter({
adminDashStats: adminProcedure.query(async () => { adminDashStats: adminProcedure.query(async () => {
@ -19,20 +21,42 @@ export const statsRouter = createTRPCRouter({
value: parseInt(String(item.value)), value: parseInt(String(item.value)),
})); }));
const orderTimeserie = ( const serviziTimeserie = {
valueALabel: "Transitorio",
valueBLabel: "Stabile",
data: Object.values(
(
await db await db
.selectFrom("ordini") .selectFrom("servizio")
.select((eb) => [ .select((eb) => [
sql<Date>`date_trunc('day', created_at)`.as("date"), sql<Date>`date_trunc('day', created_at)`.as("date"),
eb.fn.count<number>("ordine_id").as("value"), eb.fn.count<number>("servizio_id").as("value"),
"tipologia",
]) ])
.groupBy("date") .groupBy(["date", "tipologia"])
.orderBy("date") .orderBy("date")
.execute() .execute()
).map((item) => ({ ).reduce(
(acc, item) => {
const dateStr = item.date.toISOString().slice(0, 10);
if (!acc[dateStr]) {
acc[dateStr] = {
date: item.date, date: item.date,
value: parseInt(String(item.value)), valueA: 0,
})); valueB: 0,
};
}
if (item.tipologia === TipologiaPosizioneEnum.Transitorio) {
acc[dateStr].valueA = parseInt(String(item.value));
} else if (item.tipologia === TipologiaPosizioneEnum.Stabile) {
acc[dateStr].valueB = parseInt(String(item.value));
}
return acc;
},
{} as Record<string, StackedTimeserie>,
),
),
};
const contattiTimeserie = ( const contattiTimeserie = (
await db await db
@ -52,10 +76,16 @@ export const statsRouter = createTRPCRouter({
value: parseInt(String(item.value)), value: parseInt(String(item.value)),
})); }));
const userCount = await db
.selectFrom("users")
.select((eb) => [eb.fn.count<number>("id").as("count")])
.executeTakeFirstOrThrow();
return { return {
userTimeserie, userTimeserie,
orderTimeserie, userCount: userCount.count,
contattiTimeserie, contattiTimeserie,
serviziTimeserie,
}; };
}), }),
}); });