first commit
This commit is contained in:
35
src/components/dashboard/DashboardCards.tsx
Normal file
35
src/components/dashboard/DashboardCards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/components/dashboard/DashboardKpiRow.tsx
Normal file
48
src/components/dashboard/DashboardKpiRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
src/components/dashboard/DashboardPageClient.tsx
Normal file
104
src/components/dashboard/DashboardPageClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
src/components/dashboard/DashboardStatCard.tsx
Normal file
62
src/components/dashboard/DashboardStatCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
src/components/dashboard/ExpenseDistribution.tsx
Normal file
126
src/components/dashboard/ExpenseDistribution.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/components/dashboard/FinancialSummary.tsx
Normal file
46
src/components/dashboard/FinancialSummary.tsx
Normal 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} />;
|
||||
}
|
||||
62
src/components/dashboard/GoalProgress.tsx
Normal file
62
src/components/dashboard/GoalProgress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
src/components/dashboard/UpcomingPayments.tsx
Normal file
127
src/components/dashboard/UpcomingPayments.tsx
Normal 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`;
|
||||
}
|
||||
45
src/components/dashboard/YearSelector.tsx
Normal file
45
src/components/dashboard/YearSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user