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 { 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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
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 { 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,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue