Frontend: Update Admin Dashboard with new TimeserieBarChart and TimeserieBarChartStacked components for enhanced analytics visualization
This commit is contained in:
parent
f08c1ef8d4
commit
c426566cca
4 changed files with 268 additions and 50 deletions
|
|
@ -4,7 +4,15 @@ import type { UsersId } from "~/schemas/public/Users";
|
|||
import { ServizioContainer } from "~/components/servizio/main";
|
||||
import { AccordionComp } from "~/components/accordionComp";
|
||||
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 = () => {
|
||||
const { data: stats, isLoading } = api.stats.adminDashStats.useQuery();
|
||||
|
|
@ -21,26 +29,36 @@ export const AdminDashboard = () => {
|
|||
{!stats ? (
|
||||
<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="flex w-full justify-center">
|
||||
<AnalyticsChart
|
||||
<div className="grid grid-cols-1 gap-4 px-2 pt-4 sm:grid-cols-2 sm:px-4">
|
||||
<Card className="">
|
||||
<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"
|
||||
timeserie={stats.userTimeserie}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full justify-center">
|
||||
<AnalyticsChart
|
||||
title="Ordini creati"
|
||||
timeserie={stats.orderTimeserie}
|
||||
|
||||
<TimeserieBarChartStacked
|
||||
title="Servizi creati"
|
||||
valueALabel={stats.serviziTimeserie.valueALabel}
|
||||
valueBLabel={stats.serviziTimeserie.valueBLabel}
|
||||
timeserie={stats.serviziTimeserie.data}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full justify-center">
|
||||
<AnalyticsChart
|
||||
|
||||
<TimeserieBarChart
|
||||
title="Contatti generati"
|
||||
timeserie={stats.contattiTimeserie}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -32,21 +32,21 @@ import { useTranslation } from "~/providers/I18nProvider";
|
|||
|
||||
const chartConfig = {
|
||||
value: {
|
||||
label: "value",
|
||||
color: "var(--color-primary)",
|
||||
label: "Valore",
|
||||
color: "var(--chart-2)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
type AnalyticsChartProps = {
|
||||
type TimeserieBarChartProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
timeserie: { date: Date; value: number }[];
|
||||
};
|
||||
export default function AnalyticsChart({
|
||||
export default function TimeserieBarChart({
|
||||
timeserie,
|
||||
title,
|
||||
description,
|
||||
}: AnalyticsChartProps) {
|
||||
}: TimeserieBarChartProps) {
|
||||
const { locale } = useTranslation();
|
||||
|
||||
const startDate = timeserie[0]
|
||||
|
|
@ -86,7 +86,7 @@ export default function AnalyticsChart({
|
|||
<CalendarIcon />
|
||||
{range?.from && range?.to
|
||||
? `${range.from.toLocaleDateString()} - ${range.to.toLocaleDateString()}`
|
||||
: "June 2025"}
|
||||
: "Seleziona un intervallo di date"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto overflow-hidden p-0" align="end">
|
||||
|
|
@ -137,15 +137,7 @@ export default function AnalyticsChart({
|
|||
});
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
className="w-[150px]"
|
||||
nameKey="value"
|
||||
labelFormatter={() => "Utenti"}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
|
||||
<Bar dataKey="value" fill={`var(--color-value)`} radius={4} />
|
||||
</BarChart>
|
||||
)}
|
||||
178
apps/infoalloggi/src/components/timeserieBarChartStacked.tsx
Normal file
178
apps/infoalloggi/src/components/timeserieBarChartStacked.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { adminProcedure, createTRPCRouter } from "~/server/api/trpc";
|
||||
import { db } from "~/server/db";
|
||||
import { sql } from "kysely";
|
||||
import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum";
|
||||
import type { StackedTimeserie } from "~/components/timeserieBarChartStacked";
|
||||
|
||||
export const statsRouter = createTRPCRouter({
|
||||
adminDashStats: adminProcedure.query(async () => {
|
||||
|
|
@ -19,20 +21,42 @@ export const statsRouter = createTRPCRouter({
|
|||
value: parseInt(String(item.value)),
|
||||
}));
|
||||
|
||||
const orderTimeserie = (
|
||||
const serviziTimeserie = {
|
||||
valueALabel: "Transitorio",
|
||||
valueBLabel: "Stabile",
|
||||
data: Object.values(
|
||||
(
|
||||
await db
|
||||
.selectFrom("ordini")
|
||||
.selectFrom("servizio")
|
||||
.select((eb) => [
|
||||
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")
|
||||
.execute()
|
||||
).map((item) => ({
|
||||
).reduce(
|
||||
(acc, item) => {
|
||||
const dateStr = item.date.toISOString().slice(0, 10);
|
||||
if (!acc[dateStr]) {
|
||||
acc[dateStr] = {
|
||||
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 = (
|
||||
await db
|
||||
|
|
@ -52,10 +76,16 @@ export const statsRouter = createTRPCRouter({
|
|||
value: parseInt(String(item.value)),
|
||||
}));
|
||||
|
||||
const userCount = await db
|
||||
.selectFrom("users")
|
||||
.select((eb) => [eb.fn.count<number>("id").as("count")])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return {
|
||||
userTimeserie,
|
||||
orderTimeserie,
|
||||
userCount: userCount.count,
|
||||
contattiTimeserie,
|
||||
serviziTimeserie,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue