feat: chat globale revisioni + pagina statistiche admin
- Chat revisioni: rimuovi commenti inline da timeline/kanban, aggiungi ChatSection con feed cronologico + selector task opzionale (Invio per inviare) Bolle stile chat: Tu (destra, giallo) / iamcavalli (sinistra, verde) Tag task su ogni messaggio quando il messaggio non è generale - API /api/client/comment: supporto entity_type "general" (entity_id = clientId) - Pagina /admin/analytics: year selector ←→, 4 metric card (contrattualizzato, incassato, da incassare, clienti acquisiti), bar chart mensile incassato via CSS - NavBar: link "Statistiche" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
|||||||
|
import { getAnalyticsByYear, getMonthlyCollected, getAvailableYears } from "@/lib/analytics-queries";
|
||||||
|
import { YearSelector, MonthlyChart } from "@/components/admin/YearSelector";
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
function fmt(n: number) {
|
||||||
|
return n.toLocaleString("it-IT", { style: "currency", currency: "EUR", minimumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricCardProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
sub?: string;
|
||||||
|
accent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCard({ label, value, sub, accent }: MetricCardProps) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl border p-5 ${accent ? "bg-[#1A463C] border-[#1A463C] text-white" : "bg-white border-[#e5e7eb]"}`}>
|
||||||
|
<p className={`text-xs font-bold uppercase tracking-wider mb-2 ${accent ? "text-white/60" : "text-[#71717a]"}`}>
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p className={`text-2xl font-bold tracking-tight ${accent ? "text-white" : "text-[#1a1a1a]"}`}>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
{sub && (
|
||||||
|
<p className={`text-xs mt-1 ${accent ? "text-white/60" : "text-[#71717a]"}`}>{sub}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AnalyticsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ year?: string }>;
|
||||||
|
}) {
|
||||||
|
const { year: yearParam } = await searchParams;
|
||||||
|
const year = parseInt(yearParam ?? "") || new Date().getFullYear();
|
||||||
|
|
||||||
|
const [data, monthly, availableYears] = await Promise.all([
|
||||||
|
getAnalyticsByYear(year),
|
||||||
|
getMonthlyCollected(year),
|
||||||
|
getAvailableYears(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const collectedPct = data.contracted > 0
|
||||||
|
? Math.round((data.collected / data.contracted) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-[#1a1a1a]">Statistiche</h1>
|
||||||
|
<p className="text-sm text-[#71717a] mt-0.5">Panoramica finanziaria per anno</p>
|
||||||
|
</div>
|
||||||
|
<YearSelector currentYear={year} availableYears={availableYears} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metrics grid */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<MetricCard
|
||||||
|
label="Contrattualizzato"
|
||||||
|
value={fmt(data.contracted)}
|
||||||
|
sub={`${data.clientsAcquired} client${data.clientsAcquired === 1 ? "e" : "i"} acquisit${data.clientsAcquired === 1 ? "o" : "i"}`}
|
||||||
|
accent
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Incassato"
|
||||||
|
value={fmt(data.collected)}
|
||||||
|
sub={`${collectedPct}% del contrattualizzato`}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Da incassare"
|
||||||
|
value={fmt(data.pending)}
|
||||||
|
sub="Pagamenti in sospeso (tutti gli anni)"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Clienti acquisiti"
|
||||||
|
value={String(data.clientsAcquired)}
|
||||||
|
sub={`Anno ${year}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Monthly chart */}
|
||||||
|
<MonthlyChart data={monthly} year={year} />
|
||||||
|
|
||||||
|
{data.contracted === 0 && (
|
||||||
|
<p className="text-sm text-[#71717a] italic text-center py-4">
|
||||||
|
Nessun cliente registrato nel {year}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,12 +9,12 @@ export function NavBar() {
|
|||||||
<nav className="bg-[#1A463C] px-6 py-3 flex items-center justify-between">
|
<nav className="bg-[#1A463C] px-6 py-3 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
<span className="font-bold text-white tracking-tight">iamcavalli</span>
|
<span className="font-bold text-white tracking-tight">iamcavalli</span>
|
||||||
<Link
|
<Link href="/admin" className="text-sm text-white/70 hover:text-white transition-colors">
|
||||||
href="/admin"
|
|
||||||
className="text-sm text-white/70 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
Clienti
|
Clienti
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/admin/analytics" className="text-sm text-white/70 hover:text-white transition-colors">
|
||||||
|
Statistiche
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
const MONTHS = ["Gen", "Feb", "Mar", "Apr", "Mag", "Giu", "Lug", "Ago", "Set", "Ott", "Nov", "Dic"];
|
||||||
|
|
||||||
|
export function YearSelector({
|
||||||
|
currentYear,
|
||||||
|
availableYears,
|
||||||
|
}: {
|
||||||
|
currentYear: number;
|
||||||
|
availableYears: number[];
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const thisYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
function go(y: number) {
|
||||||
|
router.push(`/admin/analytics?year=${y}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => go(currentYear - 1)}
|
||||||
|
className="w-8 h-8 rounded-lg border border-[#e5e7eb] flex items-center justify-center text-[#71717a] hover:border-[#1A463C] hover:text-[#1A463C] transition-colors"
|
||||||
|
aria-label="Anno precedente"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<span className="text-lg font-bold text-[#1a1a1a] tabular-nums w-14 text-center">
|
||||||
|
{currentYear}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => go(currentYear + 1)}
|
||||||
|
disabled={currentYear >= thisYear}
|
||||||
|
className="w-8 h-8 rounded-lg border border-[#e5e7eb] flex items-center justify-center text-[#71717a] hover:border-[#1A463C] hover:text-[#1A463C] transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
aria-label="Anno successivo"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MonthlyChart({ data, year }: { data: number[]; year: number }) {
|
||||||
|
const max = Math.max(...data, 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6">
|
||||||
|
<h3 className="text-sm font-bold text-[#1a1a1a] mb-6">Incassato mese per mese — {year}</h3>
|
||||||
|
<div className="flex items-end gap-2 h-40">
|
||||||
|
{data.map((val, i) => {
|
||||||
|
const pct = Math.round((val / max) * 100);
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex-1 flex flex-col items-center gap-1">
|
||||||
|
<div className="w-full flex flex-col justify-end" style={{ height: "120px" }}>
|
||||||
|
<div
|
||||||
|
className="w-full rounded-t-md bg-[#1A463C] transition-all"
|
||||||
|
style={{ height: `${pct}%`, minHeight: val > 0 ? "4px" : "0" }}
|
||||||
|
title={`€ ${val.toLocaleString("it-IT", { minimumFractionDigits: 2 })}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-[#71717a]">{MONTHS[i]}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user