first commit

This commit is contained in:
Alessio
2026-06-02 04:02:59 +02:00
parent 368f69fdce
commit 8e5f4aafa2
148 changed files with 10590 additions and 110 deletions

View File

@@ -0,0 +1,35 @@
import { DashboardStatCard } from "@/components/dashboard/DashboardStatCard";
import { euroFormatter } from "@/lib/finance/money";
export function DashboardCards({
summary,
scope,
}: {
summary: {
income: number;
realSpent: number;
projectedSpent: number;
projectedRemainingBalance: number;
};
scope: "anno" | "mese";
}) {
const cards = [
[`Entrate ${scope}`, summary.income],
[`Spesa reale ${scope}`, summary.realSpent],
[`Previsione ${scope}`, summary.projectedSpent],
["Saldo previsto", summary.projectedRemainingBalance],
];
return (
<div className="grid grid-cols-4 gap-2 md:grid-cols-2 md:gap-3 xl:grid-cols-4">
{cards.map(([label, value]) => (
<DashboardStatCard
desktopClassName="md:col-span-1"
key={label}
label={String(label)}
mobileSpan={2}
value={euroFormatter.format(Number(value))}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { DashboardStatCard } from "@/components/dashboard/DashboardStatCard";
import { euroFormatter } from "@/lib/finance/money";
export function DashboardKpiRow({
savingsRate,
recurringCost,
billTotal,
activeSubscriptions,
scope,
}: {
savingsRate: number;
recurringCost: number;
billTotal: number;
activeSubscriptions: number;
scope: "anno" | "mese";
}) {
return (
<section className="grid grid-cols-4 gap-2 md:gap-3">
<DashboardStatCard
desktopClassName="md:col-span-1"
label="Risparmio"
mobileSpan={2}
tone="green"
value={`${(savingsRate * 100).toFixed(1)}%`}
/>
<DashboardStatCard
desktopClassName="md:col-span-1"
label={`Costo ricorrente ${scope}`}
mobileSpan={2}
tone="yellow"
value={euroFormatter.format(recurringCost)}
/>
<DashboardStatCard
desktopClassName="md:col-span-1"
label={`Totale bollette ${scope}`}
mobileSpan={2}
value={euroFormatter.format(billTotal)}
/>
<DashboardStatCard
desktopClassName="md:col-span-1"
label={scope === "anno" ? "Abbonamenti attivi" : "Abbonamenti nel mese"}
mobileSpan={2}
tone="violet"
value={activeSubscriptions}
/>
</section>
);
}

View File

@@ -0,0 +1,104 @@
"use client";
import { useMemo, useState } from "react";
import { DashboardCards } from "@/components/dashboard/DashboardCards";
import { DashboardKpiRow } from "@/components/dashboard/DashboardKpiRow";
import { ExpenseDistribution } from "@/components/dashboard/ExpenseDistribution";
import { UpcomingPayments } from "@/components/dashboard/UpcomingPayments";
import { YearSelector } from "@/components/dashboard/YearSelector";
import { PageShell } from "@/components/shared/PageShell";
import {
useBillsResource,
useIncomeResource,
useLedgerResource,
useSubscriptionPaymentsResource,
useSubscriptionsResource,
} from "@/components/shared/useFinanceData";
import { buildYearlyDashboardSummary } from "@/lib/finance/yearlySummary";
export function DashboardPageClient() {
const subscriptions = useSubscriptionsResource();
const subscriptionPayments = useSubscriptionPaymentsResource();
const bills = useBillsResource();
const ledger = useLedgerResource();
const income = useIncomeResource();
const [year, setYear] = useState(() => new Date().getFullYear());
const loading = [
subscriptions,
subscriptionPayments,
bills,
ledger,
income,
].some((resource) => resource.loading);
const error = [
subscriptions,
subscriptionPayments,
bills,
ledger,
income,
].find((resource) => resource.error)?.error;
const yearlySummary = useMemo(
() =>
buildYearlyDashboardSummary({
year,
ledger: ledger.data ?? [],
income: income.data ?? [],
subscriptions: subscriptions.data ?? [],
subscriptionPayments: subscriptionPayments.data ?? [],
bills: bills.data ?? [],
}),
[
bills.data,
income.data,
ledger.data,
year,
subscriptionPayments.data,
subscriptions.data,
],
);
const summary = {
income: yearlySummary.yearlyIncome,
realSpent: yearlySummary.realSpent,
projectedSpent: yearlySummary.projectedSpent,
projectedRemainingBalance: yearlySummary.projectedRemainingBalance,
savingsRate: yearlySummary.savingsRate,
};
return (
<PageShell
title="Dashboard"
description="Panoramica finanziaria annuale reale e prevista."
>
{loading ? (
<p className="text-sm text-slate-400">Caricamento dati...</p>
) : null}
{error ? <p className="text-sm text-rose-300">{error}</p> : null}
<section className="grid gap-4 rounded-lg border border-white/10 bg-white/[0.03] p-4">
<YearSelector onChange={setYear} year={year} />
<div className="grid gap-3">
<DashboardCards scope="anno" summary={summary} />
<DashboardKpiRow
activeSubscriptions={yearlySummary.activeSubscriptions}
billTotal={yearlySummary.annualBillTotal}
recurringCost={yearlySummary.annualRecurringCost}
savingsRate={summary.savingsRate}
scope="anno"
/>
</div>
<div className="grid items-stretch gap-4 xl:grid-cols-[minmax(0,1.12fr)_minmax(22rem,0.88fr)]">
<UpcomingPayments
bills={bills.data ?? []}
subscriptionPayments={subscriptionPayments.data ?? []}
subscriptions={subscriptions.data ?? []}
/>
<ExpenseDistribution
items={yearlySummary.expenseDistribution.items}
periodLabel={`l'anno ${year}`}
total={yearlySummary.expenseDistribution.total}
year={year}
/>
</div>
</section>
</PageShell>
);
}

View File

@@ -0,0 +1,62 @@
type StatTone = "default" | "green" | "yellow" | "red" | "violet";
type MobileSpan = 1 | 2 | 4;
const spanClasses: Record<MobileSpan, string> = {
1: "col-span-1",
2: "col-span-2",
4: "col-span-4",
};
const tones: Record<StatTone, { border: string; value: string }> = {
default: {
border: "border-white/10",
value: "text-white",
},
green: {
border: "border-emerald-400/20",
value: "text-emerald-200",
},
yellow: {
border: "border-amber-400/20",
value: "text-amber-200",
},
red: {
border: "border-rose-400/20",
value: "text-rose-200",
},
violet: {
border: "border-violet-400/20",
value: "text-violet-200",
},
};
export function DashboardStatCard({
label,
value,
mobileSpan = 1,
tone = "default",
desktopClassName = "",
}: {
label: string;
value: React.ReactNode;
mobileSpan?: MobileSpan;
tone?: StatTone;
desktopClassName?: string;
}) {
const style = tones[tone];
return (
<div
className={`${spanClasses[mobileSpan]} min-w-0 overflow-hidden rounded-lg border bg-white/[0.04] ${style.border} ${desktopClassName}`}
>
<p className="px-2 pt-2 text-[10px] leading-tight text-slate-400 md:px-4 md:pt-3 md:text-sm md:leading-normal">
{label}
</p>
<div
className={`flex min-h-10 items-center justify-center px-1 pb-2 pt-1 text-center text-lg font-semibold leading-none md:min-h-14 md:px-4 md:pb-3 md:pt-2 md:text-2xl ${style.value}`}
>
{value}
</div>
</div>
);
}

View File

@@ -0,0 +1,126 @@
"use client";
import Link from "next/link";
import { LuArrowRight } from "react-icons/lu";
import { EmptyState } from "@/components/shared/EmptyState";
import type { ExpenseDistributionItem } from "@/lib/finance/expenseDistribution";
import { euroFormatter } from "@/lib/finance/money";
export function ExpenseDistribution({
items,
total,
year,
periodLabel,
showReportLink = true,
}: {
items: ExpenseDistributionItem[];
total: number;
year: number;
periodLabel?: string;
showReportLink?: boolean;
}) {
return (
<section className="flex min-h-full flex-col rounded-lg border border-white/10 bg-white/[0.03] p-4">
<div>
<h2 className="font-medium text-white">Ripartizione spese</h2>
<p className="mt-1 text-sm text-slate-400">
Spesa reale {periodLabel ?? year}: {euroFormatter.format(total)}
</p>
</div>
{total ? (
<div className="my-5 grid flex-1 items-center gap-5 sm:grid-cols-[minmax(8rem,11rem)_1fr]">
<div className="relative mx-auto grid aspect-square w-full max-w-44 place-items-center">
<DistributionRing items={items} />
<div className="absolute grid h-[64%] w-[64%] place-items-center rounded-full bg-[#0b101a] text-center">
<div>
<p className="text-[10px] uppercase text-slate-500">Totale</p>
<p className="mt-1 text-sm font-semibold text-white">
{euroFormatter.format(total)}
</p>
</div>
</div>
</div>
<div className="grid gap-3">
{items.map((item) => (
<DistributionLegendItem item={item} key={item.sourceType} />
))}
</div>
</div>
) : (
<div className="my-4 flex-1">
<EmptyState
title="Nessuna spesa registrata"
text={`Non ci sono pagamenti reali per ${periodLabel ?? `il ${year}`}. Prova a selezionare un altro periodo. I pagamenti futuri non ancora saldati sono visibili in Dashboard.`}
/>
</div>
)}
{showReportLink ? (
<Link
className="flex items-center justify-center gap-2 rounded-md border border-white/10 px-3 py-2 text-sm text-slate-200 transition hover:bg-white/5 hover:text-white"
href={`/reports?year=${year}`}
>
Apri report
<LuArrowRight size={16} />
</Link>
) : null}
</section>
);
}
function DistributionRing({ items }: { items: ExpenseDistributionItem[] }) {
let offset = 0;
const ring = (
<svg aria-hidden="true" className="h-full w-full" viewBox="0 0 42 42">
<title>Ripartizione delle spese</title>
<circle
className="stroke-slate-800"
cx="21"
cy="21"
fill="none"
r="15.915"
strokeWidth="7"
/>
{items.map((item) => {
const dashOffset = -offset;
offset += item.percentage;
return (
<circle
className="opacity-100"
cx="21"
cy="21"
fill="none"
key={item.sourceType}
pathLength="100"
r="15.915"
stroke={item.color}
strokeDasharray={`${item.percentage} ${100 - item.percentage}`}
strokeDashoffset={dashOffset}
strokeWidth="7"
transform="rotate(-90 21 21)"
/>
);
})}
</svg>
);
return ring;
}
function DistributionLegendItem({ item }: { item: ExpenseDistributionItem }) {
return (
<div className="grid grid-cols-[1fr_auto] items-center gap-3 rounded-md px-2 py-1 text-sm">
<span className="flex min-w-0 items-center gap-2 text-slate-300">
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: item.color }}
/>
<span className="truncate">{item.label}</span>
</span>
<span className="text-right text-slate-200">
{euroFormatter.format(item.amount)}
<span className="ml-2 text-xs text-slate-500">
{item.percentage.toFixed(1)}%
</span>
</span>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { DashboardStatCard } from "@/components/dashboard/DashboardStatCard";
export function FinancialSummary({
unpaidSubscriptions,
unpaidBills,
overdueSubscriptions,
overdueBills,
}: {
unpaidSubscriptions: unknown[];
unpaidBills: unknown[];
overdueSubscriptions: unknown[];
overdueBills: unknown[];
}) {
return (
<div className="grid grid-cols-4 gap-2 md:gap-3">
<Metric
label="Abbonamenti non pagati"
value={unpaidSubscriptions.length}
tone="violet"
/>
<Metric
label="Bollette non pagate"
value={unpaidBills.length}
tone="yellow"
/>
<Metric
label="Abbonamenti scaduti"
value={overdueSubscriptions.length}
tone="red"
/>
<Metric label="Bollette scadute" value={overdueBills.length} tone="red" />
</div>
);
}
function Metric({
label,
value,
tone,
}: {
label: string;
value: number;
tone: "violet" | "yellow" | "red";
}) {
return <DashboardStatCard label={label} tone={tone} value={value} />;
}

View File

@@ -0,0 +1,62 @@
import { euroFormatter } from "@/lib/finance/money";
import type { UserSettings } from "@/types/finance";
export function GoalProgress({
settings,
yearlyIncome,
yearlySavings,
}: {
settings?: UserSettings;
yearlyIncome: number;
yearlySavings: number;
}) {
const goals = [
{
label: "Obiettivo entrate",
current: yearlyIncome,
goal: settings?.monthlyIncomeGoal
? settings.monthlyIncomeGoal * 12
: undefined,
},
{
label: "Obiettivo risparmio",
current: yearlySavings,
goal: settings?.monthlySavingsGoal
? settings.monthlySavingsGoal * 12
: undefined,
},
].filter((item) => item.goal);
if (!goals.length) return null;
return (
<section className="grid gap-3 md:grid-cols-2">
{goals.map((item) => {
const percentage = Math.min(
100,
(item.current / Number(item.goal)) * 100,
);
return (
<div
className="rounded-lg border border-white/10 bg-white/[0.03] p-4"
key={item.label}
>
<div className="flex items-center justify-between gap-3 text-sm">
<span className="text-slate-300">{item.label}</span>
<span className="text-slate-400">
{euroFormatter.format(item.current)} /{" "}
{euroFormatter.format(Number(item.goal))}
</span>
</div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-slate-950">
<div
className="h-full rounded-full bg-emerald-400 transition-[width]"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
})}
</section>
);
}

View File

@@ -0,0 +1,127 @@
"use client";
import { useMemo, useState } from "react";
import { EmptyState } from "@/components/shared/EmptyState";
import { PaymentBadge } from "@/components/shared/PaymentBadge";
import { addDays, formatItalianDate, todayIso } from "@/lib/date/formatDate";
import { paymentStatusLabel } from "@/lib/finance/labels";
import { euroFormatter } from "@/lib/finance/money";
import { getPaymentVisualStyle } from "@/lib/finance/paymentVisualStyle";
import { getUpcomingPayments } from "@/lib/finance/upcomingPayments";
import type { Bill, Subscription, SubscriptionPayment } from "@/types/finance";
const ranges = [7, 15, 30, 60];
export function UpcomingPayments({
bills,
subscriptions,
subscriptionPayments,
}: {
bills: Bill[];
subscriptions: Subscription[];
subscriptionPayments: SubscriptionPayment[];
}) {
const [days, setDays] = useState(30);
const referenceDate = todayIso();
const items = useMemo(
() =>
getUpcomingPayments({
bills,
subscriptions,
subscriptionPayments,
days,
referenceDate,
}),
[bills, days, referenceDate, subscriptionPayments, subscriptions],
);
const total = items.reduce((sum, item) => sum + item.amount, 0);
return (
<section className="rounded-lg border border-white/10 bg-white/[0.03] p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-white">
Prossimi pagamenti
</h2>
<p className="mt-1 text-sm text-slate-400">
Da oggi: {items.length} pagamenti · {euroFormatter.format(total)}
</p>
</div>
<div className="flex rounded-md border border-white/10 bg-slate-950/60 p-1">
{ranges.map((range) => (
<button
aria-pressed={days === range}
className={`rounded px-2.5 py-1.5 text-xs transition ${
days === range
? "bg-sky-400/15 text-sky-100"
: "text-slate-400 hover:text-white"
}`}
key={range}
onClick={() => setDays(range)}
type="button"
>
{range} gg
</button>
))}
</div>
</div>
<div className="mt-4 grid gap-2">
{items.length ? (
items.map((item) => {
const style = getPaymentVisualStyle(item.sourceType, item.status);
return (
<div
className={`flex items-center justify-between gap-2 rounded-md border px-3 py-2.5 ${style.card}`}
key={item.id}
>
<div className="min-w-0">
<p className="truncate font-medium text-slate-100">
{item.title}
</p>
<p className="mt-0.5 text-xs text-slate-400">
{item.subtitle} · {formatItalianDate(item.dueDate)} ·{" "}
{dateDistanceLabel(item.dueDate, referenceDate)}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="text-right">
<p className={`text-sm ${style.accent}`}>
{euroFormatter.format(item.amount)}
</p>
<div className="mt-1">
<PaymentBadge
sourceType={item.sourceType}
status={item.status}
>
{paymentStatusLabel(item.status)}
</PaymentBadge>
</div>
</div>
</div>
</div>
);
})
) : (
<EmptyState
title="Nessun pagamento previsto"
text={`Non ci sono bollette o abbonamenti nei prossimi ${days} giorni.`}
/>
)}
</div>
</section>
);
}
function dateDistanceLabel(dueDate: string, referenceDate: string) {
if (dueDate === referenceDate) return "oggi";
let distance = 0;
let cursor = referenceDate;
const direction = dueDate > referenceDate ? 1 : -1;
while (cursor !== dueDate && Math.abs(distance) < 3660) {
cursor = addDays(cursor, direction);
distance += direction;
}
return distance > 0
? `tra ${distance} ${distance === 1 ? "giorno" : "giorni"}`
: `${Math.abs(distance)} ${distance === -1 ? "giorno" : "giorni"} fa`;
}

View File

@@ -0,0 +1,45 @@
import { LuCalendarRange, LuChevronLeft, LuChevronRight } from "react-icons/lu";
export function YearSelector({
year,
onChange,
disabled = false,
}: {
year: number;
onChange: (year: number) => void;
disabled?: boolean;
}) {
return (
<div
className={`w-full inline-flex items-center justify-between gap-1 rounded-xl border border-sky-400/15 bg-slate-950/25 p-1 shadow-sm shadow-sky-950/30 transition ${
disabled ? "opacity-40" : ""
}`}
>
<span className="flex min-w-24 items-center gap-2 px-2 text-center text-sm font-semibold text-white">
<LuCalendarRange className="text-sky-300" size={16} />
{year}
</span>
<div className="flex justify-end">
<button
aria-label="Anno precedente"
className="grid size-9 place-items-center rounded-lg text-slate-300 transition hover:bg-sky-400/10 hover:text-sky-100 disabled:cursor-default"
disabled={disabled}
onClick={() => onChange(year - 1)}
type="button"
>
<LuChevronLeft size={18} />
</button>
<button
aria-label="Anno successivo"
className="grid size-9 place-items-center rounded-lg text-slate-300 transition hover:bg-sky-400/10 hover:text-sky-100 disabled:cursor-default"
disabled={disabled}
onClick={() => onChange(year + 1)}
type="button"
>
<LuChevronRight size={18} />
</button>
</div>
</div>
);
}