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,5 @@
import { BillsPageClient } from "@/components/bills/BillsPageClient";
export default function BillsPage() {
return <BillsPageClient />;
}

View File

@@ -0,0 +1,5 @@
import { DashboardPageClient } from "@/components/dashboard/DashboardPageClient";
export default function DashboardPage() {
return <DashboardPageClient />;
}

View File

@@ -0,0 +1,5 @@
import { IncomePageClient } from "@/components/income/IncomePageClient";
export default function IncomePage() {
return <IncomePageClient />;
}

View File

@@ -0,0 +1,17 @@
import { redirect } from "next/navigation";
import { AppShell } from "@/components/AppShell";
import { getAuthenticatedUser } from "@/lib/auth";
import { isInstanceConfigured } from "@/lib/database/setup";
export default async function ProtectedLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const user = await getAuthenticatedUser();
if (!user) {
redirect((await isInstanceConfigured()) ? "/login" : "/setup");
}
return <AppShell user={user}>{children}</AppShell>;
}

View File

@@ -0,0 +1,5 @@
import { OneTimePaymentsPageClient } from "@/components/one-time-payments/OneTimePaymentsPageClient";
export default function OneTimePaymentsPage() {
return <OneTimePaymentsPageClient />;
}

View File

@@ -0,0 +1,8 @@
import { ProfilePageClient } from "@/components/profile/ProfilePageClient";
import { getAuthenticatedUser } from "@/lib/auth";
export default async function ProfilePage() {
const user = await getAuthenticatedUser();
if (!user) return null;
return <ProfilePageClient user={user} />;
}

View File

@@ -0,0 +1,17 @@
import { ReportsPageClient } from "@/components/reports/ReportsPageClient";
export default async function ReportsPage({
searchParams,
}: {
searchParams: Promise<{ year?: string }>;
}) {
const { year } = await searchParams;
return <ReportsPageClient initialYear={normalizeYear(year)} />;
}
function normalizeYear(value?: string) {
const year = Number(value);
return Number.isInteger(year) && year >= 2000 && year <= 2100
? year
: new Date().getFullYear();
}

View File

@@ -0,0 +1,5 @@
import { SubscriptionsPageClient } from "@/components/subscriptions/SubscriptionsPageClient";
export default function SubscriptionsPage() {
return <SubscriptionsPageClient />;
}

View File

@@ -0,0 +1,11 @@
import { redirect } from "next/navigation";
import { AuthForm } from "@/components/AuthForm";
import { getAuthenticatedUser } from "@/lib/auth";
import { isInstanceConfigured } from "@/lib/database/setup";
export default async function LoginPage() {
const user = await getAuthenticatedUser();
if (user) redirect("/dashboard");
if (!(await isInstanceConfigured())) redirect("/setup");
return <AuthForm />;
}

View File

@@ -0,0 +1,11 @@
import { redirect } from "next/navigation";
import { SetupForm } from "@/components/SetupForm";
import { getAuthenticatedUser } from "@/lib/auth";
import { isInstanceConfigured } from "@/lib/database/setup";
export default async function SetupPage() {
const user = await getAuthenticatedUser();
if (user) redirect("/dashboard");
if (await isInstanceConfigured()) redirect("/login");
return <SetupForm />;
}

View File

@@ -0,0 +1,39 @@
import bcrypt from "bcryptjs";
import { createSession } from "@/lib/auth";
import { database } from "@/lib/database/server";
import { ensureUserSettings } from "@/lib/profile/settings";
export async function POST(request: Request) {
try {
const body = await request.json();
const email = String(body.email ?? "")
.trim()
.toLowerCase();
const password = String(body.password ?? "");
if (!email || !password) {
return Response.json(
{ error: "Email e password sono obbligatorie" },
{ status: 400 },
);
}
const [user] = await database()<
{ id: string; password_hash: string }[]
>`SELECT id, password_hash FROM users WHERE email = ${email} LIMIT 1`;
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
return Response.json(
{ error: "Credenziali non valide" },
{ status: 401 },
);
}
await createSession(user.id);
await ensureUserSettings(user.id);
return Response.json({ ok: true });
} catch (error) {
const message =
error instanceof Error ? error.message : "Accesso non riuscito";
return Response.json({ error: message }, { status: 401 });
}
}

View File

@@ -0,0 +1,6 @@
import { deleteCurrentSession } from "@/lib/auth";
export async function POST() {
await deleteCurrentSession();
return Response.json({ ok: true });
}

View File

@@ -0,0 +1,6 @@
import { getAuthenticatedUser } from "@/lib/auth";
export async function GET() {
const user = await getAuthenticatedUser();
return Response.json({ authenticated: Boolean(user), user });
}

View File

@@ -0,0 +1,49 @@
import bcrypt from "bcryptjs";
import { createSession } from "@/lib/auth";
import { database } from "@/lib/database/server";
import { ensureUserSettings } from "@/lib/profile/settings";
import { requireString } from "@/lib/validation/common";
class SetupCompletedError extends Error {}
export async function POST(request: Request) {
try {
const body = (await request.json()) as Record<string, unknown>;
const name = requireString(body.name, "name");
const email = requireString(body.email, "email").toLowerCase();
const password = requireString(body.password, "password");
if (!email.includes("@")) throw new Error("L'email non è valida");
if (password.length < 8) {
throw new Error("La password deve contenere almeno 8 caratteri");
}
const passwordHash = await bcrypt.hash(password, 12);
const user = await database().begin(async (sql) => {
await sql`SELECT pg_advisory_xact_lock(727743)`;
const [existing] = await sql<[{ configured: boolean }]>`
SELECT EXISTS(SELECT 1 FROM users) AS configured
`;
if (existing.configured) throw new SetupCompletedError();
const [created] = await sql<[{ id: string }]>`
INSERT INTO users (email, name, password_hash)
VALUES (${email}, ${name}, ${passwordHash})
RETURNING id
`;
return created;
});
await createSession(user.id);
await ensureUserSettings(user.id);
return Response.json({ ok: true }, { status: 201 });
} catch (error) {
if (error instanceof SetupCompletedError) {
return Response.json(
{ error: "L'istanza è già configurata" },
{ status: 409 },
);
}
const message =
error instanceof Error ? error.message : "Configurazione non riuscita";
return Response.json({ error: message }, { status: 400 });
}
}

View File

@@ -0,0 +1,55 @@
import { requireUser } from "@/lib/auth";
import {
databaseConfig,
deleteDocument,
getOwnedDocument,
updateDocument,
} from "@/lib/database/server";
import { removeLinkedLedger, syncBillLedger } from "@/lib/finance/ledger";
import { parseBillInput } from "@/lib/validation/bills";
import { parseBody, updatedField } from "@/lib/validation/common";
import type { Bill } from "@/types/finance";
type Ctx = { params: Promise<{ id: string }> };
export async function PATCH(request: Request, ctx: Ctx) {
const auth = await requireUser();
if ("response" in auth) return auth.response;
const { id } = await ctx.params;
const parsed = await parseBody(request, parseBillInput);
if (!parsed.ok)
return Response.json({ error: parsed.error }, { status: 400 });
const { collections } = databaseConfig();
const current = await getOwnedDocument<Bill>(
collections.bills,
id,
auth.user.id,
);
let bill = await updateDocument<Bill>(collections.bills, id, {
...parsed.data,
paymentDate: parsed.data.status === "paid" ? parsed.data.paymentDate : null,
linkedPaymentId: current.linkedPaymentId ?? null,
...updatedField(),
});
const ledger = await syncBillLedger(bill);
bill = await updateDocument<Bill>(collections.bills, id, {
linkedPaymentId: ledger?.$id ?? null,
...updatedField(),
});
return Response.json(bill);
}
export async function DELETE(_request: Request, ctx: Ctx) {
const auth = await requireUser();
if ("response" in auth) return auth.response;
const { id } = await ctx.params;
const { collections } = databaseConfig();
const bill = await getOwnedDocument<Bill>(
collections.bills,
id,
auth.user.id,
);
await removeLinkedLedger(auth.user.id, bill.linkedPaymentId, "bill", id);
await deleteDocument(collections.bills, id);
return Response.json({ ok: true });
}

View File

@@ -0,0 +1,46 @@
import { requireUser } from "@/lib/auth";
import {
createDocument,
databaseConfig,
listDocuments,
query,
updateDocument,
} from "@/lib/database/server";
import { syncBillLedger } from "@/lib/finance/ledger";
import { parseBillInput } from "@/lib/validation/bills";
import { nowFields, parseBody } from "@/lib/validation/common";
import type { Bill } from "@/types/finance";
export async function GET() {
const auth = await requireUser();
if ("response" in auth) return auth.response;
const { collections } = databaseConfig();
const result = await listDocuments<Bill>(collections.bills, [
query.equal("userId", auth.user.id),
query.orderAsc("dueDate"),
query.limit(5000),
]);
return Response.json(result.documents);
}
export async function POST(request: Request) {
const auth = await requireUser();
if ("response" in auth) return auth.response;
const parsed = await parseBody(request, parseBillInput);
if (!parsed.ok)
return Response.json({ error: parsed.error }, { status: 400 });
const { collections } = databaseConfig();
let bill = await createDocument<Bill>(collections.bills, {
...parsed.data,
userId: auth.user.id,
...nowFields(),
});
const ledger = await syncBillLedger(bill);
if (ledger && bill.linkedPaymentId !== ledger.$id) {
bill = await updateDocument<Bill>(collections.bills, bill.$id, {
linkedPaymentId: ledger.$id,
updatedAt: new Date().toISOString(),
});
}
return Response.json(bill, { status: 201 });
}

View File

@@ -0,0 +1,38 @@
import { requireUser } from "@/lib/auth";
import {
databaseConfig,
deleteDocument,
getOwnedDocument,
updateDocument,
} from "@/lib/database/server";
import { parseBody, updatedField } from "@/lib/validation/common";
import { parseIncomeInput } from "@/lib/validation/income";
import type { Income } from "@/types/finance";
type Ctx = { params: Promise<{ id: string }> };
export async function PATCH(request: Request, ctx: Ctx) {
const auth = await requireUser();
if ("response" in auth) return auth.response;
const { id } = await ctx.params;
const parsed = await parseBody(request, parseIncomeInput);
if (!parsed.ok)
return Response.json({ error: parsed.error }, { status: 400 });
const { collections } = databaseConfig();
await getOwnedDocument<Income>(collections.income, id, auth.user.id);
const document = await updateDocument<Income>(collections.income, id, {
...parsed.data,
...updatedField(),
});
return Response.json(document);
}
export async function DELETE(_request: Request, ctx: Ctx) {
const auth = await requireUser();
if ("response" in auth) return auth.response;
const { id } = await ctx.params;
const { collections } = databaseConfig();
await getOwnedDocument<Income>(collections.income, id, auth.user.id);
await deleteDocument(collections.income, id);
return Response.json({ ok: true });
}

View File

@@ -0,0 +1,37 @@
import { requireUser } from "@/lib/auth";
import {
createDocument,
databaseConfig,
listDocuments,
query,
} from "@/lib/database/server";
import { nowFields, parseBody } from "@/lib/validation/common";
import { parseIncomeInput } from "@/lib/validation/income";
import type { Income } from "@/types/finance";
export async function GET() {
const auth = await requireUser();
if ("response" in auth) return auth.response;
const { collections } = databaseConfig();
const result = await listDocuments<Income>(collections.income, [
query.equal("userId", auth.user.id),
query.orderDesc("date"),
query.limit(5000),
]);
return Response.json(result.documents);
}
export async function POST(request: Request) {
const auth = await requireUser();
if ("response" in auth) return auth.response;
const parsed = await parseBody(request, parseIncomeInput);
if (!parsed.ok)
return Response.json({ error: parsed.error }, { status: 400 });
const { collections } = databaseConfig();
const document = await createDocument<Income>(collections.income, {
...parsed.data,
userId: auth.user.id,
...nowFields(),
});
return Response.json(document, { status: 201 });
}

View File

@@ -0,0 +1,67 @@
import { requireUser } from "@/lib/auth";
import {
databaseConfig,
deleteDocument,
getOwnedDocument,
updateDocument,
} from "@/lib/database/server";
import { removeLinkedLedger, syncOneTimeLedger } from "@/lib/finance/ledger";
import { parseBody, updatedField } from "@/lib/validation/common";
import { parseOneTimePaymentInput } from "@/lib/validation/oneTimePayments";
import type { OneTimePayment } from "@/types/finance";
type Ctx = { params: Promise<{ id: string }> };
export async function PATCH(request: Request, ctx: Ctx) {
const auth = await requireUser();
if ("response" in auth) return auth.response;
const { id } = await ctx.params;
const parsed = await parseBody(request, parseOneTimePaymentInput);
if (!parsed.ok)
return Response.json({ error: parsed.error }, { status: 400 });
const { collections } = databaseConfig();
const current = await getOwnedDocument<OneTimePayment>(
collections.oneTimePayments,
id,
auth.user.id,
);
let payment = await updateDocument<OneTimePayment>(
collections.oneTimePayments,
id,
{
...parsed.data,
linkedPaymentId: current.linkedPaymentId,
...updatedField(),
},
);
const ledger = await syncOneTimeLedger(payment);
payment = await updateDocument<OneTimePayment>(
collections.oneTimePayments,
id,
{
linkedPaymentId: ledger.$id,
...updatedField(),
},
);
return Response.json(payment);
}
export async function DELETE(_request: Request, ctx: Ctx) {
const auth = await requireUser();
if ("response" in auth) return auth.response;
const { id } = await ctx.params;
const { collections } = databaseConfig();
const payment = await getOwnedDocument<OneTimePayment>(
collections.oneTimePayments,
id,
auth.user.id,
);
await removeLinkedLedger(
auth.user.id,
payment.linkedPaymentId,
"one_time",
id,
);
await deleteDocument(collections.oneTimePayments, id);
return Response.json({ ok: true });
}

View File

@@ -0,0 +1,54 @@
import { requireUser } from "@/lib/auth";
import {
createDocument,
databaseConfig,
listDocuments,
query,
updateDocument,
} from "@/lib/database/server";
import { syncOneTimeLedger } from "@/lib/finance/ledger";
import { nowFields, parseBody } from "@/lib/validation/common";
import { parseOneTimePaymentInput } from "@/lib/validation/oneTimePayments";
import type { OneTimePayment } from "@/types/finance";
export async function GET() {
const auth = await requireUser();
if ("response" in auth) return auth.response;
const { collections } = databaseConfig();
const result = await listDocuments<OneTimePayment>(
collections.oneTimePayments,
[
query.equal("userId", auth.user.id),
query.orderDesc("date"),
query.limit(5000),
],
);
return Response.json(result.documents);
}
export async function POST(request: Request) {
const auth = await requireUser();
if ("response" in auth) return auth.response;
const parsed = await parseBody(request, parseOneTimePaymentInput);
if (!parsed.ok)
return Response.json({ error: parsed.error }, { status: 400 });
const { collections } = databaseConfig();
let payment = await createDocument<OneTimePayment>(
collections.oneTimePayments,
{
...parsed.data,
userId: auth.user.id,
...nowFields(),
},
);
const ledger = await syncOneTimeLedger(payment);
payment = await updateDocument<OneTimePayment>(
collections.oneTimePayments,
payment.$id,
{
linkedPaymentId: ledger.$id,
updatedAt: new Date().toISOString(),
},
);
return Response.json(payment, { status: 201 });
}

View File

@@ -0,0 +1,49 @@
import { requireUser } from "@/lib/auth";
import {
createDocument,
databaseConfig,
listDocuments,
query,
} from "@/lib/database/server";
import { nowFields, parseBody } from "@/lib/validation/common";
import { parsePaymentTitleInput } from "@/lib/validation/paymentTitles";
import type { PaymentTitle } from "@/types/finance";
export async function GET() {
const auth = await requireUser();
if ("response" in auth) return auth.response;
const { collections } = databaseConfig();
const result = await listDocuments<PaymentTitle>(collections.paymentTitles, [
query.equal("userId", auth.user.id),
query.orderAsc("normalizedName"),
]);
return Response.json(result.documents);
}
export async function POST(request: Request) {
const auth = await requireUser();
if ("response" in auth) return auth.response;
const parsed = await parseBody(request, parsePaymentTitleInput);
if (!parsed.ok)
return Response.json({ error: parsed.error }, { status: 400 });
const { collections } = databaseConfig();
const existing = await listDocuments<PaymentTitle>(
collections.paymentTitles,
[
query.equal("userId", auth.user.id),
query.equal("normalizedName", parsed.data.normalizedName),
query.equal("paymentType", parsed.data.paymentType),
query.limit(1),
],
);
if (existing.documents[0]) return Response.json(existing.documents[0]);
const document = await createDocument<PaymentTitle>(
collections.paymentTitles,
{
...parsed.data,
userId: auth.user.id,
...nowFields(),
},
);
return Response.json(document, { status: 201 });
}

View File

@@ -0,0 +1,18 @@
import { requireUser } from "@/lib/auth";
import { databaseConfig, listDocuments, query } from "@/lib/database/server";
import type { PaymentLedger } from "@/types/finance";
export async function GET() {
const auth = await requireUser();
if ("response" in auth) return auth.response;
const { collections } = databaseConfig();
const result = await listDocuments<PaymentLedger>(
collections.paymentsLedger,
[
query.equal("userId", auth.user.id),
query.orderDesc("date"),
query.limit(5000),
],
);
return Response.json(result.documents);
}

View File

@@ -0,0 +1,120 @@
import bcrypt from "bcryptjs";
import { clearSessionSecret, requireUser } from "@/lib/auth";
import { database } from "@/lib/database/server";
import { deleteAvatar } from "@/lib/profile/avatarStorage";
import {
parseAccountPatch,
parseDeleteAccount,
parsePasswordPatch,
} from "@/lib/validation/account";
import { parseBody } from "@/lib/validation/common";
import type { UserSession } from "@/types/finance";
export async function PATCH(request: Request) {
const auth = await requireUser();
if ("response" in auth) return auth.response;
const parsed = await parseBody(request, parseAccountPatch);
if (!parsed.ok) {
return Response.json({ error: parsed.error }, { status: 400 });
}
try {
const [user] = await database()<UserSession[]>`
UPDATE users
SET name = ${parsed.data.name},
email = ${parsed.data.email},
updated_at = NOW()
WHERE id = ${auth.user.id}
RETURNING
id,
name,
email,
CASE
WHEN avatar_file IS NULL THEN NULL
ELSE '/api/profile/avatar?v=' || EXTRACT(EPOCH FROM updated_at)::bigint::text
END AS "avatarUrl"
`;
return Response.json(user);
} catch (error) {
if (hasDatabaseCode(error, "23505")) {
return Response.json(
{ error: "Esiste già un account con questa email" },
{ status: 409 },
);
}
return Response.json(
{ error: "Impossibile aggiornare i dati account" },
{ status: 500 },
);
}
}
export async function POST(request: Request) {
const auth = await requireUser();
if ("response" in auth) return auth.response;
const parsed = await parseBody(request, parsePasswordPatch);
if (!parsed.ok) {
return Response.json({ error: parsed.error }, { status: 400 });
}
const [user] = await database()<{ password_hash: string }[]>`
SELECT password_hash
FROM users
WHERE id = ${auth.user.id}
LIMIT 1
`;
if (
!user ||
!(await bcrypt.compare(parsed.data.currentPassword, user.password_hash))
) {
return Response.json(
{ error: "La password attuale non è corretta" },
{ status: 401 },
);
}
const passwordHash = await bcrypt.hash(parsed.data.newPassword, 12);
await database()`
UPDATE users
SET password_hash = ${passwordHash},
updated_at = NOW()
WHERE id = ${auth.user.id}
`;
return Response.json({ ok: true });
}
export async function DELETE(request: Request) {
const auth = await requireUser();
if ("response" in auth) return auth.response;
const parsed = await parseBody(request, parseDeleteAccount);
if (!parsed.ok) {
return Response.json({ error: parsed.error }, { status: 400 });
}
const [user] = await database()<
{ avatar_file: string | null; password_hash: string }[]
>`
SELECT avatar_file, password_hash
FROM users
WHERE id = ${auth.user.id}
LIMIT 1
`;
if (
!user ||
!(await bcrypt.compare(parsed.data.currentPassword, user.password_hash))
) {
return Response.json(
{ error: "La password attuale non è corretta" },
{ status: 401 },
);
}
await database()`DELETE FROM users WHERE id = ${auth.user.id}`;
await clearSessionSecret();
await deleteAvatar(user.avatar_file).catch(() => undefined);
return Response.json({ ok: true });
}
function hasDatabaseCode(error: unknown, code: string) {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
error.code === code
);
}

View File

@@ -0,0 +1,98 @@
import { requireUser } from "@/lib/auth";
import { database } from "@/lib/database/server";
import {
avatarContentType,
deleteAvatar,
readAvatar,
saveAvatar,
} from "@/lib/profile/avatarStorage";
import type { UserSession } from "@/types/finance";
export async function GET() {
const auth = await requireUser();
if ("response" in auth) return auth.response;
const [user] = await database()<{ avatar_file: string | null }[]>`
SELECT avatar_file
FROM users
WHERE id = ${auth.user.id}
LIMIT 1
`;
if (!user?.avatar_file) {
return Response.json({ error: "Avatar non impostato" }, { status: 404 });
}
try {
return new Response(await readAvatar(user.avatar_file), {
headers: {
"Cache-Control": "private, no-cache",
"Content-Type": avatarContentType(user.avatar_file),
},
});
} catch {
return Response.json({ error: "Avatar non disponibile" }, { status: 404 });
}
}
export async function POST(request: Request) {
const auth = await requireUser();
if ("response" in auth) return auth.response;
try {
const formData = await request.formData();
const file = formData.get("avatar");
if (!(file instanceof File)) {
return Response.json({ error: "Seleziona un'immagine" }, { status: 400 });
}
const [current] = await database()<{ avatar_file: string | null }[]>`
SELECT avatar_file
FROM users
WHERE id = ${auth.user.id}
LIMIT 1
`;
const filename = await saveAvatar(auth.user.id, file);
const user = await updateAvatar(auth.user.id, filename);
if (current?.avatar_file && current.avatar_file !== filename) {
await deleteAvatar(current.avatar_file);
}
return Response.json(user);
} catch (error) {
return Response.json(
{ error: errorMessage(error, "Impossibile salvare l'avatar") },
{ status: 400 },
);
}
}
export async function DELETE() {
const auth = await requireUser();
if ("response" in auth) return auth.response;
const [current] = await database()<{ avatar_file: string | null }[]>`
SELECT avatar_file
FROM users
WHERE id = ${auth.user.id}
LIMIT 1
`;
const user = await updateAvatar(auth.user.id, null);
await deleteAvatar(current?.avatar_file);
return Response.json(user);
}
async function updateAvatar(userId: string, filename: string | null) {
const [user] = await database()<UserSession[]>`
UPDATE users
SET avatar_file = ${filename},
updated_at = NOW()
WHERE id = ${userId}
RETURNING
id,
name,
email,
CASE
WHEN avatar_file IS NULL THEN NULL
ELSE '/api/profile/avatar?v=' || EXTRACT(EPOCH FROM updated_at)::bigint::text
END AS "avatarUrl"
`;
return user;
}
function errorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}

View File

@@ -0,0 +1,37 @@
import { requireUser } from "@/lib/auth";
import {
databaseConfig,
deleteDocument,
listDocuments,
query,
} from "@/lib/database/server";
export async function DELETE() {
const auth = await requireUser();
if ("response" in auth) return auth.response;
await deleteFinancialData(auth.user.id);
return Response.json({ ok: true });
}
async function deleteFinancialData(userId: string) {
const { collections } = databaseConfig();
const collectionIds = [
collections.subscriptionPayments,
collections.paymentsLedger,
collections.subscriptions,
collections.bills,
collections.oneTimePayments,
collections.paymentTitles,
collections.income,
];
for (const collectionId of collectionIds) {
const result = await listDocuments<{ $id: string }>(collectionId, [
query.equal("userId", userId),
query.limit(5000),
]);
for (const document of result.documents) {
await deleteDocument(collectionId, document.$id);
}
}
}

View File

@@ -0,0 +1,19 @@
import { requireUser } from "@/lib/auth";
import { ensureUserSettings, saveUserSettings } from "@/lib/profile/settings";
import { parseBody } from "@/lib/validation/common";
import { parseUserSettingsPatch } from "@/lib/validation/userSettings";
export async function GET() {
const auth = await requireUser();
if ("response" in auth) return auth.response;
return Response.json(await ensureUserSettings(auth.user.id));
}
export async function PATCH(request: Request) {
const auth = await requireUser();
if ("response" in auth) return auth.response;
const parsed = await parseBody(request, parseUserSettingsPatch);
if (!parsed.ok)
return Response.json({ error: parsed.error }, { status: 400 });
return Response.json(await saveUserSettings(auth.user.id, parsed.data));
}

View File

@@ -0,0 +1,79 @@
import { requireUser } from "@/lib/auth";
import {
databaseConfig,
deleteDocument,
getOwnedDocument,
updateDocument,
} from "@/lib/database/server";
import {
removeLinkedLedger,
syncSubscriptionLedger,
} from "@/lib/finance/ledger";
import { requireDate, updatedField } from "@/lib/validation/common";
import type { Subscription, SubscriptionPayment } from "@/types/finance";
type Ctx = { params: Promise<{ id: string }> };
export async function PATCH(request: Request, ctx: Ctx) {
const auth = await requireUser();
if ("response" in auth) return auth.response;
const { id } = await ctx.params;
const body = (await request.json()) as Record<string, unknown>;
const status = body.status === "paid" ? "paid" : "unpaid";
const { collections } = databaseConfig();
const current = await getOwnedDocument<SubscriptionPayment>(
collections.subscriptionPayments,
id,
auth.user.id,
);
const subscription = await getOwnedDocument<Subscription>(
collections.subscriptions,
current.subscriptionId,
auth.user.id,
);
let payment = await updateDocument<SubscriptionPayment>(
collections.subscriptionPayments,
id,
{
status,
paymentDate:
status === "paid"
? body.paymentDate
? requireDate(body.paymentDate, "paymentDate")
: current.renewalDate
: "",
linkedPaymentId: current.linkedPaymentId,
...updatedField(),
},
);
const ledger = await syncSubscriptionLedger(subscription, payment);
payment = await updateDocument<SubscriptionPayment>(
collections.subscriptionPayments,
id,
{
linkedPaymentId: ledger?.$id ?? "",
...updatedField(),
},
);
return Response.json(payment);
}
export async function DELETE(_request: Request, ctx: Ctx) {
const auth = await requireUser();
if ("response" in auth) return auth.response;
const { id } = await ctx.params;
const { collections } = databaseConfig();
const payment = await getOwnedDocument<SubscriptionPayment>(
collections.subscriptionPayments,
id,
auth.user.id,
);
await removeLinkedLedger(
auth.user.id,
payment.linkedPaymentId,
"subscription",
id,
);
await deleteDocument(collections.subscriptionPayments, id);
return Response.json({ ok: true });
}

View File

@@ -0,0 +1,160 @@
import { requireUser } from "@/lib/auth";
import {
createDocument,
DatabaseRequestError,
databaseConfig,
getOwnedDocument,
listDocuments,
query,
updateDocument,
} from "@/lib/database/server";
import {
removeLinkedLedger,
syncSubscriptionLedger,
} from "@/lib/finance/ledger";
import { isScheduledSubscriptionOccurrence } from "@/lib/finance/subscriptionOccurrences";
import {
requireDate,
requireString,
updatedField,
} from "@/lib/validation/common";
import type { Subscription, SubscriptionPayment } from "@/types/finance";
function parsePayment(body: Record<string, unknown>) {
const status = body.status === "paid" ? "paid" : "unpaid";
return {
subscriptionId: requireString(body.subscriptionId, "subscriptionId"),
renewalDate: requireDate(body.renewalDate, "renewalDate"),
status,
paymentDate:
status === "paid"
? body.paymentDate
? requireDate(body.paymentDate, "paymentDate")
: requireDate(body.renewalDate, "renewalDate")
: undefined,
};
}
async function findPayment(
userId: string,
subscriptionId: string,
renewalDate: string,
) {
const { collections } = databaseConfig();
const result = await listDocuments<SubscriptionPayment>(
collections.subscriptionPayments,
[
query.equal("subscriptionId", subscriptionId),
query.equal("renewalDate", renewalDate),
query.equal("userId", userId),
query.limit(1),
],
);
return result.documents[0];
}
export async function GET() {
const auth = await requireUser();
if ("response" in auth) return auth.response;
const { collections } = databaseConfig();
const result = await listDocuments<SubscriptionPayment>(
collections.subscriptionPayments,
[
query.equal("userId", auth.user.id),
query.orderDesc("renewalDate"),
query.limit(5000),
],
);
return Response.json(result.documents);
}
export async function POST(request: Request) {
const auth = await requireUser();
if ("response" in auth) return auth.response;
const userId = auth.user.id;
try {
const body = (await request.json()) as Record<string, unknown>;
const parsed = parsePayment(body);
const { collections } = databaseConfig();
const subscription = await getOwnedDocument<Subscription>(
collections.subscriptions,
parsed.subscriptionId,
userId,
);
const existing = await findPayment(
userId,
parsed.subscriptionId,
parsed.renewalDate,
);
if (
!existing &&
!isScheduledSubscriptionOccurrence(subscription, parsed.renewalDate)
) {
throw new Error("Il rinnovo richiesto non è previsto");
}
let payment = existing
? await updatePayment(existing, parsed)
: await createPayment(parsed).catch(async (error) => {
if (
!(error instanceof DatabaseRequestError) ||
error.status !== 409
) {
throw error;
}
const winner = await findPayment(
userId,
parsed.subscriptionId,
parsed.renewalDate,
);
if (!winner) throw error;
return updatePayment(winner, parsed);
});
const ledger = await syncSubscriptionLedger(subscription, payment);
if (parsed.status === "unpaid") {
await removeLinkedLedger(
userId,
payment.linkedPaymentId,
"subscription",
payment.$id,
);
}
payment = await updateDocument<SubscriptionPayment>(
collections.subscriptionPayments,
payment.$id,
{
linkedPaymentId: ledger?.$id ?? "",
paymentDate: parsed.status === "paid" ? payment.paymentDate : "",
...updatedField(),
},
);
return Response.json(payment, { status: existing ? 200 : 201 });
function updatePayment(current: SubscriptionPayment, data: typeof parsed) {
return updateDocument<SubscriptionPayment>(
collections.subscriptionPayments,
current.$id,
{
...data,
linkedPaymentId: current.linkedPaymentId,
...updatedField(),
},
);
}
function createPayment(data: typeof parsed) {
return createDocument<SubscriptionPayment>(
collections.subscriptionPayments,
{
...data,
userId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
);
}
} catch (error) {
const message =
error instanceof Error ? error.message : "Richiesta non valida";
return Response.json({ error: message }, { status: 400 });
}
}

View File

@@ -0,0 +1,79 @@
import { requireUser } from "@/lib/auth";
import {
databaseConfig,
deleteDocument,
getOwnedDocument,
listDocuments,
query,
updateDocument,
} from "@/lib/database/server";
import { removeLinkedLedger } from "@/lib/finance/ledger";
import { parseBody, updatedField } from "@/lib/validation/common";
import { parseSubscriptionPatch } from "@/lib/validation/subscriptions";
import type { Subscription, SubscriptionPayment } from "@/types/finance";
type Ctx = { params: Promise<{ id: string }> };
export async function PATCH(request: Request, ctx: Ctx) {
const auth = await requireUser();
if ("response" in auth) return auth.response;
const { id } = await ctx.params;
const parsed = await parseBody(request, parseSubscriptionPatch);
if (!parsed.ok)
return Response.json({ error: parsed.error }, { status: 400 });
const { collections } = databaseConfig();
const current = await getOwnedDocument<Subscription>(
collections.subscriptions,
id,
auth.user.id,
);
if (
current.subscriptionStatus === "terminated" &&
parsed.data.subscriptionStatus !== "terminated"
) {
return Response.json(
{ error: "Un abbonamento terminato non può essere riattivato" },
{ status: 400 },
);
}
const document = await updateDocument<Subscription>(
collections.subscriptions,
id,
{
...parsed.data,
...updatedField(),
},
);
return Response.json(document);
}
export async function DELETE(_request: Request, ctx: Ctx) {
const auth = await requireUser();
if ("response" in auth) return auth.response;
const { id } = await ctx.params;
const { collections } = databaseConfig();
await getOwnedDocument<Subscription>(
collections.subscriptions,
id,
auth.user.id,
);
const payments = await listDocuments<SubscriptionPayment>(
collections.subscriptionPayments,
[
query.equal("userId", auth.user.id),
query.equal("subscriptionId", id),
query.limit(5000),
],
);
for (const payment of payments.documents) {
await removeLinkedLedger(
auth.user.id,
payment.linkedPaymentId,
"subscription",
payment.$id,
);
await deleteDocument(collections.subscriptionPayments, payment.$id);
}
await deleteDocument(collections.subscriptions, id);
return Response.json({ ok: true });
}

View File

@@ -0,0 +1,40 @@
import { requireUser } from "@/lib/auth";
import {
createDocument,
databaseConfig,
listDocuments,
query,
} from "@/lib/database/server";
import { nowFields, parseBody } from "@/lib/validation/common";
import { parseSubscriptionInput } from "@/lib/validation/subscriptions";
import type { Subscription } from "@/types/finance";
export async function GET() {
const auth = await requireUser();
if ("response" in auth) return auth.response;
const { collections } = databaseConfig();
const result = await listDocuments<Subscription>(collections.subscriptions, [
query.equal("userId", auth.user.id),
query.orderAsc("firstPaymentDate"),
query.limit(5000),
]);
return Response.json(result.documents);
}
export async function POST(request: Request) {
const auth = await requireUser();
if ("response" in auth) return auth.response;
const parsed = await parseBody(request, parseSubscriptionInput);
if (!parsed.ok)
return Response.json({ error: parsed.error }, { status: 400 });
const { collections } = databaseConfig();
const document = await createDocument<Subscription>(
collections.subscriptions,
{
...parsed.data,
userId: auth.user.id,
...nowFields(),
},
);
return Response.json(document, { status: 201 });
}

View File

@@ -1,26 +1,85 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
/* Riserva sempre lo spazio della scrollbar */
html {
overflow-y: auto;
scrollbar-gutter: stable;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.25) transparent;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
/* Chrome, Edge, Brave, Opera */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.18);
border-radius: 999px;
border: 2px solid transparent;
background-clip: content-box;
transition: all 0.25s ease;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.35);
background-clip: content-box;
}
::-webkit-scrollbar-thumb:active {
background: rgba(255, 255, 255, 0.5);
background-clip: content-box;
}
::-webkit-scrollbar-corner {
background: transparent;
}
@keyframes modal-in {
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
@keyframes modal-out {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
}
@keyframes modal-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modal-backdrop-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}

View File

@@ -13,8 +13,9 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "MyMoney",
description:
"Tieni traccia delle tue finanze con MyMoney, in modo semplice ed efficace.",
};
export default function RootLayout({
@@ -24,10 +25,10 @@ export default function RootLayout({
}>) {
return (
<html
lang="en"
lang="it"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full bg-[#182033]">{children}</body>
</html>
);
}

View File

@@ -1,65 +1,9 @@
import Image from "next/image";
import { redirect } from "next/navigation";
import { getAuthenticatedUser } from "@/lib/auth";
import { isInstanceConfigured } from "@/lib/database/setup";
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
export default async function Home() {
const user = await getAuthenticatedUser();
if (user) redirect("/dashboard");
redirect((await isInstanceConfigured()) ? "/login" : "/setup");
}

View File

@@ -0,0 +1,12 @@
import { Header } from "@/components/shared/Header";
import type { UserSession } from "@/types/finance";
export function AppShell({
user,
children,
}: {
user: UserSession;
children: React.ReactNode;
}) {
return <Header user={user}>{children}</Header>;
}

View File

@@ -0,0 +1,75 @@
"use client";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { inputClass } from "@/components/shared/Field";
export function AuthForm() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function submit(event: React.FormEvent) {
event.preventDefault();
setLoading(true);
setError("");
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const payload = await response.json().catch(() => ({}));
setLoading(false);
if (!response.ok) {
setError(payload.error ?? "Accesso non riuscito");
return;
}
router.push("/dashboard");
router.refresh();
}
return (
<main className="grid min-h-screen place-items-center bg-[#182033] p-4 text-slate-100">
<form
className="w-full max-w-sm rounded-lg border border-white/10 bg-slate-950 p-6"
onSubmit={submit}
>
<div className="mb-2 flex flex-row items-center gap-2">
<Image src="/logo.png" alt="MyMoney" width={32} height={32} />
<h1 className="text-2xl font-semibold text-white">MyMoney</h1>
</div>
<p className="mt-1 text-sm text-slate-400">
Accedi a MyMoney - l'app per tracciare le tue finanze.
</p>
<div className="mt-6 grid gap-4">
<input
className={inputClass}
placeholder="Email"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
<input
className={inputClass}
placeholder="Password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
{error ? <p className="text-sm text-rose-300">{error}</p> : null}
<button
className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white hover:bg-sky-400 disabled:opacity-60"
disabled={loading}
type="submit"
>
{loading ? "Accesso..." : "Accedi"}
</button>
</div>
</form>
</main>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { inputClass } from "@/components/shared/Field";
export function SetupForm() {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function submit(event: React.FormEvent) {
event.preventDefault();
setLoading(true);
setError("");
const response = await fetch("/api/auth/setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, password }),
});
const payload = await response.json().catch(() => ({}));
setLoading(false);
if (!response.ok) {
setError(payload.error ?? "Configurazione non riuscita");
return;
}
router.push("/dashboard");
router.refresh();
}
return (
<main className="grid min-h-screen place-items-center bg-[#182033] p-4 text-slate-100">
<form
className="w-full max-w-sm rounded-lg border border-white/10 bg-slate-950 p-6"
onSubmit={submit}
>
<div className="mb-2 flex flex-row items-center gap-2">
<Image src="/logo.png" alt="MyMoney" width={32} height={32} />
<h1 className="text-2xl font-semibold text-white">MyMoney</h1>
</div>
<p className="mt-1 text-sm text-slate-400">
Configura il primo account per iniziare a usare la tua istanza.
</p>
<div className="mt-6 grid gap-4">
<input
className={inputClass}
placeholder="Nome"
value={name}
onChange={(event) => setName(event.target.value)}
/>
<input
className={inputClass}
placeholder="Email"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
<input
className={inputClass}
placeholder="Password (almeno 8 caratteri)"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
{error ? <p className="text-sm text-rose-300">{error}</p> : null}
<button
className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white hover:bg-sky-400 disabled:opacity-60"
disabled={loading}
type="submit"
>
{loading ? "Configurazione..." : "Crea account"}
</button>
</div>
</form>
</main>
);
}

View File

@@ -0,0 +1,162 @@
"use client";
import { useEffect, useState } from "react";
import { DateField } from "@/components/shared/DateField";
import { Field, inputClass } from "@/components/shared/Field";
import { Select } from "@/components/shared/Select";
import {
SimpleFormActions,
SimpleFormReview,
SimpleFormStepIndicator,
} from "@/components/shared/SimpleFormSteps";
import { TitleCombobox } from "@/components/shared/TitleCombobox";
import { formatItalianDate, todayIso } from "@/lib/date/formatDate";
import { paymentStatusLabel } from "@/lib/finance/labels";
import {
euroFormatter,
formatMoneyInput,
parseMoneyInput,
} from "@/lib/finance/money";
import type { Bill, PaymentTitle } from "@/types/finance";
export function BillForm({
titles,
editing,
onSubmit,
onCreateTitle,
onCancel,
defaultDate = todayIso(),
saving = false,
}: {
titles: PaymentTitle[];
editing?: Bill;
onSubmit: (data: object) => Promise<void>;
onCreateTitle?: (name: string) => Promise<void>;
onCancel?: () => void;
defaultDate?: string;
saving?: boolean;
}) {
const [review, setReview] = useState(false);
const [title, setTitle] = useState(editing?.title ?? "");
const [amount, setAmount] = useState(formatMoneyInput(editing?.amount));
const [dueDate, setDueDate] = useState(editing?.dueDate ?? defaultDate);
const [status, setStatus] = useState(editing?.status ?? "pending");
const [paymentDate, setPaymentDate] = useState(
editing?.paymentDate ?? todayIso(),
);
const [error, setError] = useState("");
useEffect(() => {
setReview(false);
setTitle(editing?.title ?? "");
setAmount(formatMoneyInput(editing?.amount));
setDueDate(editing?.dueDate ?? defaultDate);
setStatus(editing?.status ?? "pending");
setPaymentDate(editing?.paymentDate ?? todayIso());
setError("");
}, [defaultDate, editing]);
function payload() {
if (!title.trim()) throw new Error("Inserisci il titolo della bolletta");
if (!dueDate) throw new Error("Inserisci la scadenza della bolletta");
return {
title,
amount: parseMoneyInput(amount),
dueDate,
status,
paymentDate: status === "paid" ? paymentDate : undefined,
};
}
return (
<form
onSubmit={async (event) => {
event.preventDefault();
try {
setError("");
const data = payload();
if (!review) {
setReview(true);
return;
}
await onSubmit(data);
} catch (err) {
setError(
err instanceof Error ? err.message : "Salvataggio non riuscito",
);
}
}}
>
<SimpleFormStepIndicator review={review} />
{review ? (
<SimpleFormReview
rows={[
{
label: "Importo",
value: euroFormatter.format(parseMoneyInput(amount)),
},
{ label: "Scadenza", value: formatItalianDate(dueDate) },
{ label: "Stato", value: paymentStatusLabel(status) },
...(status === "paid"
? [
{
label: "Data pagamento",
value: formatItalianDate(paymentDate),
},
]
: []),
]}
title={title}
/>
) : (
<div className="grid gap-3 md:grid-cols-3">
<TitleCombobox
titles={titles}
type="bill"
value={title}
onChange={setTitle}
onCreate={onCreateTitle}
/>
<Field label="Importo">
<input
className={inputClass}
value={amount}
onChange={(event) => setAmount(event.target.value)}
/>
</Field>
<DateField
label="Scadenza"
name="dueDate"
value={dueDate}
onChange={setDueDate}
/>
<Select
label="Stato"
value={status}
onChange={(value) => setStatus(value as typeof status)}
options={[
{ value: "pending", label: "In scadenza" },
{ value: "paid", label: "Pagata" },
{ value: "overdue", label: "Scaduta" },
]}
/>
{status === "paid" ? (
<DateField
label="Data pagamento"
name="paymentDate"
value={paymentDate}
onChange={setPaymentDate}
/>
) : null}
</div>
)}
{error ? <p className="mt-4 text-sm text-rose-300">{error}</p> : null}
<SimpleFormActions
onBack={() => setReview(false)}
onCancel={onCancel}
review={review}
saving={saving}
/>
</form>
);
}

View File

@@ -0,0 +1,109 @@
"use client";
import { useState } from "react";
import {
LuCalendarClock,
LuCircleCheck,
LuCircleX,
LuPencil,
LuTrash2,
} from "react-icons/lu";
import { BillStatusBadge } from "@/components/bills/BillStatusBadge";
import { EmptyState } from "@/components/shared/EmptyState";
import { financeListCardClass } from "@/components/shared/financeListCard";
import { ListCardAction } from "@/components/shared/ListCardAction";
import { formatItalianDate, todayIso } from "@/lib/date/formatDate";
import { euroFormatter } from "@/lib/finance/money";
import type { Bill } from "@/types/finance";
export function BillList({
bills,
onEdit,
onDelete,
onTogglePaid,
}: {
bills: Bill[];
onEdit: (bill: Bill) => void;
onDelete: (bill: Bill) => Promise<void>;
onTogglePaid: (bill: Bill) => Promise<void>;
}) {
const [busy, setBusy] = useState("");
if (!bills.length) return <EmptyState title="Nessuna bolletta" />;
return (
<div className="grid gap-2">
{bills.map((bill) => {
return (
<div className={financeListCardClass} key={bill.$id}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate font-medium text-white">
{bill.title}
</p>
<BillStatusBadge bill={bill} />
</div>
<p className="mt-1 text-lg font-semibold text-slate-100">
{euroFormatter.format(bill.amount)}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<ListCardAction
disabled={busy === `${bill.$id}:toggle`}
icon={bill.status === "paid" ? LuCircleX : LuCircleCheck}
label={
bill.status === "paid"
? `Segna ${bill.title} come non pagata`
: `Segna ${bill.title} come pagata`
}
onClick={async () => {
setBusy(`${bill.$id}:toggle`);
try {
await onTogglePaid(bill);
} finally {
setBusy("");
}
}}
/>
<ListCardAction
icon={LuPencil}
label={`Modifica ${bill.title}`}
onClick={() => onEdit(bill)}
/>
<ListCardAction
confirmDescription={`Sei sicuro di voler eliminare la bolletta "${bill.title}"?`}
danger
disabled={busy === `${bill.$id}:delete`}
icon={LuTrash2}
label={`Elimina ${bill.title}`}
onClick={async () => {
setBusy(`${bill.$id}:delete`);
try {
await onDelete(bill);
} finally {
setBusy("");
}
}}
/>
</div>
</div>
<p className="mt-3 flex items-center gap-1.5 text-xs text-slate-400">
<LuCalendarClock className="text-cyan-300" size={14} />
Scadenza {formatItalianDate(bill.dueDate)}
</p>
</div>
);
})}
</div>
);
}
export function toggledBillPayload(bill: Bill) {
const paid = bill.status === "paid";
return {
title: bill.title,
amount: bill.amount,
dueDate: bill.dueDate,
status: paid ? "pending" : "paid",
paymentDate: paid ? null : todayIso(),
};
}

View File

@@ -0,0 +1,13 @@
import { PaymentBadge } from "@/components/shared/PaymentBadge";
import { resolveBillStatus } from "@/lib/finance/billStatus";
import { paymentStatusLabel } from "@/lib/finance/labels";
import type { Bill } from "@/types/finance";
export function BillStatusBadge({ bill }: { bill: Bill }) {
const status = resolveBillStatus(bill);
return (
<PaymentBadge sourceType="bill" status={status}>
{paymentStatusLabel(status)}
</PaymentBadge>
);
}

View File

@@ -0,0 +1,162 @@
"use client";
import { useMemo, useState } from "react";
import { BillForm } from "@/components/bills/BillForm";
import { BillList, toggledBillPayload } from "@/components/bills/BillList";
import { EntityFormModal } from "@/components/shared/EntityFormModal";
import { MonthlyPageToolbar } from "@/components/shared/MonthlyPageToolbar";
import { PageShell } from "@/components/shared/PageShell";
import { SectionAddButton } from "@/components/shared/SectionAddButton";
import { notify } from "@/components/shared/Toast";
import {
apiSave,
evictCachedLedgerSource,
invalidateResource,
useBillsResource,
usePaymentTitlesResource,
} from "@/components/shared/useFinanceData";
import {
defaultDateForPeriod,
monthKey,
periodBounds,
} from "@/lib/date/formatDate";
import type { Bill, PaymentTitle } from "@/types/finance";
export function BillsPageClient() {
const bills = useBillsResource();
const titles = usePaymentTitlesResource();
const [editing, setEditing] = useState<Bill | undefined>();
const [modalOpen, setModalOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [month, setMonth] = useState(monthKey);
const visibleBills = useMemo(() => {
const { start, end } = periodBounds(month);
return (bills.data ?? []).filter(
(bill) => bill.dueDate >= start && bill.dueDate <= end,
);
}, [bills.data, month]);
const populatedMonths = useMemo(
() => new Set((bills.data ?? []).map((bill) => bill.dueDate.slice(0, 7))),
[bills.data],
);
async function createTitle(name: string) {
const saved = await apiSave<PaymentTitle>("/api/payment-titles", "POST", {
name,
paymentType: "bill",
});
titles.mutate((current) => {
const list = current ?? [];
return list.some((item) => item.$id === saved.$id)
? list
: [...list, saved];
});
}
async function save(payload: object) {
setSaving(true);
try {
if ("title" in payload) await createTitle(String(payload.title));
const saved = await apiSave<Bill>(
editing ? `/api/bills/${editing.$id}` : "/api/bills",
editing ? "PATCH" : "POST",
payload,
);
bills.mutate((current) => {
const list = current ?? [];
return editing
? list.map((item) => (item.$id === saved.$id ? saved : item))
: [saved, ...list];
});
if (saved.status !== "paid") evictCachedLedgerSource("bill", saved.$id);
invalidateResource("payments-ledger");
setModalOpen(false);
setEditing(undefined);
notify("Bolletta salvata");
} finally {
setSaving(false);
}
}
return (
<PageShell
title="Bollette"
description="Gestisci le tue bollette."
actions={
<SectionAddButton
label="Aggiungi bolletta"
onClick={() => {
setEditing(undefined);
setModalOpen(true);
}}
/>
}
>
<section className="grid gap-4 rounded-lg border border-white/10 bg-white/[0.03] p-4">
<MonthlyPageToolbar
allowYearSelection
month={month}
onChange={setMonth}
populatedMonths={populatedMonths}
/>
<BillList
bills={visibleBills}
onEdit={(bill) => {
setEditing(bill);
setModalOpen(true);
}}
onDelete={async (bill) => {
await apiSave(`/api/bills/${bill.$id}`, "DELETE");
bills.mutate((current) =>
(current ?? []).filter((item) => item.$id !== bill.$id),
);
evictCachedLedgerSource("bill", bill.$id);
invalidateResource("payments-ledger");
notify("Bolletta eliminata");
}}
onTogglePaid={async (bill) => {
const saved = await apiSave<Bill>(
`/api/bills/${bill.$id}`,
"PATCH",
toggledBillPayload(bill),
);
bills.mutate((current) =>
(current ?? []).map((item) =>
item.$id === saved.$id ? saved : item,
),
);
if (saved.status !== "paid") {
evictCachedLedgerSource("bill", saved.$id);
}
invalidateResource("payments-ledger");
notify("Stato bolletta aggiornato");
}}
/>
</section>
{modalOpen ? (
<EntityFormModal
onClose={() => {
setModalOpen(false);
setEditing(undefined);
}}
open
saving={saving}
title={editing ? "Modifica Bolletta" : "Nuova Bolletta"}
>
<BillForm
defaultDate={defaultDateForPeriod(month)}
editing={editing}
titles={titles.data ?? []}
onCancel={() => {
setModalOpen(false);
setEditing(undefined);
}}
onCreateTitle={createTitle}
onSubmit={save}
saving={saving}
/>
</EntityFormModal>
) : null}
</PageShell>
);
}

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>
);
}

View File

@@ -0,0 +1,132 @@
"use client";
import { useEffect, useState } from "react";
import { DateField } from "@/components/shared/DateField";
import { Field, inputClass } from "@/components/shared/Field";
import { Select } from "@/components/shared/Select";
import {
SimpleFormActions,
SimpleFormReview,
SimpleFormStepIndicator,
} from "@/components/shared/SimpleFormSteps";
import { TitleCombobox } from "@/components/shared/TitleCombobox";
import { formatItalianDate, todayIso } from "@/lib/date/formatDate";
import { incomeTypeLabel } from "@/lib/finance/labels";
import {
euroFormatter,
formatMoneyInput,
parseMoneyInput,
} from "@/lib/finance/money";
import type { Income, PaymentTitle } from "@/types/finance";
export function IncomeForm({
titles,
editing,
defaultDate = todayIso(),
onSubmit,
onCreateTitle,
onCancel,
saving = false,
}: {
titles: PaymentTitle[];
editing?: Income;
defaultDate?: string;
onSubmit: (data: object) => Promise<void>;
onCreateTitle?: (name: string) => Promise<void>;
onCancel?: () => void;
saving?: boolean;
}) {
const [review, setReview] = useState(false);
const [title, setTitle] = useState(editing?.title ?? "");
const [amount, setAmount] = useState(formatMoneyInput(editing?.amount));
const [type, setType] = useState(editing?.type ?? "salary");
const [date, setDate] = useState(editing?.date ?? defaultDate);
const [error, setError] = useState("");
useEffect(() => {
setReview(false);
setTitle(editing?.title ?? "");
setAmount(formatMoneyInput(editing?.amount));
setType(editing?.type ?? "salary");
setDate(editing?.date ?? defaultDate);
setError("");
}, [defaultDate, editing]);
function payload() {
if (!title.trim()) throw new Error("Inserisci il titolo dell'entrata");
if (!date) throw new Error("Inserisci la data dell'entrata");
return { title, amount: parseMoneyInput(amount), type, date };
}
return (
<form
onSubmit={async (event) => {
event.preventDefault();
try {
setError("");
const data = payload();
if (!review) {
setReview(true);
return;
}
await onSubmit(data);
} catch (err) {
setError(
err instanceof Error ? err.message : "Salvataggio non riuscito",
);
}
}}
>
<SimpleFormStepIndicator review={review} />
{review ? (
<SimpleFormReview
rows={[
{
label: "Importo",
value: euroFormatter.format(parseMoneyInput(amount)),
},
{ label: "Tipo", value: incomeTypeLabel(type) },
{ label: "Data", value: formatItalianDate(date) },
]}
title={title}
/>
) : (
<div className="grid gap-3 md:grid-cols-4">
<TitleCombobox
titles={titles}
type="income"
value={title}
onChange={setTitle}
onCreate={onCreateTitle}
/>
<Field label="Importo">
<input
className={inputClass}
value={amount}
onChange={(event) => setAmount(event.target.value)}
/>
</Field>
<Select
label="Tipo"
value={type}
onChange={(value) => setType(value as typeof type)}
options={[
{ value: "salary", label: "Stipendio" },
{ value: "recurring", label: "Ricorrente" },
{ value: "one_time", label: "Una tantum" },
{ value: "other", label: "Altro" },
]}
/>
<DateField label="Data" name="date" value={date} onChange={setDate} />
</div>
)}
{error ? <p className="mt-4 text-sm text-rose-300">{error}</p> : null}
<SimpleFormActions
onBack={() => setReview(false)}
onCancel={onCancel}
review={review}
saving={saving}
/>
</form>
);
}

View File

@@ -0,0 +1,83 @@
"use client";
import { useState } from "react";
import {
LuCalendarDays,
LuPencil,
LuTrash2,
LuTrendingUp,
} from "react-icons/lu";
import { EmptyState } from "@/components/shared/EmptyState";
import { financeListCardClass } from "@/components/shared/financeListCard";
import { ListCardAction } from "@/components/shared/ListCardAction";
import { PaymentBadge } from "@/components/shared/PaymentBadge";
import { formatItalianDate } from "@/lib/date/formatDate";
import { incomeTypeLabel } from "@/lib/finance/labels";
import { euroFormatter } from "@/lib/finance/money";
import type { Income } from "@/types/finance";
export function IncomeList({
income,
onEdit,
onDelete,
}: {
income: Income[];
onEdit: (item: Income) => void;
onDelete: (item: Income) => Promise<void>;
}) {
const [busy, setBusy] = useState("");
if (!income.length) return <EmptyState title="Nessuna entrata" />;
return (
<div className="grid gap-2">
{income.map((item) => (
<div className={financeListCardClass} key={item.$id}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate font-medium text-white">{item.title}</p>
<PaymentBadge sourceType="income" status="paid">
{incomeTypeLabel(item.type)}
</PaymentBadge>
</div>
<p className="mt-1 text-lg font-semibold text-slate-100">
{euroFormatter.format(item.amount)}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<ListCardAction
icon={LuPencil}
label={`Modifica ${item.title}`}
onClick={() => onEdit(item)}
/>
<ListCardAction
confirmDescription={`Sei sicuro di voler eliminare l'entrata "${item.title}"?`}
danger
disabled={busy === item.$id}
icon={LuTrash2}
label={`Elimina ${item.title}`}
onClick={async () => {
setBusy(item.$id);
try {
await onDelete(item);
} finally {
setBusy("");
}
}}
/>
</div>
</div>
<div className="mt-3 grid gap-1 text-xs text-slate-400 sm:grid-cols-2">
<p className="flex items-center gap-1.5">
<LuTrendingUp className="text-emerald-300" size={14} />
Entrata {incomeTypeLabel(item.type).toLowerCase()}
</p>
<p className="flex items-center gap-1.5">
<LuCalendarDays className="text-emerald-300" size={14} />
Registrata il {formatItalianDate(item.date)}
</p>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,138 @@
"use client";
import { useMemo, useState } from "react";
import { IncomeForm } from "@/components/income/IncomeForm";
import { IncomeList } from "@/components/income/IncomeList";
import { IncomePeriodToolbar } from "@/components/income/IncomePeriodToolbar";
import { EntityFormModal } from "@/components/shared/EntityFormModal";
import { PageShell } from "@/components/shared/PageShell";
import { SectionAddButton } from "@/components/shared/SectionAddButton";
import { notify } from "@/components/shared/Toast";
import {
apiSave,
useIncomeResource,
usePaymentTitlesResource,
} from "@/components/shared/useFinanceData";
import {
defaultDateForPeriod,
monthKey,
periodBounds,
} from "@/lib/date/formatDate";
import type { Income, PaymentTitle } from "@/types/finance";
export function IncomePageClient() {
const income = useIncomeResource();
const titles = usePaymentTitlesResource();
const [month, setMonth] = useState(monthKey);
const [editing, setEditing] = useState<Income | undefined>();
const [modalOpen, setModalOpen] = useState(false);
const [saving, setSaving] = useState(false);
const monthIncome = useMemo(() => {
const { start, end } = periodBounds(month);
return (income.data ?? []).filter(
(item) => item.date >= start && item.date <= end,
);
}, [income.data, month]);
const populatedMonths = useMemo(
() => new Set((income.data ?? []).map((item) => item.date.slice(0, 7))),
[income.data],
);
async function createTitle(name: string) {
const saved = await apiSave<PaymentTitle>("/api/payment-titles", "POST", {
name,
paymentType: "income",
});
titles.mutate((current) => {
const list = current ?? [];
return list.some((item) => item.$id === saved.$id)
? list
: [...list, saved];
});
}
async function save(payload: object) {
setSaving(true);
try {
if ("title" in payload) await createTitle(String(payload.title));
const saved = await apiSave<Income>(
editing ? `/api/income/${editing.$id}` : "/api/income",
editing ? "PATCH" : "POST",
payload,
);
income.mutate((current) => {
const list = current ?? [];
return editing
? list.map((item) => (item.$id === saved.$id ? saved : item))
: [saved, ...list];
});
setModalOpen(false);
setEditing(undefined);
notify("Entrata salvata");
} finally {
setSaving(false);
}
}
return (
<PageShell
title="Entrate"
description="Inserisci le tue entrate mesili o occasionali."
actions={
<SectionAddButton
label="Aggiungi entrata"
onClick={() => {
setEditing(undefined);
setModalOpen(true);
}}
/>
}
>
<section className="grid gap-4 rounded-lg border border-white/10 bg-white/[0.03] p-4">
<IncomePeriodToolbar
month={month}
onMonthChange={setMonth}
populatedMonths={populatedMonths}
/>
<IncomeList
income={monthIncome}
onEdit={(item) => {
setEditing(item);
setModalOpen(true);
}}
onDelete={async (item) => {
await apiSave(`/api/income/${item.$id}`, "DELETE");
income.mutate((current) =>
(current ?? []).filter((entry) => entry.$id !== item.$id),
);
notify("Entrata eliminata");
}}
/>
</section>
{modalOpen ? (
<EntityFormModal
onClose={() => {
setModalOpen(false);
setEditing(undefined);
}}
open
saving={saving}
title={editing ? "Modifica Entrata" : "Nuova Entrata"}
>
<IncomeForm
defaultDate={defaultDateForPeriod(month)}
editing={editing}
titles={titles.data ?? []}
onCancel={() => {
setModalOpen(false);
setEditing(undefined);
}}
onCreateTitle={createTitle}
onSubmit={save}
saving={saving}
/>
</EntityFormModal>
) : null}
</PageShell>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
import { LocalMonthNavigator } from "@/components/shared/LocalMonthNavigator";
export function IncomePeriodToolbar({
month,
onMonthChange,
populatedMonths,
}: {
month: string;
onMonthChange: (month: string) => void;
populatedMonths?: Iterable<string>;
}) {
return (
<div className="grid gap-3">
<div className="flex flex-wrap items-center justify-center gap-2">
<LocalMonthNavigator
allowYearSelection
month={month}
onChange={onMonthChange}
populatedMonths={populatedMonths}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,116 @@
"use client";
import { useEffect, useState } from "react";
import { DateField } from "@/components/shared/DateField";
import { Field, inputClass } from "@/components/shared/Field";
import {
SimpleFormActions,
SimpleFormReview,
SimpleFormStepIndicator,
} from "@/components/shared/SimpleFormSteps";
import { TitleCombobox } from "@/components/shared/TitleCombobox";
import { formatItalianDate, todayIso } from "@/lib/date/formatDate";
import {
euroFormatter,
formatMoneyInput,
parseMoneyInput,
} from "@/lib/finance/money";
import type { OneTimePayment, PaymentTitle } from "@/types/finance";
export function OneTimePaymentForm({
titles,
editing,
onSubmit,
onCreateTitle,
onCancel,
defaultDate = todayIso(),
saving = false,
}: {
titles: PaymentTitle[];
editing?: OneTimePayment;
onSubmit: (data: object) => Promise<void>;
onCreateTitle?: (name: string) => Promise<void>;
onCancel?: () => void;
defaultDate?: string;
saving?: boolean;
}) {
const [review, setReview] = useState(false);
const [title, setTitle] = useState(editing?.title ?? "");
const [amount, setAmount] = useState(formatMoneyInput(editing?.amount));
const [date, setDate] = useState(editing?.date ?? defaultDate);
const [error, setError] = useState("");
useEffect(() => {
setReview(false);
setTitle(editing?.title ?? "");
setAmount(formatMoneyInput(editing?.amount));
setDate(editing?.date ?? defaultDate);
setError("");
}, [defaultDate, editing]);
function payload() {
if (!title.trim()) throw new Error("Inserisci il titolo del pagamento");
if (!date) throw new Error("Inserisci la data del pagamento");
return { title, amount: parseMoneyInput(amount), date };
}
return (
<form
onSubmit={async (event) => {
event.preventDefault();
try {
setError("");
const data = payload();
if (!review) {
setReview(true);
return;
}
await onSubmit(data);
} catch (err) {
setError(
err instanceof Error ? err.message : "Salvataggio non riuscito",
);
}
}}
>
<SimpleFormStepIndicator review={review} />
{review ? (
<SimpleFormReview
rows={[
{
label: "Importo",
value: euroFormatter.format(parseMoneyInput(amount)),
},
{ label: "Data", value: formatItalianDate(date) },
]}
title={title}
/>
) : (
<div className="grid gap-3 md:grid-cols-3">
<TitleCombobox
titles={titles}
type="one_time"
value={title}
onChange={setTitle}
onCreate={onCreateTitle}
/>
<Field label="Importo">
<input
className={inputClass}
value={amount}
onChange={(event) => setAmount(event.target.value)}
/>
</Field>
<DateField label="Data" name="date" value={date} onChange={setDate} />
</div>
)}
{error ? <p className="mt-4 text-sm text-rose-300">{error}</p> : null}
<SimpleFormActions
onBack={() => setReview(false)}
onCancel={onCancel}
review={review}
saving={saving}
/>
</form>
);
}

View File

@@ -0,0 +1,84 @@
"use client";
import { useState } from "react";
import {
LuCalendarDays,
LuPencil,
LuReceiptText,
LuTrash2,
} from "react-icons/lu";
import { EmptyState } from "@/components/shared/EmptyState";
import { financeListCardClass } from "@/components/shared/financeListCard";
import { ListCardAction } from "@/components/shared/ListCardAction";
import { PaymentBadge } from "@/components/shared/PaymentBadge";
import { formatItalianDate } from "@/lib/date/formatDate";
import { euroFormatter } from "@/lib/finance/money";
import type { OneTimePayment } from "@/types/finance";
export function OneTimePaymentList({
payments,
onEdit,
onDelete,
}: {
payments: OneTimePayment[];
onEdit: (payment: OneTimePayment) => void;
onDelete: (payment: OneTimePayment) => Promise<void>;
}) {
const [busy, setBusy] = useState("");
if (!payments.length) return <EmptyState title="Nessun pagamento manuale" />;
return (
<div className="grid gap-2">
{payments.map((payment) => (
<div className={financeListCardClass} key={payment.$id}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate font-medium text-white">
{payment.title}
</p>
<PaymentBadge sourceType="one_time" status="neutral">
Manuale
</PaymentBadge>
</div>
<p className="mt-1 text-lg font-semibold text-slate-100">
{euroFormatter.format(payment.amount)}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<ListCardAction
icon={LuPencil}
label={`Modifica ${payment.title}`}
onClick={() => onEdit(payment)}
/>
<ListCardAction
confirmDescription={`Sei sicuro di voler eliminare il pagamento "${payment.title}"?`}
danger
disabled={busy === payment.$id}
icon={LuTrash2}
label={`Elimina ${payment.title}`}
onClick={async () => {
setBusy(payment.$id);
try {
await onDelete(payment);
} finally {
setBusy("");
}
}}
/>
</div>
</div>
<div className="mt-3 grid gap-1 text-xs text-slate-400 sm:grid-cols-2">
<p className="flex items-center gap-1.5">
<LuReceiptText className="text-slate-300" size={14} />
Pagamento singolo
</p>
<p className="flex items-center gap-1.5">
<LuCalendarDays className="text-slate-300" size={14} />
Registrato il {formatItalianDate(payment.date)}
</p>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,147 @@
"use client";
import { useMemo, useState } from "react";
import { OneTimePaymentForm } from "@/components/one-time-payments/OneTimePaymentForm";
import { OneTimePaymentList } from "@/components/one-time-payments/OneTimePaymentList";
import { EntityFormModal } from "@/components/shared/EntityFormModal";
import { MonthlyPageToolbar } from "@/components/shared/MonthlyPageToolbar";
import { PageShell } from "@/components/shared/PageShell";
import { SectionAddButton } from "@/components/shared/SectionAddButton";
import { notify } from "@/components/shared/Toast";
import {
apiSave,
evictCachedLedgerSource,
invalidateResource,
useOneTimePaymentsResource,
usePaymentTitlesResource,
} from "@/components/shared/useFinanceData";
import {
defaultDateForPeriod,
monthKey,
periodBounds,
} from "@/lib/date/formatDate";
import type { OneTimePayment, PaymentTitle } from "@/types/finance";
export function OneTimePaymentsPageClient() {
const payments = useOneTimePaymentsResource();
const titles = usePaymentTitlesResource();
const [editing, setEditing] = useState<OneTimePayment | undefined>();
const [modalOpen, setModalOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [month, setMonth] = useState(monthKey);
const visiblePayments = useMemo(() => {
const { start, end } = periodBounds(month);
return (payments.data ?? []).filter(
(payment) => payment.date >= start && payment.date <= end,
);
}, [month, payments.data]);
const populatedMonths = useMemo(
() =>
new Set((payments.data ?? []).map((payment) => payment.date.slice(0, 7))),
[payments.data],
);
async function createTitle(name: string) {
const saved = await apiSave<PaymentTitle>("/api/payment-titles", "POST", {
name,
paymentType: "one_time",
});
titles.mutate((current) => {
const list = current ?? [];
return list.some((item) => item.$id === saved.$id)
? list
: [...list, saved];
});
}
async function save(payload: object) {
setSaving(true);
try {
if ("title" in payload) await createTitle(String(payload.title));
const saved = await apiSave<OneTimePayment>(
editing
? `/api/one-time-payments/${editing.$id}`
: "/api/one-time-payments",
editing ? "PATCH" : "POST",
payload,
);
payments.mutate((current) => {
const list = current ?? [];
return editing
? list.map((item) => (item.$id === saved.$id ? saved : item))
: [saved, ...list];
});
invalidateResource("payments-ledger");
setModalOpen(false);
setEditing(undefined);
notify("Pagamento salvato");
} finally {
setSaving(false);
}
}
return (
<PageShell
title="Pagamenti manuali"
description="Spese immediate, senza categorie o note."
actions={
<SectionAddButton
label="Aggiungi pagamento"
onClick={() => {
setEditing(undefined);
setModalOpen(true);
}}
/>
}
>
<section className="grid gap-4 rounded-lg border border-white/10 bg-white/[0.03] p-4">
<MonthlyPageToolbar
allowYearSelection
month={month}
onChange={setMonth}
populatedMonths={populatedMonths}
/>
<OneTimePaymentList
payments={visiblePayments}
onEdit={(payment) => {
setEditing(payment);
setModalOpen(true);
}}
onDelete={async (payment) => {
await apiSave(`/api/one-time-payments/${payment.$id}`, "DELETE");
payments.mutate((current) =>
(current ?? []).filter((item) => item.$id !== payment.$id),
);
evictCachedLedgerSource("one_time", payment.$id);
invalidateResource("payments-ledger");
notify("Pagamento eliminato");
}}
/>
</section>
{modalOpen ? (
<EntityFormModal
onClose={() => {
setModalOpen(false);
setEditing(undefined);
}}
open
saving={saving}
title={editing ? "Modifica Pagamento" : "Nuovo Pagamento"}
>
<OneTimePaymentForm
defaultDate={defaultDateForPeriod(month)}
editing={editing}
titles={titles.data ?? []}
onCancel={() => {
setModalOpen(false);
setEditing(undefined);
}}
onCreateTitle={createTitle}
onSubmit={save}
saving={saving}
/>
</EntityFormModal>
) : null}
</PageShell>
);
}

View File

@@ -0,0 +1,148 @@
"use client";
import { useRef } from "react";
import type { IconType } from "react-icons";
import {
LuImagePlus,
LuKeyRound,
LuMail,
LuPencil,
LuTrash2,
LuUserRound,
} from "react-icons/lu";
import { ProfileCard } from "@/components/profile/ProfileCard";
import { ListCardAction } from "@/components/shared/ListCardAction";
import { UserAvatar } from "@/components/shared/UserAvatar";
import type { UserSession } from "@/types/finance";
export function AccountCard({
onDeleteAvatar,
onEditAccount,
onEditAvatar,
onEditPassword,
onSelectAvatar,
savingAvatar,
user,
}: {
onDeleteAvatar: () => Promise<void>;
onEditAccount: () => void;
onEditAvatar: () => void;
onEditPassword: () => void;
onSelectAvatar: (file: File) => void;
savingAvatar: boolean;
user: UserSession;
}) {
const inputRef = useRef<HTMLInputElement>(null);
return (
<ProfileCard
className="overflow-hidden border-sky-400/20 bg-gradient-to-br from-sky-400/[0.08] via-white/[0.035] to-violet-400/[0.06]"
description="Le informazioni del tuo account e le opzioni di accesso."
title="Account"
>
<div className="grid gap-5">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-4">
<UserAvatar
className="size-20 text-xl ring-4 ring-sky-400/10"
user={user}
/>
<div className="min-w-0">
<p className="truncate text-lg font-semibold text-white">
{user.name}
</p>
<p className="mt-1 truncate text-sm text-slate-400">
{user.email}
</p>
<p className="mt-2 text-xs text-slate-500">
JPG, PNG o WebP fino a 10 MB
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<input
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={async (event) => {
const file = event.target.files?.[0];
if (!file) return;
onSelectAvatar(file);
event.target.value = "";
}}
ref={inputRef}
type="file"
/>
<ListCardAction
disabled={savingAvatar}
icon={user.avatarUrl ? LuPencil : LuImagePlus}
label={user.avatarUrl ? "Modifica avatar" : "Carica avatar"}
onClick={() =>
user.avatarUrl ? onEditAvatar() : inputRef.current?.click()
}
/>
{user.avatarUrl ? (
<ListCardAction
confirmDescription="Sei sicuro di voler rimuovere l'immagine del profilo?"
danger
disabled={savingAvatar}
icon={LuTrash2}
label="Rimuovi avatar"
onClick={onDeleteAvatar}
/>
) : null}
</div>
</div>
<div className="grid gap-2">
<InfoRow
action={
<ListCardAction
icon={LuPencil}
label="Modifica nome ed email"
onClick={onEditAccount}
/>
}
icon={LuUserRound}
label="Nome"
value={user.name}
/>
<InfoRow icon={LuMail} label="Email" value={user.email} />
<InfoRow
action={
<ListCardAction
icon={LuPencil}
label="Modifica password"
onClick={onEditPassword}
/>
}
icon={LuKeyRound}
label="Password"
value="••••••••"
/>
</div>
</div>
</ProfileCard>
);
}
function InfoRow({
action,
icon: Icon,
label,
value,
}: {
action?: React.ReactNode;
icon: IconType;
label: string;
value: string;
}) {
return (
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 rounded-lg border border-white/10 bg-slate-950/25 px-3 py-2.5">
<Icon className="text-sky-300" size={16} />
<div className="min-w-0">
<p className="text-xs text-slate-500">{label}</p>
<p className="mt-0.5 truncate text-sm text-slate-200">{value}</p>
</div>
{action}
</div>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import { useEffect, useState } from "react";
import { EntityFormModal } from "@/components/shared/EntityFormModal";
import { Field, inputClass } from "@/components/shared/Field";
import {
SimpleFormActions,
SimpleFormReview,
SimpleFormStepIndicator,
} from "@/components/shared/SimpleFormSteps";
import type { UserSession } from "@/types/finance";
export function AccountEditModal({
onClose,
onSubmit,
open,
saving,
user,
}: {
onClose: () => void;
onSubmit: (payload: { name: string; email: string }) => Promise<boolean>;
open: boolean;
saving: boolean;
user: UserSession;
}) {
const [review, setReview] = useState(false);
const [name, setName] = useState(user.name);
const [email, setEmail] = useState(user.email);
useEffect(() => {
if (!open) return;
setReview(false);
setName(user.name);
setEmail(user.email);
}, [open, user.email, user.name]);
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!review) {
setReview(true);
return;
}
if (await onSubmit({ name, email })) onClose();
}
return (
<EntityFormModal
onClose={onClose}
open={open}
saving={saving}
title="Modifica dati account"
>
<SimpleFormStepIndicator review={review} />
<form onSubmit={submit}>
{review ? (
<SimpleFormReview
rows={[
{ label: "Nome", value: name },
{ label: "Email", value: email },
]}
title="Conferma i nuovi dati account"
/>
) : (
<div className="grid gap-3">
<Field label="Nome">
<input
autoComplete="name"
className={inputClass}
onChange={(event) => setName(event.target.value)}
required
value={name}
/>
</Field>
<Field label="Email">
<input
autoComplete="email"
className={inputClass}
onChange={(event) => setEmail(event.target.value)}
required
type="email"
value={email}
/>
</Field>
</div>
)}
<SimpleFormActions
onBack={() => setReview(false)}
onCancel={onClose}
review={review}
saving={saving}
/>
</form>
</EntityFormModal>
);
}

View File

@@ -0,0 +1,306 @@
// biome-ignore-all lint/performance/noImgElement: blob previews need stable native dimensions for canvas editing
"use client";
import { useEffect, useRef, useState } from "react";
import { LuCrop, LuImagePlus, LuRotateCcw, LuZoomIn } from "react-icons/lu";
import { EntityFormModal } from "@/components/shared/EntityFormModal";
const OUTPUT_SIZE = 512;
type ImageSize = { height: number; width: number };
export function AvatarCropModal({
file,
onClose,
onConfirm,
onSelectFile,
saving,
}: {
file?: File;
onClose: () => void;
onConfirm: (file: File) => Promise<void>;
onSelectFile: (file: File) => void;
saving: boolean;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const previewRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<
{ pointerId: number; x: number; y: number } | undefined
>(undefined);
const [source, setSource] = useState("");
const [imageSize, setImageSize] = useState<ImageSize>();
const [zoom, setZoom] = useState(1);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [previewSize, setPreviewSize] = useState(384);
useEffect(() => {
if (!file) return;
const url = URL.createObjectURL(file);
setSource(url);
setImageSize(undefined);
setZoom(1);
setPosition({ x: 0, y: 0 });
return () => URL.revokeObjectURL(url);
}, [file]);
useEffect(() => {
const preview = previewRef.current;
if (!preview) return;
const observer = new ResizeObserver(([entry]) => {
if (entry) setPreviewSize(entry.contentRect.width);
});
observer.observe(preview);
return () => observer.disconnect();
}, []);
return (
<EntityFormModal
onClose={onClose}
open={Boolean(file)}
panelClassName="max-w-md"
saving={saving}
title="Ridimensiona immagine profilo"
>
<p className="text-sm text-slate-400">
L'immagine parte interamente visibile. Trascinala o aumenta lo zoom solo
se vuoi regolare l'inquadratura.
</p>
<div className="mt-4 grid gap-4">
<div
className="relative mx-auto aspect-square w-full max-w-56 touch-none overflow-hidden rounded-lg border border-white/10 bg-slate-900 shadow-lg shadow-sky-950/40"
onPointerDown={(event) => {
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = {
pointerId: event.pointerId,
x: event.clientX,
y: event.clientY,
};
}}
onPointerMove={(event) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
setPosition((current) =>
clampPosition(
{
x: current.x + event.clientX - drag.x,
y: current.y + event.clientY - drag.y,
},
imageSize,
previewSize,
zoom,
),
);
dragRef.current = {
pointerId: event.pointerId,
x: event.clientX,
y: event.clientY,
};
}}
onPointerUp={(event) => {
if (dragRef.current?.pointerId === event.pointerId) {
dragRef.current = undefined;
}
}}
onPointerCancel={() => {
dragRef.current = undefined;
}}
ref={previewRef}
>
{source ? (
<img
alt="Anteprima ritaglio avatar"
className="pointer-events-none absolute inset-0 h-full w-full origin-center select-none object-contain"
draggable={false}
onLoad={(event) => {
const height = event.currentTarget.naturalHeight;
const width = event.currentTarget.naturalWidth;
setImageSize((current) => {
if (current) return current;
setPosition({ x: 0, y: 0 });
return { height, width };
});
}}
ref={imageRef}
src={source}
style={imageStyle(imageSize, previewSize, zoom, position)}
/>
) : null}
</div>
<div className="mx-auto grid w-full max-w-sm gap-3">
<div className="rounded-lg border border-white/10 bg-white/[0.03] p-3">
<div className="flex items-center gap-2 text-sm text-slate-300">
<LuZoomIn className="text-sky-300" size={16} />
Zoom
</div>
<input
aria-label="Zoom avatar"
className="mt-3 w-full accent-sky-400"
max="3"
min="1"
onChange={(event) => {
const nextZoom = Number(event.target.value);
setZoom(nextZoom);
setPosition((current) =>
clampPosition(current, imageSize, previewSize, nextZoom),
);
}}
step="0.01"
type="range"
value={zoom}
/>
</div>
<input
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(event) => {
const nextFile = event.target.files?.[0];
if (nextFile) onSelectFile(nextFile);
event.target.value = "";
}}
ref={fileInputRef}
type="file"
/>
<div className="grid grid-cols-2 gap-2">
<button
className="inline-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 disabled:opacity-50"
disabled={saving}
onClick={() => fileInputRef.current?.click()}
type="button"
>
<LuImagePlus size={16} />
Sostituisci
</button>
<button
className="inline-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"
onClick={() => {
setZoom(1);
setPosition({ x: 0, y: 0 });
}}
type="button"
>
<LuRotateCcw size={16} />
Ripristina
</button>
</div>
</div>
</div>
<div className="mt-6 flex flex-wrap justify-end gap-2 border-t border-white/10 pt-4">
<button
className="rounded-md border border-white/10 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/5"
disabled={saving}
onClick={onClose}
type="button"
>
Annulla
</button>
<button
className="flex items-center gap-2 rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-400 disabled:opacity-50"
disabled={saving || !source || !imageSize}
onClick={async () => {
if (!file || !imageRef.current) return;
await onConfirm(
await croppedAvatarFile(
imageRef.current,
file.name,
previewSize,
zoom,
position,
),
);
}}
type="button"
>
<LuCrop size={16} />
{saving ? "Salvataggio..." : "Salva immagine"}
</button>
</div>
</EntityFormModal>
);
}
function imageStyle(
_imageSize: ImageSize | undefined,
_previewSize: number,
zoom: number,
position: { x: number; y: number },
) {
return {
transform: `translate(${position.x}px, ${position.y}px) scale(${zoom})`,
};
}
function clampPosition(
position: { x: number; y: number },
imageSize: ImageSize | undefined,
previewSize: number,
zoom: number,
) {
if (!imageSize) return position;
const baseScale = initialImageScale(imageSize, previewSize);
const maxX = Math.max(
0,
(imageSize.width * baseScale * zoom - previewSize) / 2,
);
const maxY = Math.max(
0,
(imageSize.height * baseScale * zoom - previewSize) / 2,
);
return {
x: Math.min(maxX, Math.max(-maxX, position.x)),
y: Math.min(maxY, Math.max(-maxY, position.y)),
};
}
async function croppedAvatarFile(
image: HTMLImageElement,
originalFilename: string,
previewSize: number,
zoom: number,
position: { x: number; y: number },
) {
const canvas = document.createElement("canvas");
canvas.height = OUTPUT_SIZE;
canvas.width = OUTPUT_SIZE;
const context = canvas.getContext("2d");
if (!context) throw new Error("Impossibile elaborare l'immagine");
const outputScale = OUTPUT_SIZE / previewSize;
const baseScale = initialImageScale(
{ height: image.naturalHeight, width: image.naturalWidth },
previewSize,
);
const scale = baseScale * zoom * outputScale;
const width = image.naturalWidth * scale;
const height = image.naturalHeight * scale;
context.drawImage(
image,
(OUTPUT_SIZE - width) / 2 + position.x * outputScale,
(OUTPUT_SIZE - height) / 2 + position.y * outputScale,
width,
height,
);
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(result) =>
result
? resolve(result)
: reject(new Error("Esportazione non riuscita")),
"image/webp",
0.9,
);
});
return new File([blob], `${stripExtension(originalFilename)}.webp`, {
type: "image/webp",
});
}
function initialImageScale(imageSize: ImageSize, previewSize: number) {
return Math.min(
previewSize / imageSize.width,
previewSize / imageSize.height,
);
}
function stripExtension(filename: string) {
return filename.replace(/\.[^.]+$/, "") || "avatar";
}

View File

@@ -0,0 +1,45 @@
"use client";
import { EntityFormModal } from "@/components/shared/EntityFormModal";
export function ConfirmDangerModal({
saving,
onClose,
onConfirm,
}: {
saving: boolean;
onClose: () => void;
onConfirm: () => Promise<void>;
}) {
return (
<EntityFormModal
onClose={onClose}
open
saving={saving}
title="Elimina dati finanziari"
>
<p className="text-sm text-slate-300">
Questa azione elimina definitivamente tutti i tuoi dati finanziari. I
dati di accesso rimangono salvati.
</p>
<div className="mt-5 flex gap-2">
<button
className="rounded-md bg-rose-500 px-4 py-2 text-sm font-medium text-white hover:bg-rose-400 disabled:opacity-50"
disabled={saving}
onClick={onConfirm}
type="button"
>
{saving ? "Eliminazione..." : "Conferma"}
</button>
<button
className="rounded-md border border-white/10 px-4 py-2 text-sm text-slate-200 hover:bg-white/5 disabled:opacity-50"
disabled={saving}
onClick={onClose}
type="button"
>
Annulla
</button>
</div>
</EntityFormModal>
);
}

View File

@@ -0,0 +1,34 @@
import { ProfileCard } from "@/components/profile/ProfileCard";
export function DangerZoneCard({
onDeleteAccount,
onDeleteAll,
}: {
onDeleteAccount: () => void;
onDeleteAll: () => void;
}) {
return (
<ProfileCard
className="border-rose-400/20 bg-rose-950/10"
description="Azioni irreversibili protette da una conferma esplicita."
title="Gestione dati"
>
<div className="flex flex-wrap gap-2">
<button
className="rounded-md border border-rose-400/30 px-3 py-2 text-sm text-rose-100 transition hover:bg-rose-400/10"
onClick={onDeleteAll}
type="button"
>
Elimina tutti i dati finanziari
</button>
<button
className="rounded-md bg-rose-500 px-3 py-2 text-sm font-medium text-white transition hover:bg-rose-400"
onClick={onDeleteAccount}
type="button"
>
Elimina account
</button>
</div>
</ProfileCard>
);
}

View File

@@ -0,0 +1,77 @@
"use client";
import { useEffect, useState } from "react";
import { LuTrash2 } from "react-icons/lu";
import { EntityFormModal } from "@/components/shared/EntityFormModal";
import { Field, inputClass } from "@/components/shared/Field";
export function DeleteAccountModal({
onClose,
onConfirm,
open,
saving,
}: {
onClose: () => void;
onConfirm: (currentPassword: string) => Promise<boolean>;
open: boolean;
saving: boolean;
}) {
const [currentPassword, setCurrentPassword] = useState("");
useEffect(() => {
if (open) setCurrentPassword("");
}, [open]);
return (
<EntityFormModal
onClose={onClose}
open={open}
saving={saving}
title="Elimina account"
>
<p className="text-sm text-slate-300">
Verranno eliminati definitivamente account, sessioni, avatar e tutti i
dati finanziari.
</p>
<p className="mt-2 text-sm text-rose-300">
Questa operazione non può essere annullata.
</p>
<form
className="mt-5"
onSubmit={async (event) => {
event.preventDefault();
if (await onConfirm(currentPassword)) onClose();
}}
>
<Field label="Inserisci la password attuale per confermare">
<input
autoComplete="current-password"
className={inputClass}
onChange={(event) => setCurrentPassword(event.target.value)}
required
type="password"
value={currentPassword}
/>
</Field>
<div className="mt-5 flex flex-wrap gap-2">
<button
className="flex items-center gap-2 rounded-md bg-rose-500 px-4 py-2 text-sm font-medium text-white transition hover:bg-rose-400 disabled:opacity-50"
disabled={saving}
type="submit"
>
<LuTrash2 size={16} />
{saving ? "Eliminazione..." : "Elimina definitivamente"}
</button>
<button
className="rounded-md border border-white/10 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/5"
disabled={saving}
onClick={onClose}
type="button"
>
Annulla
</button>
</div>
</form>
</EntityFormModal>
);
}

View File

@@ -0,0 +1,118 @@
"use client";
import { useEffect, useState } from "react";
import { EntityFormModal } from "@/components/shared/EntityFormModal";
import { Field, inputClass } from "@/components/shared/Field";
import {
SimpleFormActions,
SimpleFormReview,
SimpleFormStepIndicator,
} from "@/components/shared/SimpleFormSteps";
export function PasswordEditModal({
onClose,
onSubmit,
open,
saving,
}: {
onClose: () => void;
onSubmit: (payload: {
currentPassword: string;
newPassword: string;
confirmPassword: string;
}) => Promise<boolean>;
open: boolean;
saving: boolean;
}) {
const [review, setReview] = useState(false);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
useEffect(() => {
if (!open) return;
setReview(false);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
}, [open]);
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!review) {
setReview(true);
return;
}
if (await onSubmit({ currentPassword, newPassword, confirmPassword })) {
onClose();
}
}
return (
<EntityFormModal
onClose={onClose}
open={open}
saving={saving}
title="Modifica password"
>
<SimpleFormStepIndicator review={review} />
<form onSubmit={submit}>
{review ? (
<SimpleFormReview
rows={[
{
label: "Password attuale",
value: "Verrà verificata al salvataggio",
},
{ label: "Nuova password", value: "••••••••" },
]}
title="Conferma il cambio password"
/>
) : (
<div className="grid gap-3">
<Field label="Password attuale">
<input
autoComplete="current-password"
className={inputClass}
onChange={(event) => setCurrentPassword(event.target.value)}
required
type="password"
value={currentPassword}
/>
</Field>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Nuova password">
<input
autoComplete="new-password"
className={inputClass}
minLength={8}
onChange={(event) => setNewPassword(event.target.value)}
required
type="password"
value={newPassword}
/>
</Field>
<Field label="Conferma nuova password">
<input
autoComplete="new-password"
className={inputClass}
minLength={8}
onChange={(event) => setConfirmPassword(event.target.value)}
required
type="password"
value={confirmPassword}
/>
</Field>
</div>
</div>
)}
<SimpleFormActions
onBack={() => setReview(false)}
onCancel={onClose}
review={review}
saving={saving}
/>
</form>
</EntityFormModal>
);
}

View File

@@ -0,0 +1,25 @@
export function ProfileCard({
title,
description,
children,
className = "",
contentClassName = "",
}: {
title: string;
description?: string;
children: React.ReactNode;
className?: string;
contentClassName?: string;
}) {
return (
<section
className={`flex flex-col rounded-lg border border-white/10 bg-white/[0.03] p-4 ${className}`}
>
<h2 className="font-medium text-white">{title}</h2>
{description ? (
<p className="mt-1 text-sm text-slate-400">{description}</p>
) : null}
<div className={`mt-4 ${contentClassName}`}>{children}</div>
</section>
);
}

View File

@@ -0,0 +1,225 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { AccountCard } from "@/components/profile/AccountCard";
import { AccountEditModal } from "@/components/profile/AccountEditModal";
import { AvatarCropModal } from "@/components/profile/AvatarCropModal";
import { ConfirmDangerModal } from "@/components/profile/ConfirmDangerModal";
import { DangerZoneCard } from "@/components/profile/DangerZoneCard";
import { DeleteAccountModal } from "@/components/profile/DeleteAccountModal";
import { PasswordEditModal } from "@/components/profile/PasswordEditModal";
import { PageShell } from "@/components/shared/PageShell";
import { notify } from "@/components/shared/Toast";
import {
apiSave,
invalidateFinancialResources,
} from "@/components/shared/useFinanceData";
import type { UserSession } from "@/types/finance";
export function ProfilePageClient({ user }: { user: UserSession }) {
const router = useRouter();
const [account, setAccount] = useState(user);
const [savingAccount, setSavingAccount] = useState(false);
const [savingPassword, setSavingPassword] = useState(false);
const [savingAvatar, setSavingAvatar] = useState(false);
const [deletingData, setDeletingData] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [deletingAccount, setDeletingAccount] = useState(false);
const [confirmDeleteAccount, setConfirmDeleteAccount] = useState(false);
const [editingAccount, setEditingAccount] = useState(false);
const [editingPassword, setEditingPassword] = useState(false);
const [selectedAvatar, setSelectedAvatar] = useState<File>();
async function saveAccount(payload: { name: string; email: string }) {
setSavingAccount(true);
try {
const saved = await apiSave<UserSession>(
"/api/profile/account",
"PATCH",
payload,
);
setAccount(saved);
router.refresh();
notify("Dati account aggiornati");
return true;
} catch (error) {
notify(errorMessage(error), "error");
return false;
} finally {
setSavingAccount(false);
}
}
async function savePassword(payload: {
currentPassword: string;
newPassword: string;
confirmPassword: string;
}) {
setSavingPassword(true);
try {
await apiSave("/api/profile/account", "POST", payload);
notify("Password aggiornata");
return true;
} catch (error) {
notify(errorMessage(error), "error");
return false;
} finally {
setSavingPassword(false);
}
}
async function uploadAvatar(file: File) {
setSavingAvatar(true);
try {
const formData = new FormData();
formData.set("avatar", file);
const saved = await saveAvatarRequest("POST", formData);
setAccount(saved);
router.refresh();
notify("Immagine profilo aggiornata");
return true;
} catch (error) {
notify(errorMessage(error), "error");
return false;
} finally {
setSavingAvatar(false);
}
}
async function editAvatar() {
if (!account.avatarUrl) return;
setSavingAvatar(true);
try {
const response = await fetch(account.avatarUrl, { cache: "no-store" });
if (!response.ok) throw new Error("Impossibile caricare l'immagine.");
const blob = await response.blob();
setSelectedAvatar(
new File([blob], avatarFilename(blob.type), { type: blob.type }),
);
} catch (error) {
notify(errorMessage(error), "error");
} finally {
setSavingAvatar(false);
}
}
async function deleteAvatar() {
setSavingAvatar(true);
try {
const saved = await saveAvatarRequest("DELETE");
setAccount(saved);
router.refresh();
notify("Immagine profilo rimossa");
} catch (error) {
notify(errorMessage(error), "error");
} finally {
setSavingAvatar(false);
}
}
async function deleteFinancialData() {
setDeletingData(true);
try {
await apiSave("/api/profile/data", "DELETE");
invalidateFinancialResources();
setConfirmDelete(false);
notify("Dati finanziari eliminati");
} catch (error) {
notify(errorMessage(error), "error");
} finally {
setDeletingData(false);
}
}
async function deleteAccount(currentPassword: string) {
setDeletingAccount(true);
try {
await apiSave("/api/profile/account", "DELETE", { currentPassword });
notify("Account eliminato");
router.push("/setup");
router.refresh();
return true;
} catch (error) {
notify(errorMessage(error), "error");
return false;
} finally {
setDeletingAccount(false);
}
}
return (
<PageShell
description="Gestisci i tuoi dati di accesso e la sicurezza dell'account."
title="Profilo"
>
<section className="grid gap-4 rounded-xl border border-white/10 bg-white/[0.025] p-4">
<AccountCard
onDeleteAvatar={deleteAvatar}
onEditAccount={() => setEditingAccount(true)}
onEditAvatar={editAvatar}
onEditPassword={() => setEditingPassword(true)}
onSelectAvatar={setSelectedAvatar}
savingAvatar={savingAvatar}
user={account}
/>
<DangerZoneCard
onDeleteAccount={() => setConfirmDeleteAccount(true)}
onDeleteAll={() => setConfirmDelete(true)}
/>
</section>
<AccountEditModal
onClose={() => setEditingAccount(false)}
onSubmit={saveAccount}
open={editingAccount}
saving={savingAccount}
user={account}
/>
<AvatarCropModal
file={selectedAvatar}
onClose={() => setSelectedAvatar(undefined)}
onConfirm={async (file) => {
if (await uploadAvatar(file)) setSelectedAvatar(undefined);
}}
onSelectFile={setSelectedAvatar}
saving={savingAvatar}
/>
<PasswordEditModal
onClose={() => setEditingPassword(false)}
onSubmit={savePassword}
open={editingPassword}
saving={savingPassword}
/>
{confirmDelete ? (
<ConfirmDangerModal
onClose={() => setConfirmDelete(false)}
onConfirm={deleteFinancialData}
saving={deletingData}
/>
) : null}
<DeleteAccountModal
onClose={() => setConfirmDeleteAccount(false)}
onConfirm={deleteAccount}
open={confirmDeleteAccount}
saving={deletingAccount}
/>
</PageShell>
);
}
function avatarFilename(mimeType: string) {
if (mimeType === "image/png") return "avatar.png";
if (mimeType === "image/jpeg") return "avatar.jpg";
return "avatar.webp";
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : "Operazione non riuscita";
}
async function saveAvatarRequest(method: "POST" | "DELETE", body?: FormData) {
const response = await fetch("/api/profile/avatar", { method, body });
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Operazione non riuscita");
return payload as UserSession;
}

View File

@@ -0,0 +1,66 @@
import { EmptyState } from "@/components/shared/EmptyState";
import { formatItalianDate } from "@/lib/date/formatDate";
import { euroFormatter } from "@/lib/finance/money";
import type { getBillsInsights } from "@/lib/finance/reportAnalysis";
type Insights = ReturnType<typeof getBillsInsights>;
export function BillsInsights({
insights,
periodLabel,
}: {
insights: Insights;
periodLabel: string;
}) {
const biggest = insights.topBills[0];
return (
<section className="flex min-h-full flex-col rounded-lg border border-white/10 bg-white/[0.03] p-4">
<h2 className="font-medium text-white">Analisi bollette</h2>
<p className="mt-1 text-sm text-slate-400">
Una lettura rapida delle bollette per {periodLabel}.
</p>
{biggest ? (
<div className="mt-4 grid flex-1 gap-3 sm:grid-cols-3">
<Insight
label="Totale periodo"
value={euroFormatter.format(insights.total)}
/>
<Insight
label="Importo medio"
value={euroFormatter.format(insights.average)}
/>
<Insight
label="Bolletta maggiore"
text={`${biggest.title} - ${formatItalianDate(biggest.dueDate)}`}
value={euroFormatter.format(biggest.amount)}
/>
</div>
) : (
<div className="mt-4">
<EmptyState
text="Prova a selezionare un altro periodo."
title="Nessuna bolletta registrata"
/>
</div>
)}
</section>
);
}
function Insight({
label,
text,
value,
}: {
label: string;
text?: string;
value: string;
}) {
return (
<div className="flex flex-col justify-center rounded-md border border-white/10 bg-white/[0.025] p-3">
<p className="text-xs text-slate-500">{label}</p>
<p className="mt-1 text-sm font-semibold text-white">{value}</p>
{text ? <p className="mt-1 text-xs text-slate-500">{text}</p> : null}
</div>
);
}

View File

@@ -0,0 +1,41 @@
import type { ExpenseDistributionItem } from "@/lib/finance/expenseDistribution";
import { euroFormatter } from "@/lib/finance/money";
export function ExpenseBreakdown({
items,
periodLabel,
}: {
items: ExpenseDistributionItem[];
periodLabel: string;
}) {
return (
<section className="flex min-h-full flex-col rounded-lg border border-white/10 bg-white/[0.03] p-4">
<h2 className="font-medium text-white">Dettaglio spese</h2>
<p className="mt-1 text-sm text-slate-400">
Quanto incidono le diverse tipologie sulla spesa reale.
</p>
<div className="mt-3 grid flex-1 gap-3 sm:grid-cols-3">
{items.map((item) => (
<article
className="flex flex-col justify-center rounded-lg border border-white/10 bg-white/[0.025] p-4"
key={item.sourceType}
>
<div className="flex items-center gap-2">
<span
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: item.color }}
/>
<h3 className="text-sm text-slate-300">{item.label}</h3>
</div>
<p className="mt-3 text-xl font-semibold text-white">
{euroFormatter.format(item.amount)}
</p>
<p className="mt-1 text-xs text-slate-500">
{item.percentage.toFixed(1)}% delle spese per {periodLabel}
</p>
</article>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,48 @@
import { euroFormatter } from "@/lib/finance/money";
export function MonthSummary({
income,
label,
projected,
scope,
spent,
}: {
income: number;
label: string;
projected: number;
scope: "mese" | "anno";
spent: number;
}) {
const balance = income - spent;
const savingsRate = income ? (balance / income) * 100 : 0;
const cards = [
[`Entrate ${scope}`, euroFormatter.format(income)],
["Spese reali", euroFormatter.format(spent)],
["Spese previste", euroFormatter.format(projected)],
[`Saldo ${scope}`, euroFormatter.format(balance)],
[`Risparmio ${scope}`, `${savingsRate.toFixed(1)}%`],
];
return (
<section className="grid gap-2 lg:grid-cols-[minmax(10rem,0.72fr)_minmax(0,4fr)]">
<div className="flex flex-col justify-center rounded-lg border border-sky-400/20 bg-white/[0.04] p-4">
<p className="text-xs font-medium uppercase tracking-wide text-sky-300">
Periodo selezionato
</p>
<h2 className="mt-1 text-lg font-semibold text-white">{label}</h2>
</div>
<div className="grid grid-cols-2 gap-2 rounded-lg border border-sky-400/20 bg-white/[0.04] p-4 md:grid-cols-5">
{cards.map(([cardLabel, value]) => (
<div
className="flex flex-col justify-center rounded-md border border-white/10 bg-slate-950/35 px-3 py-2"
key={cardLabel}
>
<p className="text-[11px] text-slate-500">{cardLabel}</p>
<p className="mt-1 truncate text-sm font-semibold text-white">
{value}
</p>
</div>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,186 @@
"use client";
import { useMemo, useState } from "react";
import { ExpenseDistribution } from "@/components/dashboard/ExpenseDistribution";
import { BillsInsights } from "@/components/reports/BillsInsights";
import { ExpenseBreakdown } from "@/components/reports/ExpenseBreakdown";
import { MonthSummary } from "@/components/reports/MonthSummary";
import { SubscriptionInsights } from "@/components/reports/SubscriptionInsights";
import { LocalMonthNavigator } from "@/components/shared/LocalMonthNavigator";
import { PageShell } from "@/components/shared/PageShell";
import {
useBillsResource,
useIncomeResource,
useLedgerResource,
useSubscriptionPaymentsResource,
useSubscriptionsResource,
} from "@/components/shared/useFinanceData";
import { monthKey } from "@/lib/date/formatDate";
import {
getMonthlyExpenseDistribution,
getYearlyExpenseDistribution,
} from "@/lib/finance/expenseDistribution";
import {
getBillsInsights,
getMonthlyReportSummary,
getSubscriptionInsights,
} from "@/lib/finance/reportAnalysis";
import { buildYearlyDashboardSummary } from "@/lib/finance/yearlySummary";
const fullMonthLabels = [
"Gennaio",
"Febbraio",
"Marzo",
"Aprile",
"Maggio",
"Giugno",
"Luglio",
"Agosto",
"Settembre",
"Ottobre",
"Novembre",
"Dicembre",
];
export function ReportsPageClient({ initialYear }: { initialYear: number }) {
const subscriptions = useSubscriptionsResource();
const subscriptionPayments = useSubscriptionPaymentsResource();
const bills = useBillsResource();
const ledger = useLedgerResource();
const income = useIncomeResource();
const [period, setPeriod] = useState(() => defaultMonth(initialYear));
const selectedYear = Number(period.slice(0, 4));
const yearlySummary = buildYearlyDashboardSummary({
year: selectedYear,
ledger: ledger.data ?? [],
income: income.data ?? [],
subscriptions: subscriptions.data ?? [],
subscriptionPayments: subscriptionPayments.data ?? [],
bills: bills.data ?? [],
});
const resources = [
subscriptions,
subscriptionPayments,
bills,
ledger,
income,
];
const loading = resources.some((resource) => resource.loading);
const error = resources.find((resource) => resource.error)?.error;
const annual = period.length === 4;
const selectedMonthSummary = useMemo(
() =>
annual
? undefined
: getMonthlyReportSummary({
month: period,
ledger: ledger.data ?? [],
income: income.data ?? [],
}),
[annual, income.data, ledger.data, period],
);
const selectedMonthlyPoint = annual
? undefined
: yearlySummary.monthly.find((point) => point.month === period);
const periodSummary = annual
? {
income: yearlySummary.yearlyIncome,
projected: yearlySummary.projectedSpent,
spent: yearlySummary.realSpent,
}
: {
income: selectedMonthSummary?.income ?? 0,
projected: selectedMonthlyPoint?.projected ?? 0,
spent: selectedMonthSummary?.spent ?? 0,
};
const distribution = useMemo(
() =>
annual
? getYearlyExpenseDistribution(ledger.data ?? [], selectedYear)
: getMonthlyExpenseDistribution(ledger.data ?? [], period),
[annual, ledger.data, period, selectedYear],
);
const subscriptionInsights = useMemo(
() => getSubscriptionInsights(subscriptions.data ?? [], period),
[period, subscriptions.data],
);
const billsInsights = useMemo(
() => getBillsInsights(bills.data ?? [], period),
[bills.data, period],
);
const populatedMonths = useMemo(
() =>
new Set(
yearlySummary.monthly
.filter(
(point) =>
point.income > 0 || point.spent > 0 || point.projected > 0,
)
.map((point) => point.month),
),
[yearlySummary.monthly],
);
const selectedPeriodLabel = annual
? `l'anno ${selectedYear}`
: monthLabel(period);
return (
<PageShell
description="Una lettura immediata di quanto spendi e dove va il tuo denaro."
title="Report"
>
{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">
<LocalMonthNavigator
allowYearSelection
month={period}
onChange={setPeriod}
populatedMonths={populatedMonths}
/>
<MonthSummary
income={periodSummary.income}
label={annual ? `Anno ${selectedYear}` : monthLabel(period)}
projected={periodSummary.projected}
scope={annual ? "anno" : "mese"}
spent={periodSummary.spent}
/>
<div className="grid items-stretch gap-4 xl:grid-cols-[minmax(20rem,0.82fr)_minmax(0,1.18fr)]">
<ExpenseDistribution
items={distribution.items}
periodLabel={selectedPeriodLabel}
showReportLink={false}
total={distribution.total}
year={selectedYear}
/>
<ExpenseBreakdown
items={distribution.items}
periodLabel={selectedPeriodLabel}
/>
</div>
<div className="grid items-stretch gap-4 xl:grid-cols-2">
<SubscriptionInsights
insights={subscriptionInsights}
periodLabel={selectedPeriodLabel}
/>
<BillsInsights
insights={billsInsights}
periodLabel={selectedPeriodLabel}
/>
</div>
</section>
</PageShell>
);
}
function defaultMonth(year: number) {
const current = monthKey();
return current.startsWith(`${year}-`) ? current : `${year}-01`;
}
function monthLabel(month: string) {
const [year, monthNumber] = month.split("-");
return `${fullMonthLabels[Number(monthNumber) - 1]} ${year}`;
}

View File

@@ -0,0 +1,63 @@
import { EmptyState } from "@/components/shared/EmptyState";
import { euroFormatter } from "@/lib/finance/money";
import type { getSubscriptionInsights } from "@/lib/finance/reportAnalysis";
type Insight = ReturnType<typeof getSubscriptionInsights>[number];
export function SubscriptionInsights({
insights,
periodLabel,
}: {
insights: Insight[];
periodLabel: string;
}) {
const maximum = Math.max(1, ...insights.map((item) => item.periodCost));
return (
<section className="flex min-h-full flex-col rounded-lg border border-white/10 bg-white/[0.03] p-4">
<h2 className="font-medium text-white">Impatto abbonamenti</h2>
<p className="mt-1 text-sm text-slate-400">
Abbonamenti attivi ordinati per costo stimato nel periodo selezionato.
</p>
<div className="mt-4 grid flex-1 gap-2.5">
{insights.length ? (
insights.map(({ subscription, periodCost }) => (
<div key={subscription.$id}>
<div className="flex items-center justify-between gap-3 text-sm">
<span className="truncate text-slate-200">
{subscription.title}
</span>
<span className="shrink-0 text-slate-300">
{euroFormatter.format(periodCost)}
</span>
</div>
<p className="mt-0.5 text-xs text-slate-500">
{euroFormatter.format(subscription.amount)} -{" "}
{cycleLabel(subscription.billingCycle)}
</p>
<div className="mt-1 h-1 overflow-hidden rounded-full bg-slate-950">
<div
className="h-full rounded-full bg-violet-400"
style={{ width: `${(periodCost / maximum) * 100}%` }}
/>
</div>
</div>
))
) : (
<EmptyState title="Nessun abbonamento attivo" />
)}
</div>
<p className="mt-3 text-xs text-slate-500">{periodLabel}</p>
</section>
);
}
function cycleLabel(cycle: Insight["subscription"]["billingCycle"]) {
return (
{
monthly: "al mese",
weekly: "a settimana",
yearly: "all'anno",
custom: "personalizzato",
}[cycle] ?? cycle
);
}

View File

@@ -0,0 +1,20 @@
"use client";
import { YearSelector } from "@/components/dashboard/YearSelector";
export function AnnualPageToolbar({
year,
onChange,
children,
}: {
year: number;
onChange: (year: number) => void;
children?: React.ReactNode;
}) {
return (
<div className="w-full flex flex-nowrap items-center justify-center gap-2">
<YearSelector year={year} onChange={onChange} />
{children}
</div>
);
}

View File

@@ -0,0 +1,26 @@
type BadgeTone = "green" | "yellow" | "red" | "blue" | "violet" | "neutral";
const tones: Record<BadgeTone, string> = {
green: "border-emerald-400/30 bg-emerald-400/10 text-emerald-200",
yellow: "border-amber-400/30 bg-amber-400/10 text-amber-200",
red: "border-rose-400/30 bg-rose-400/10 text-rose-200",
blue: "border-sky-400/30 bg-sky-400/10 text-sky-200",
violet: "border-violet-400/30 bg-violet-400/10 text-violet-200",
neutral: "border-white/10 bg-white/5 text-slate-300",
};
export function Badge({
children,
tone = "neutral",
}: {
children: React.ReactNode;
tone?: BadgeTone;
}) {
return (
<span
className={`inline-flex rounded-full border px-2 py-1 text-xs ${tones[tone]}`}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,220 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { FaCalendarAlt } from "react-icons/fa";
import { Field, inputClass } from "@/components/shared/Field";
import {
addMonths,
formatItalianDate,
fromIso,
todayIso,
toIsoDate,
} from "@/lib/date/formatDate";
import { parseItalianDate } from "@/lib/date/parseItalianDate";
export function DateField({
label,
name,
value,
onChange,
optional = false,
}: {
label: string;
name: string;
value: string;
onChange: (value: string) => void;
optional?: boolean;
}) {
const [display, setDisplay] = useState(formatItalianDate(value));
const [open, setOpen] = useState(false);
const [error, setError] = useState("");
const wrapperRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const calendarRef = useRef<HTMLDivElement>(null);
const [viewMonth, setViewMonth] = useState(value.slice(0, 7));
const [calendarPosition, setCalendarPosition] = useState({
left: 0,
top: 0,
});
useEffect(() => {
setDisplay(formatItalianDate(value));
setViewMonth(value.slice(0, 7));
}, [value]);
useEffect(() => {
function close(event: MouseEvent) {
const target = event.target as Node;
if (
!wrapperRef.current?.contains(target) &&
!calendarRef.current?.contains(target)
) {
setOpen(false);
}
}
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, []);
useEffect(() => {
if (!open) return;
function positionCalendar() {
const rect = buttonRef.current?.getBoundingClientRect();
if (!rect) return;
const width = 288;
const gap = 8;
const left = Math.min(
Math.max(8, rect.right - width),
window.innerWidth - width - 8,
);
const fitsBelow = rect.bottom + gap + 336 <= window.innerHeight;
setCalendarPosition({
left,
top: fitsBelow ? rect.bottom + gap : Math.max(8, rect.top - 336 - gap),
});
}
positionCalendar();
window.addEventListener("resize", positionCalendar);
window.addEventListener("scroll", positionCalendar, true);
return () => {
window.removeEventListener("resize", positionCalendar);
window.removeEventListener("scroll", positionCalendar, true);
};
}, [open]);
const fallbackMonth = value.slice(0, 7) || todayIso().slice(0, 7);
const activeMonth = viewMonth || fallbackMonth;
const days = useMemo(() => calendarDays(activeMonth), [activeMonth]);
function commit(raw: string) {
if (optional && !raw.trim()) {
setError("");
onChange("");
return;
}
const parsed = parseItalianDate(raw);
if (!parsed) {
setError("Data non valida");
onChange("");
return;
}
setError("");
onChange(parsed);
}
return (
<div className="relative" ref={wrapperRef}>
<Field label={`${label}${optional ? " (opzionale)" : ""}`}>
<div className="flex gap-2">
<input
className={inputClass}
inputMode="numeric"
name={name}
placeholder="DD/MM/YYYY"
value={display}
onBlur={() => commit(display)}
onChange={(event) => {
setDisplay(event.target.value);
setError("");
}}
/>
<button
className="rounded-md border border-white/10 px-3 text-sm text-slate-200 hover:bg-white/5"
onClick={() => setOpen((current) => !current)}
ref={buttonRef}
type="button"
>
<FaCalendarAlt />
</button>
</div>
{error ? <span className="text-xs text-rose-300">{error}</span> : null}
</Field>
{open
? createPortal(
<div
className="fixed z-[70] w-72 rounded-lg border border-white/10 bg-slate-950 p-3 shadow-2xl"
ref={calendarRef}
style={calendarPosition}
>
<div className="mb-3 flex items-center justify-between">
<button
className="rounded border border-white/10 px-2 py-1 text-xs"
onClick={() =>
setViewMonth(addMonths(`${activeMonth}-01`, -1).slice(0, 7))
}
type="button"
>
Indietro
</button>
<span className="text-sm font-medium text-white">
{activeMonth}
</span>
<button
className="rounded border border-white/10 px-2 py-1 text-xs"
onClick={() =>
setViewMonth(addMonths(`${activeMonth}-01`, 1).slice(0, 7))
}
type="button"
>
Avanti
</button>
</div>
<div className="grid grid-cols-7 gap-1 text-center text-xs text-slate-500">
{[
["mon", "L"],
["tue", "M"],
["wed", "M"],
["thu", "G"],
["fri", "V"],
["sat", "S"],
["sun", "D"],
].map(([key, day]) => (
<span key={key}>{day}</span>
))}
</div>
<div className="mt-1 grid grid-cols-7 gap-1">
{days.map((day) => {
const active = day === value;
const inMonth = day.startsWith(activeMonth);
return (
<button
className={`rounded px-2 py-1.5 text-xs ${
active
? "bg-sky-500 text-white"
: inMonth
? "text-slate-200 hover:bg-white/10"
: "text-slate-600 hover:bg-white/5"
}`}
key={day}
onClick={() => {
setOpen(false);
setError("");
onChange(day);
}}
type="button"
>
{day.slice(8)}
</button>
);
})}
</div>
</div>,
document.body,
)
: null}
</div>
);
}
function calendarDays(month: string) {
const start = fromIso(`${month}-01`);
const mondayOffset = (start.getDay() + 6) % 7;
const gridStart = new Date(start);
gridStart.setDate(start.getDate() - mondayOffset);
return Array.from({ length: 42 }, (_, index) => {
const date = new Date(gridStart);
date.setDate(gridStart.getDate() + index);
return toIsoDate(date);
});
}

View File

@@ -0,0 +1,8 @@
export function EmptyState({ title, text }: { title: string; text?: string }) {
return (
<div className="rounded-lg border border-dashed border-white/10 bg-white/[0.03] p-6 text-center">
<p className="font-medium text-slate-100">{title}</p>
{text ? <p className="mt-1 text-sm text-slate-400">{text}</p> : null}
</div>
);
}

View File

@@ -0,0 +1,126 @@
"use client";
import { useEffect, useRef, useState } from "react";
const focusableSelector = [
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
export function EntityFormModal({
open,
title,
saving,
onClose,
panelClassName = "max-w-2xl",
children,
}: {
open: boolean;
title: string;
saving: boolean;
onClose: () => void;
panelClassName?: string;
children: React.ReactNode;
}) {
const panelRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLElement | null>(null);
const [mounted, setMounted] = useState(open);
const savingRef = useRef(saving);
const onCloseRef = useRef(onClose);
savingRef.current = saving;
onCloseRef.current = onClose;
useEffect(() => {
if (open) {
setMounted(true);
return;
}
const timeout = window.setTimeout(() => setMounted(false), 140);
return () => window.clearTimeout(timeout);
}, [open]);
useEffect(() => {
if (!open || !mounted) return;
triggerRef.current = document.activeElement as HTMLElement | null;
const panel = panelRef.current;
const focusable = panel?.querySelectorAll<HTMLElement>(focusableSelector);
focusable?.[0]?.focus();
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape" && !savingRef.current) {
event.preventDefault();
onCloseRef.current();
return;
}
if (event.key !== "Tab" || !panel) return;
const elements = Array.from(
panel.querySelectorAll<HTMLElement>(focusableSelector),
);
if (!elements.length) return;
const first = elements[0];
const last = elements[elements.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
triggerRef.current?.focus();
};
}, [open, mounted]);
if (!mounted) return null;
return (
<div
aria-labelledby="entity-modal-title"
aria-modal="true"
className={`fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-slate-950/25 p-4 backdrop-blur-sm ${
open
? "animate-[modal-backdrop-in_160ms_ease-out]"
: "animate-[modal-backdrop-out_140ms_ease-in]"
}`}
onMouseDown={(event) => {
if (event.target === event.currentTarget && !saving) onClose();
}}
role="dialog"
>
<div
className={`max-h-[calc(100vh-2rem)] w-full ${panelClassName} overflow-y-auto rounded-lg border border-white/10 bg-slate-950 p-5 shadow-2xl ${
open
? "animate-[modal-in_160ms_ease-out]"
: "animate-[modal-out_140ms_ease-in]"
}`}
ref={panelRef}
>
<div className="mb-5 flex items-start justify-between gap-3">
<h2
className="text-lg font-semibold text-white"
id="entity-modal-title"
>
{title}
</h2>
<button
aria-label="Chiudi"
className="rounded-md border border-white/10 px-3 py-1.5 text-sm text-slate-300 hover:bg-white/5 disabled:opacity-50"
disabled={saving}
onClick={onClose}
type="button"
>
Chiudi
</button>
</div>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
export function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="grid gap-1.5 text-sm text-slate-300">
<span>{label}</span>
{children}
</div>
);
}
export const inputClass =
"w-full rounded-md border border-white/10 bg-slate-950 px-3 py-2 text-sm text-slate-100 outline-none transition focus:border-sky-400";

View File

@@ -0,0 +1,54 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useRef, useState } from "react";
import { MobileSidebar } from "@/components/shared/navigation/MobileSidebar";
import { Sidebar } from "@/components/shared/navigation/Sidebar";
import { SidebarTrigger } from "@/components/shared/navigation/SidebarTrigger";
import { notify, Toast } from "@/components/shared/Toast";
import type { UserSession } from "@/types/finance";
export function Header({
user,
children,
}: {
user: UserSession;
children: React.ReactNode;
}) {
const pathname = usePathname();
const router = useRouter();
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const sidebarTriggerRef = useRef<HTMLButtonElement>(null);
async function logout() {
await fetch("/api/auth/logout", { method: "POST" });
notify("Disconnessione effettuata");
router.push("/login");
router.refresh();
}
return (
<div className="min-h-screen bg-[#182033] text-slate-100">
<Sidebar onLogout={logout} pathname={pathname} user={user} />
<MobileSidebar
onClose={() => setMobileSidebarOpen(false)}
onLogout={logout}
open={mobileSidebarOpen}
pathname={pathname}
triggerRef={sidebarTriggerRef}
user={user}
/>
<div className="lg:pl-64">
<header className="sticky top-0 z-20 bg-[#182033]/90 px-4 py-3 lg:hidden">
<SidebarTrigger
onClick={() => setMobileSidebarOpen(true)}
open={mobileSidebarOpen}
ref={sidebarTriggerRef}
/>
</header>
<main className="px-4 py-6 md:px-8">{children}</main>
</div>
<Toast />
</div>
);
}

View File

@@ -0,0 +1,90 @@
"use client";
import { useState } from "react";
import type { IconType } from "react-icons";
import { EntityFormModal } from "@/components/shared/EntityFormModal";
export function ListCardAction({
danger = false,
disabled = false,
confirmDescription,
icon: Icon,
label,
onClick,
}: {
danger?: boolean;
disabled?: boolean;
confirmDescription?: string;
icon: IconType;
label: string;
onClick: () => void | Promise<void>;
}) {
const [confirmOpen, setConfirmOpen] = useState(false);
const [confirming, setConfirming] = useState(false);
async function runAction() {
if (danger && confirmDescription) {
setConfirmOpen(true);
return;
}
await onClick();
}
return (
<>
<button
aria-label={label}
className={`grid size-9 place-items-center rounded-md border transition ${
danger
? "border-rose-400/20 text-rose-300 hover:bg-rose-400/10"
: "border-white/10 text-slate-300 hover:bg-white/5 hover:text-white"
}`}
disabled={disabled}
onClick={runAction}
title={label}
type="button"
>
<Icon size={15} />
</button>
{confirmOpen ? (
<EntityFormModal
onClose={() => setConfirmOpen(false)}
open
saving={confirming}
title="Conferma eliminazione"
>
<p className="text-sm text-slate-300">{confirmDescription}</p>
<p className="mt-2 text-sm text-slate-500">
Questa operazione non può essere annullata.
</p>
<div className="mt-5 flex gap-2">
<button
className="rounded-md bg-rose-500 px-4 py-2 text-sm font-medium text-white hover:bg-rose-400 disabled:opacity-50"
disabled={confirming}
onClick={async () => {
setConfirming(true);
try {
await onClick();
setConfirmOpen(false);
} finally {
setConfirming(false);
}
}}
type="button"
>
{confirming ? "Eliminazione..." : "Elimina"}
</button>
<button
className="rounded-md border border-white/10 px-4 py-2 text-sm text-slate-200 hover:bg-white/5"
disabled={confirming}
onClick={() => setConfirmOpen(false)}
type="button"
>
Annulla
</button>
</div>
</EntityFormModal>
) : null}
</>
);
}

View File

@@ -0,0 +1,148 @@
"use client";
import {
LuCalendarRange,
LuChevronLeft,
LuChevronRight,
LuRotateCcw,
} from "react-icons/lu";
import { monthKey } from "@/lib/date/formatDate";
const monthLabels = [
"Gen",
"Feb",
"Mar",
"Apr",
"Mag",
"Giu",
"Lug",
"Ago",
"Set",
"Ott",
"Nov",
"Dic",
];
export function LocalMonthNavigator({
month,
onChange,
populatedMonths = [],
allowYearSelection = false,
disabled = false,
}: {
month: string;
onChange: (month: string) => void;
populatedMonths?: Iterable<string>;
allowYearSelection?: boolean;
disabled?: boolean;
}) {
const [year, selectedMonthNumber = monthKey().slice(5)] = month.split("-");
const currentMonth = monthKey();
const currentYear = currentMonth.slice(0, 4);
const monthsWithContent = new Set(populatedMonths);
function changeYear(offset: number) {
const nextYear = String(Number(year) + offset);
onChange(month === year ? nextYear : `${nextYear}-${selectedMonthNumber}`);
}
return (
<div
className={`w-full rounded-xl border border-sky-400/15 bg-slate-950/25 p-2 shadow-sm shadow-sky-950/30 transition ${
disabled ? "opacity-40" : ""
}`}
>
<div className="mb-2 flex items-center justify-between gap-2">
{allowYearSelection ? (
<button
aria-label={`Mostra tutto l'anno ${year}`}
aria-pressed={month === year}
className={`flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm font-semibold transition ${
month === year
? "bg-sky-400/20 text-sky-100 ring-1 ring-sky-300/40"
: "text-white hover:bg-white/5"
}`}
disabled={disabled}
onClick={() => onChange(year)}
type="button"
>
<LuCalendarRange className="text-sky-300" size={16} />
<span>{year}</span>
<span className="text-[10px] font-medium uppercase tracking-wide text-sky-300">
Anno intero
</span>
</button>
) : (
<span className="flex items-center gap-2 px-1 text-sm font-semibold text-white">
<LuCalendarRange className="text-sky-300" size={16} />
{year}
</span>
)}
<div className="flex items-center gap-1">
<button
aria-label="Anno precedente"
className="grid size-8 place-items-center rounded-lg text-slate-300 transition hover:bg-sky-400/10 hover:text-sky-100 disabled:cursor-default"
disabled={disabled}
onClick={() => changeYear(-1)}
type="button"
>
<LuChevronLeft size={17} />
</button>
<button
aria-label="Torna al mese corrente"
className="grid size-8 place-items-center rounded-lg text-sky-200 transition hover:bg-sky-400/10 disabled:cursor-default disabled:opacity-35"
disabled={disabled || month === currentMonth}
onClick={() => onChange(currentMonth)}
title="Torna al mese corrente"
type="button"
>
<LuRotateCcw size={15} />
</button>
<button
aria-label="Anno successivo"
className="grid size-8 place-items-center rounded-lg text-slate-300 transition hover:bg-sky-400/10 hover:text-sky-100 disabled:cursor-default"
disabled={disabled}
onClick={() => changeYear(1)}
type="button"
>
<LuChevronRight size={17} />
</button>
</div>
</div>
<div className="grid grid-cols-6 gap-1 sm:grid-cols-12">
{monthLabels.map((label, index) => {
const value = `${year}-${String(index + 1).padStart(2, "0")}`;
const selected = month === value;
const populated = monthsWithContent.has(value);
const current = currentMonth === value && currentYear === year;
return (
<button
aria-label={`${label} ${year}${populated ? ", contiene dati" : ""}`}
aria-pressed={selected}
className={`relative flex min-h-10 flex-col items-center justify-center rounded-lg px-1 py-1 text-xs font-medium transition ${
selected
? "bg-sky-400/20 text-sky-100 ring-1 ring-sky-300/40"
: "text-slate-400 hover:bg-white/5 hover:text-slate-200"
}`}
disabled={disabled}
key={value}
onClick={() => onChange(value)}
type="button"
>
<span>{label}</span>
<span
className={`mt-1 size-1.5 rounded-full ${
populated
? "bg-sky-300 shadow-[0_0_7px_rgba(125,211,252,0.75)]"
: current
? "bg-slate-500"
: "bg-transparent"
}`}
/>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
"use client";
import { LocalMonthNavigator } from "@/components/shared/LocalMonthNavigator";
export function MonthlyPageToolbar({
month,
onChange,
populatedMonths,
allowYearSelection = false,
children,
}: {
month: string;
onChange: (month: string) => void;
populatedMonths?: Iterable<string>;
allowYearSelection?: boolean;
children?: React.ReactNode;
}) {
return (
<div className="w-full flex flex-nowrap items-center justify-start gap-2">
<LocalMonthNavigator
allowYearSelection={allowYearSelection}
month={month}
onChange={onChange}
populatedMonths={populatedMonths}
/>
{children}
</div>
);
}

View File

@@ -0,0 +1,26 @@
export function PageShell({
title,
description,
actions,
children,
}: {
title: string;
description?: string;
actions?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div className="grid gap-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-col">
<h1 className="text-2xl font-semibold text-white">{title}</h1>
{description ? (
<p className="mt-1 text-sm text-slate-400">{description}</p>
) : null}
</div>
{actions}
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { getPaymentVisualStyle } from "@/lib/finance/paymentVisualStyle";
import type { CalendarEventType, PaymentSourceType } from "@/types/finance";
export function PaymentBadge({
sourceType,
status,
children,
}: {
sourceType: PaymentSourceType | CalendarEventType | "income";
status?: string;
children: React.ReactNode;
}) {
return (
<span
className={`inline-flex rounded-full border px-2 py-1 text-xs ${getPaymentVisualStyle(sourceType, status).badge}`}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,19 @@
export function SectionAddButton({
label,
onClick,
}: {
label: string;
onClick: () => void;
}) {
return (
<button
aria-label={label}
className="grid size-10 shrink-0 place-items-center rounded-md bg-sky-500 text-lg font-medium text-white hover:bg-sky-400 sm:flex sm:w-auto sm:px-4 sm:text-sm"
onClick={onClick}
type="button"
>
<span>+</span>
<span className="hidden sm:inline">&nbsp;Aggiungi</span>
</button>
);
}

View File

@@ -0,0 +1,57 @@
"use client";
import { LuCalendarDays, LuCalendarRange } from "react-icons/lu";
export type SectionPeriodView = "month" | "year";
export function SectionPeriodTabs({
view,
onChange,
}: {
view: SectionPeriodView;
onChange: (view: SectionPeriodView) => void;
}) {
return (
<div className="grid grid-cols-2 rounded-xl border border-white/10 bg-slate-950/60 p-1">
<Tab
active={view === "month"}
icon={<LuCalendarDays size={16} />}
label="Mese"
onClick={() => onChange("month")}
/>
<Tab
active={view === "year"}
icon={<LuCalendarRange size={16} />}
label="Anno"
onClick={() => onChange("year")}
/>
</div>
);
}
function Tab({
active,
icon,
label,
onClick,
}: {
active: boolean;
icon: React.ReactNode;
label: string;
onClick: () => void;
}) {
return (
<button
className={`flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition ${
active
? "bg-sky-400/15 text-sky-100"
: "text-slate-400 hover:bg-white/5 hover:text-slate-200"
}`}
onClick={onClick}
type="button"
>
{icon}
{label}
</button>
);
}

View File

@@ -0,0 +1,178 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { LuCheck, LuChevronDown } from "react-icons/lu";
import { Field, inputClass } from "@/components/shared/Field";
type Option = { value: string; label: string };
export function Select({
label,
value,
onChange,
options,
}: {
label: string;
value: string;
onChange: (value: string) => void;
options: Option[];
}) {
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(() =>
selectedIndex(options, value),
);
const wrapperRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [dropdownPosition, setDropdownPosition] = useState({
left: 0,
top: 0,
width: 0,
});
const selected = options.find((option) => option.value === value);
useEffect(() => {
setActiveIndex(selectedIndex(options, value));
}, [options, value]);
useEffect(() => {
function close(event: MouseEvent) {
const target = event.target as Node;
if (
!wrapperRef.current?.contains(target) &&
!dropdownRef.current?.contains(target)
) {
setOpen(false);
}
}
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, []);
useEffect(() => {
if (!open) return;
function positionDropdown() {
const rect = buttonRef.current?.getBoundingClientRect();
if (!rect) return;
const gap = 8;
const estimatedHeight = Math.min(options.length * 40 + 8, 320);
const fitsBelow =
rect.bottom + gap + estimatedHeight <= window.innerHeight;
setDropdownPosition({
left: Math.max(
8,
Math.min(rect.left, window.innerWidth - rect.width - 8),
),
top: fitsBelow
? rect.bottom + gap
: Math.max(8, rect.top - estimatedHeight - gap),
width: rect.width,
});
}
positionDropdown();
window.addEventListener("resize", positionDropdown);
window.addEventListener("scroll", positionDropdown, true);
return () => {
window.removeEventListener("resize", positionDropdown);
window.removeEventListener("scroll", positionDropdown, true);
};
}, [open, options.length]);
function choose(option: Option) {
onChange(option.value);
setOpen(false);
buttonRef.current?.focus();
}
function moveActive(direction: number) {
setActiveIndex((current) =>
Math.min(Math.max(current + direction, 0), options.length - 1),
);
}
return (
<div className="relative" ref={wrapperRef}>
<Field label={label}>
<button
aria-expanded={open}
aria-haspopup="listbox"
className={`${inputClass} flex items-center justify-between gap-3 text-left`}
onClick={() => setOpen((current) => !current)}
onKeyDown={(event) => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (!open) {
setOpen(true);
return;
}
moveActive(event.key === "ArrowDown" ? 1 : -1);
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (!open) {
setOpen(true);
return;
}
const option = options[activeIndex];
if (option) choose(option);
}
if (event.key === "Escape") setOpen(false);
}}
ref={buttonRef}
type="button"
>
<span className="truncate">{selected?.label ?? value}</span>
<LuChevronDown
className={`shrink-0 text-slate-400 transition ${open ? "rotate-180" : ""}`}
size={16}
/>
</button>
</Field>
{open
? createPortal(
<div
aria-label={label}
className="fixed z-[70] max-h-80 overflow-y-auto rounded-lg border border-white/10 bg-slate-950 p-1 shadow-2xl"
ref={dropdownRef}
role="listbox"
style={dropdownPosition}
>
{options.map((option, index) => {
const active = index === activeIndex;
const checked = option.value === value;
return (
<button
aria-selected={checked}
className={`flex w-full items-center justify-between gap-3 rounded-md px-3 py-2 text-left text-sm transition ${
active
? "bg-sky-500/15 text-sky-100"
: "text-slate-200 hover:bg-white/5"
}`}
key={option.value}
onClick={() => choose(option)}
onMouseEnter={() => setActiveIndex(index)}
role="option"
type="button"
>
<span className="truncate">{option.label}</span>
{checked ? (
<LuCheck className="shrink-0 text-sky-300" size={15} />
) : null}
</button>
);
})}
</div>,
document.body,
)
: null}
</div>
);
}
function selectedIndex(options: Option[], value: string) {
return Math.max(
0,
options.findIndex((option) => option.value === value),
);
}

View File

@@ -0,0 +1,107 @@
import { LuArrowLeft, LuArrowRight, LuCircleCheck } from "react-icons/lu";
export function SimpleFormStepIndicator({ review }: { review: boolean }) {
return (
<div className="mb-5 grid grid-cols-2 gap-2">
{["Dati", "Conferma"].map((label, index) => {
const active = review ? index === 1 : index === 0;
const completed = review && index === 0;
return (
<div
className={`rounded-lg border px-3 py-2 text-center text-xs font-medium ${
active
? "border-sky-400/30 bg-sky-400/10 text-sky-100"
: completed
? "border-emerald-400/20 bg-emerald-400/5 text-emerald-200"
: "border-white/10 text-slate-500"
}`}
key={label}
>
{index + 1}. {label}
</div>
);
})}
</div>
);
}
export function SimpleFormActions({
onBack,
onCancel,
review,
saving,
}: {
onBack: () => void;
onCancel?: () => void;
review: boolean;
saving: boolean;
}) {
return (
<div className="mt-6 flex flex-wrap items-center justify-between gap-2 border-t border-white/10 pt-4">
<div>
{review ? (
<button
className="flex items-center gap-2 rounded-md border border-white/10 px-4 py-2 text-sm text-slate-200 hover:bg-white/5"
disabled={saving}
onClick={onBack}
type="button"
>
<LuArrowLeft size={15} />
Indietro
</button>
) : onCancel ? (
<button
className="rounded-md border border-white/10 px-4 py-2 text-sm text-slate-200 hover:bg-white/5"
disabled={saving}
onClick={onCancel}
type="button"
>
Annulla
</button>
) : null}
</div>
<button
className="flex items-center gap-2 rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white hover:bg-sky-400 disabled:opacity-50"
disabled={saving}
type="submit"
>
{review ? (
<>
<LuCircleCheck size={16} />
{saving ? "Salvataggio..." : "Salva"}
</>
) : (
<>
Avanti
<LuArrowRight size={15} />
</>
)}
</button>
</div>
);
}
export function SimpleFormReview({
rows,
title,
}: {
rows: { label: string; value: string }[];
title: string;
}) {
return (
<div className="rounded-lg border border-sky-400/15 bg-sky-950/20 p-4">
<p className="text-xs font-medium uppercase tracking-wide text-sky-300">
Riepilogo
</p>
<p className="mt-2 text-lg font-semibold text-white">{title}</p>
<dl className="mt-4 grid gap-3 sm:grid-cols-2">
{rows.map((row) => (
<div key={row.label}>
<dt className="text-xs text-slate-500">{row.label}</dt>
<dd className="mt-1 text-sm text-slate-200">{row.value}</dd>
</div>
))}
</dl>
</div>
);
}

View File

@@ -0,0 +1,192 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Field, inputClass } from "@/components/shared/Field";
import { normalizeTitleName } from "@/lib/validation/paymentTitles";
import type { PaymentTitleType } from "@/types/finance";
type Title = { $id: string; name: string; paymentType: PaymentTitleType };
export function TitleCombobox({
value,
onChange,
onCreate,
titles,
type,
}: {
value: string;
onChange: (value: string) => void;
onCreate?: (name: string) => Promise<void>;
titles: Title[];
type: PaymentTitleType;
}) {
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const wrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [dropdownPosition, setDropdownPosition] = useState({
left: 0,
top: 0,
width: 0,
});
const normalizedValue = normalizeTitleName(value);
const filtered = useMemo(
() =>
titles
.filter(
(title) => title.paymentType === type || title.paymentType === "any",
)
.filter((title) =>
normalizeTitleName(title.name).includes(normalizedValue),
)
.slice(0, 8),
[titles, type, normalizedValue],
);
const exact = titles.find(
(title) =>
(title.paymentType === type || title.paymentType === "any") &&
normalizeTitleName(title.name) === normalizedValue,
);
useEffect(() => {
function close(event: MouseEvent) {
const target = event.target as Node;
if (
!wrapperRef.current?.contains(target) &&
!dropdownRef.current?.contains(target)
) {
setOpen(false);
}
}
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, []);
useEffect(() => {
if (!open) return;
function positionDropdown() {
const rect = inputRef.current?.getBoundingClientRect();
if (!rect) return;
const gap = 8;
const estimatedHeight = 320;
const fitsBelow =
rect.bottom + gap + estimatedHeight <= window.innerHeight;
setDropdownPosition({
left: Math.max(
8,
Math.min(rect.left, window.innerWidth - rect.width - 8),
),
top: fitsBelow
? rect.bottom + gap
: Math.max(8, rect.top - estimatedHeight - gap),
width: rect.width,
});
}
positionDropdown();
window.addEventListener("resize", positionDropdown);
window.addEventListener("scroll", positionDropdown, true);
return () => {
window.removeEventListener("resize", positionDropdown);
window.removeEventListener("scroll", positionDropdown, true);
};
}, [open]);
async function createOrSelect() {
const selected = filtered[activeIndex];
if (selected) {
onChange(selected.name);
setOpen(false);
return;
}
if (value.trim()) {
await onCreate?.(value.trim());
setOpen(false);
}
}
return (
<div className="relative" ref={wrapperRef}>
<Field label="Titolo">
<input
className={inputClass}
placeholder="Cerca o crea titolo"
ref={inputRef}
value={value}
onChange={(event) => {
onChange(event.target.value);
setActiveIndex(0);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={async (event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveIndex((current) =>
Math.min(current + 1, Math.max(filtered.length - 1, 0)),
);
}
if (event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex((current) => Math.max(current - 1, 0));
}
if (event.key === "Enter") {
event.preventDefault();
await createOrSelect();
}
if (event.key === "Escape") setOpen(false);
}}
/>
</Field>
{open
? createPortal(
<div
className="fixed z-[70] max-h-80 overflow-y-auto rounded-lg border border-white/10 bg-slate-950 shadow-2xl"
ref={dropdownRef}
style={dropdownPosition}
>
<div className="border-b border-white/10 px-3 py-2 text-xs text-slate-500">
Titoli salvati
</div>
{filtered.map((title, index) => (
<button
className={`block w-full px-3 py-2 text-left text-sm ${
index === activeIndex
? "bg-sky-500/15 text-sky-100"
: "text-slate-200 hover:bg-white/5"
}`}
key={title.$id}
onClick={() => {
onChange(title.name);
setOpen(false);
}}
type="button"
>
{title.name}
</button>
))}
{value.trim() && !exact ? (
<button
className="block w-full border-t border-white/10 px-3 py-2 text-left text-sm text-emerald-100 hover:bg-emerald-400/10"
onClick={async () => {
await onCreate?.(value.trim());
setOpen(false);
}}
type="button"
>
Crea nuovo titolo "{value.trim()}"
</button>
) : null}
{!filtered.length && !value.trim() ? (
<p className="px-3 py-3 text-sm text-slate-500">
Inizia a scrivere per cercare o creare.
</p>
) : null}
</div>,
document.body,
)
: null}
</div>
);
}

View File

@@ -0,0 +1,53 @@
"use client";
import { useEffect, useState } from "react";
type ToastItem = { id: number; message: string; type: "ok" | "error" };
export function notify(message: string, type: "ok" | "error" = "ok") {
window.dispatchEvent(
new CustomEvent("appspese:toast", { detail: { message, type } }),
);
}
export function Toast() {
const [items, setItems] = useState<ToastItem[]>([]);
useEffect(() => {
function onToast(event: Event) {
const detail = (event as CustomEvent).detail;
const item = {
id: Date.now(),
message: detail.message,
type: detail.type,
};
setItems((current) => [...current, item]);
setTimeout(
() =>
setItems((current) =>
current.filter((entry) => entry.id !== item.id),
),
3200,
);
}
window.addEventListener("appspese:toast", onToast);
return () => window.removeEventListener("appspese:toast", onToast);
}, []);
return (
<div className="fixed bottom-4 right-4 z-50 grid gap-2">
{items.map((item) => (
<div
className={`rounded-md border px-4 py-3 text-sm shadow-xl ${
item.type === "error"
? "border-rose-400/30 bg-rose-950 text-rose-100"
: "border-emerald-400/30 bg-emerald-950 text-emerald-100"
}`}
key={item.id}
>
{item.message}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import Image from "next/image";
import { useState } from "react";
import type { UserSession } from "@/types/finance";
export function UserAvatar({
user,
className = "size-10 text-sm",
}: {
user: UserSession;
className?: string;
}) {
const [failedUrl, setFailedUrl] = useState<string>();
return (
<span
className={`grid shrink-0 place-items-center overflow-hidden rounded-full border border-sky-300/25 bg-sky-400/10 font-semibold text-sky-100 ${className}`}
>
{user.avatarUrl && user.avatarUrl !== failedUrl ? (
<Image
alt={`Avatar di ${user.name}`}
className="h-full w-full object-cover"
height={80}
onError={() => setFailedUrl(user.avatarUrl ?? undefined)}
src={user.avatarUrl}
unoptimized
width={80}
/>
) : (
initials(user.name)
)}
</span>
);
}
function initials(name: string) {
return (
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join("")
.toUpperCase() || "?"
);
}

View File

@@ -0,0 +1,2 @@
export const financeListCardClass =
"rounded-lg border border-sky-400/15 bg-sky-950/15 p-3 transition";

View File

@@ -0,0 +1,89 @@
"use client";
import { useEffect, useRef } from "react";
import {
SidebarAccount,
SidebarLinks,
} from "@/components/shared/navigation/Sidebar";
import { SidebarOverlay } from "@/components/shared/navigation/SidebarOverlay";
import type { UserSession } from "@/types/finance";
const focusableSelector = [
"a[href]",
"button:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
export function MobileSidebar({
open,
pathname,
triggerRef,
onClose,
onLogout,
user,
}: {
open: boolean;
pathname: string;
triggerRef: React.RefObject<HTMLButtonElement | null>;
onClose: () => void;
onLogout: () => void;
user: UserSession;
}) {
const panelRef = useRef<HTMLElement>(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
if (!open) return;
panelRef.current?.querySelector<HTMLElement>(focusableSelector)?.focus();
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
onCloseRef.current();
return;
}
if (event.key !== "Tab" || !panelRef.current) return;
const elements = Array.from(
panelRef.current.querySelectorAll<HTMLElement>(focusableSelector),
);
if (!elements.length) return;
const first = elements[0];
const last = elements[elements.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
triggerRef.current?.focus();
};
}, [open, triggerRef]);
return (
<>
<SidebarOverlay open={open} onClose={onClose} />
<aside
aria-hidden={!open}
aria-label="Navigazione principale"
className={`fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-white/10 bg-black/60 p-5 shadow-2xl transition-[opacity,transform] duration-200 ease-out lg:hidden ${
open
? "scale-100 opacity-100"
: "pointer-events-none scale-[0.98] opacity-0"
}`}
id="mobile-navigation"
inert={!open}
ref={panelRef}
>
<SidebarLinks pathname={pathname} onNavigate={onClose} />
<SidebarAccount onLogout={onLogout} user={user} />
</aside>
</>
);
}

View File

@@ -0,0 +1,122 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import {
LuChartNoAxesCombined,
LuCreditCard,
LuLayoutDashboard,
LuLogOut,
LuReceiptText,
LuRefreshCw,
LuTrendingUp,
LuUserRound,
} from "react-icons/lu";
import { UserAvatar } from "@/components/shared/UserAvatar";
import type { UserSession } from "@/types/finance";
export const navigationLinks = [
{ icon: LuLayoutDashboard, title: "Dashboard", href: "/dashboard" },
{ icon: LuTrendingUp, title: "Entrate", href: "/income" },
{ icon: LuReceiptText, title: "Bollette", href: "/bills" },
{ icon: LuRefreshCw, title: "Abbonamenti", href: "/subscriptions" },
{
icon: LuCreditCard,
title: "Pagamenti Singoli",
href: "/one-time-payments",
},
{ icon: LuChartNoAxesCombined, title: "Report", href: "/reports" },
{ icon: LuUserRound, title: "Profilo", href: "/profile" },
];
export function SidebarBrand() {
return (
<div className="flex flex-row items-center gap-2">
<Image src="/logo.png" alt="MyMoney" width={32} height={32} />
<div className="text-lg font-semibold text-white">MyMoney</div>
</div>
);
}
export function SidebarLinks({
pathname,
onNavigate,
className = "",
}: {
pathname: string;
onNavigate?: () => void;
className?: string;
}) {
return (
<nav className={`grid gap-1 ${className}`}>
{navigationLinks.map((link) => {
const active = pathname === link.href;
const Icon = link.icon;
return (
<Link
className={`flex flex-row items-center rounded-md px-3 py-2 text-sm transition ${
active
? "bg-sky-500/15 text-sky-100"
: "text-slate-400 hover:bg-white/5 hover:text-white"
}`}
href={link.href}
key={link.href}
onClick={onNavigate}
>
<Icon size={20} />
<span className="ml-2">{link.title}</span>
</Link>
);
})}
</nav>
);
}
export function SidebarAccount({
onLogout,
user,
}: {
onLogout: () => void;
user: UserSession;
}) {
return (
<div className="mt-auto border-t border-white/10 pt-4">
<Link
className="flex min-w-0 items-center gap-3 rounded-lg p-2 transition hover:bg-white/5"
href="/profile"
>
<UserAvatar user={user} />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-white">{user.name}</p>
<p className="truncate text-[10px] text-slate-400">{user.email}</p>
</div>
</Link>
<button
className="mt-2 flex w-[80px] items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-400 transition hover:bg-red-400/30 hover:text-rose-100"
onClick={onLogout}
type="button"
>
<LuLogOut size={17} />
Esci
</button>
</div>
);
}
export function Sidebar({
onLogout,
pathname,
user,
}: {
onLogout: () => void;
pathname: string;
user: UserSession;
}) {
return (
<aside className="fixed inset-y-0 left-0 hidden w-64 flex-col border-r border-white/10 bg-black/50 p-5 lg:flex">
<SidebarBrand />
<SidebarLinks className="mt-8" pathname={pathname} />
<SidebarAccount onLogout={onLogout} user={user} />
</aside>
);
}

View File

@@ -0,0 +1,19 @@
export function SidebarOverlay({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
return (
<button
aria-label="Chiudi navigazione"
className={`fixed inset-0 z-40 bg-slate-950/25 backdrop-blur-sm transition-opacity duration-200 ease-out lg:hidden ${
open ? "opacity-100" : "pointer-events-none opacity-0"
}`}
onClick={onClose}
tabIndex={open ? 0 : -1}
type="button"
/>
);
}

View File

@@ -0,0 +1,24 @@
import { forwardRef } from "react";
import { LuMenu } from "react-icons/lu";
export const SidebarTrigger = forwardRef<
HTMLButtonElement,
{
open: boolean;
onClick: () => void;
}
>(function SidebarTrigger({ open, onClick }, ref) {
return (
<button
aria-controls="mobile-navigation"
aria-expanded={open}
aria-label="Apri navigazione"
className="rounded-md border border-white/10 p-2 text-slate-200 hover:bg-white/5 lg:hidden"
onClick={onClick}
ref={ref}
type="button"
>
<LuMenu size={20} />
</button>
);
});

View File

@@ -0,0 +1,231 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { notify } from "@/components/shared/Toast";
import type {
Bill,
Income,
OneTimePayment,
PaymentLedger,
PaymentTitle,
Subscription,
SubscriptionPayment,
UserSettings,
} from "@/types/finance";
type CacheEntry<T> = {
data?: T;
error?: string;
promise?: Promise<T>;
refresh?: () => void;
updatedAt: number;
subscribers: Set<() => void>;
};
const staleTime = 30_000;
const cache = new Map<string, CacheEntry<unknown>>();
function entryFor<T>(key: string): CacheEntry<T> {
let entry = cache.get(key) as CacheEntry<T> | undefined;
if (!entry) {
entry = { updatedAt: 0, subscribers: new Set() };
cache.set(key, entry as CacheEntry<unknown>);
}
return entry;
}
function notifySubscribers(key: string) {
const entry = cache.get(key);
if (!entry) return;
for (const subscriber of entry.subscribers) subscriber();
}
async function getJson<T>(url: string): Promise<T> {
const response = await fetch(url, { cache: "no-store" });
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? `Errore ${url}`);
return payload;
}
export function useApiResource<T>(key: string, url: string) {
const [, forceRender] = useState(0);
const entry = entryFor<T>(key);
const revalidate = useCallback(
async (force = false) => {
const current = entryFor<T>(key);
const fresh = Date.now() - current.updatedAt < staleTime;
if (!force && current.data !== undefined && fresh) return current.data;
if (current.promise) return current.promise;
current.promise = getJson<T>(url)
.then((data) => {
current.data = data;
current.error = undefined;
current.updatedAt = Date.now();
return data;
})
.catch((error) => {
current.error =
error instanceof Error ? error.message : "Errore di caricamento";
notify(current.error, "error");
throw error;
})
.finally(() => {
current.promise = undefined;
notifySubscribers(key);
});
notifySubscribers(key);
return current.promise;
},
[key, url],
);
useEffect(() => {
const current = entryFor<T>(key);
const subscriber = () => forceRender((value) => value + 1);
const refresh = () => {
revalidate(true).catch(() => undefined);
};
current.subscribers.add(subscriber);
current.refresh = refresh;
revalidate(false).catch(() => undefined);
return () => {
current.subscribers.delete(subscriber);
if (current.refresh === refresh) current.refresh = undefined;
};
}, [key, revalidate]);
const mutate = useCallback(
(
updater?: T | ((current: T | undefined) => T | undefined),
revalidateAfter = true,
) => {
const current = entryFor<T>(key);
if (updater !== undefined) {
current.data =
typeof updater === "function"
? (updater as (current: T | undefined) => T | undefined)(
current.data,
)
: updater;
current.updatedAt = Date.now();
notifySubscribers(key);
}
return revalidateAfter
? revalidate(true).catch(() => undefined)
: Promise.resolve(current.data);
},
[key, revalidate],
);
return {
data: entry.data,
loading: entry.data === undefined && Boolean(entry.promise),
error: entry.error ?? "",
refresh: () => revalidate(true).catch(() => undefined),
mutate,
};
}
export function invalidateResource(key: string) {
const current = cache.get(key);
if (!current) return;
current.updatedAt = 0;
notifySubscribers(key);
current.refresh?.();
}
export function evictCachedLedgerSource(
sourceType: PaymentLedger["sourceType"],
sourceId: string,
) {
const current = cache.get("payments-ledger") as
| CacheEntry<PaymentLedger[]>
| undefined;
if (!current?.data) return;
current.data = current.data.filter(
(item) => item.sourceType !== sourceType || item.sourceId !== sourceId,
);
current.updatedAt = 0;
notifySubscribers("payments-ledger");
current.refresh?.();
}
export function invalidateFinancialResources() {
for (const key of [
"subscriptions",
"subscription-payments",
"bills",
"one-time-payments",
"payments-ledger",
"payment-titles",
"income",
]) {
const current = cache.get(key);
if (!current) continue;
current.data = undefined;
current.updatedAt = 0;
notifySubscribers(key);
}
}
export function useSubscriptionsResource() {
return useApiResource<Subscription[]>("subscriptions", "/api/subscriptions");
}
export function useSubscriptionPaymentsResource() {
return useApiResource<SubscriptionPayment[]>(
"subscription-payments",
"/api/subscription-payments",
);
}
export function useBillsResource() {
return useApiResource<Bill[]>("bills", "/api/bills");
}
export function useOneTimePaymentsResource() {
return useApiResource<OneTimePayment[]>(
"one-time-payments",
"/api/one-time-payments",
);
}
export function useLedgerResource() {
return useApiResource<PaymentLedger[]>(
"payments-ledger",
"/api/payments-ledger",
);
}
export function usePaymentTitlesResource() {
return useApiResource<PaymentTitle[]>(
"payment-titles",
"/api/payment-titles",
);
}
export function useIncomeResource() {
return useApiResource<Income[]>("income", "/api/income");
}
export function useUserSettingsResource() {
return useApiResource<UserSettings>("user-settings", "/api/profile/settings");
}
export async function apiSave<T = unknown>(
url: string,
method: "POST" | "PATCH" | "DELETE",
body?: object,
) {
const response = await fetch(url, {
method,
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Operazione non riuscita");
return payload as T;
}

View File

@@ -0,0 +1,508 @@
"use client";
import { useEffect, useState } from "react";
import { LuArrowLeft, LuArrowRight, LuCircleCheck } from "react-icons/lu";
import { DateField } from "@/components/shared/DateField";
import { Field, inputClass } from "@/components/shared/Field";
import { Select } from "@/components/shared/Select";
import { TitleCombobox } from "@/components/shared/TitleCombobox";
import { todayIso } from "@/lib/date/formatDate";
import { formatMoneyInput, parseMoneyInput } from "@/lib/finance/money";
import type {
BillingCycle,
PaymentTitle,
RenewalIntervalUnit,
Subscription,
SubscriptionStatus,
} from "@/types/finance";
type DurationMode = "indefinite" | "date" | "count" | "both";
const steps = ["Servizio", "Rinnovo", "Conferma"];
export function SubscriptionForm({
titles,
editing,
onSubmit,
onCreateTitle,
onCancel,
saving = false,
}: {
titles: PaymentTitle[];
editing?: Subscription;
onSubmit: (data: object) => Promise<void>;
onCreateTitle?: (name: string) => Promise<void>;
onCancel?: () => void;
saving?: boolean;
}) {
const [step, setStep] = useState(0);
const [title, setTitle] = useState(editing?.title ?? "");
const [amount, setAmount] = useState(formatMoneyInput(editing?.amount));
const [activationDate, setActivationDate] = useState(
editing?.activationDate ?? todayIso(),
);
const [firstPaymentDate, setFirstPaymentDate] = useState(
editing?.firstPaymentDate ?? todayIso(),
);
const [billingCycle, setBillingCycle] = useState(
editing?.billingCycle ?? "monthly",
);
const [durationMode, setDurationMode] = useState(durationModeFor(editing));
const [endDate, setEndDate] = useState(editing?.endDate ?? "");
const [occurrenceCount, setOccurrenceCount] = useState(
String(editing?.occurrenceCount ?? ""),
);
const [customIntervalValue, setCustomIntervalValue] = useState(
String(editing?.customIntervalValue ?? ""),
);
const [customIntervalUnit, setCustomIntervalUnit] = useState(
editing?.customIntervalUnit ?? "months",
);
const [subscriptionStatus, setSubscriptionStatus] = useState(
editing?.subscriptionStatus ?? "active",
);
const [error, setError] = useState("");
useEffect(() => {
setStep(0);
setTitle(editing?.title ?? "");
setAmount(formatMoneyInput(editing?.amount));
setActivationDate(editing?.activationDate ?? todayIso());
setFirstPaymentDate(editing?.firstPaymentDate ?? todayIso());
setBillingCycle(editing?.billingCycle ?? "monthly");
setDurationMode(durationModeFor(editing));
setEndDate(editing?.endDate ?? "");
setOccurrenceCount(String(editing?.occurrenceCount ?? ""));
setCustomIntervalValue(String(editing?.customIntervalValue ?? ""));
setCustomIntervalUnit(editing?.customIntervalUnit ?? "months");
setSubscriptionStatus(editing?.subscriptionStatus ?? "active");
setError("");
}, [editing]);
async function submit() {
setError("");
await onSubmit({
title,
amount: parseMoneyInput(amount),
activationDate,
firstPaymentDate,
billingCycle,
endDate:
durationMode === "date" || durationMode === "both" ? endDate : "",
occurrenceCount:
durationMode === "count" || durationMode === "both"
? occurrenceCount
: "",
customIntervalValue: billingCycle === "custom" ? customIntervalValue : "",
customIntervalUnit: billingCycle === "custom" ? customIntervalUnit : "",
subscriptionStatus,
});
}
function advance() {
try {
setError("");
if (step === 0) validateServiceStep();
if (step === 1) validateRenewalStep();
setStep((current) => Math.min(current + 1, steps.length - 1));
} catch (err) {
setError(
err instanceof Error ? err.message : "Controlla i campi inseriti",
);
}
}
function validateServiceStep() {
if (!title.trim()) throw new Error("Inserisci il titolo del servizio");
parseMoneyInput(amount);
if (!activationDate || !firstPaymentDate) {
throw new Error("Inserisci le date del servizio");
}
if (firstPaymentDate < activationDate) {
throw new Error("Il primo pagamento non può precedere l'attivazione");
}
}
function validateRenewalStep() {
if (billingCycle === "custom" && !customIntervalValue) {
throw new Error("Inserisci l'intervallo di rinnovo");
}
if ((durationMode === "date" || durationMode === "both") && !endDate) {
throw new Error("Inserisci la data di fine");
}
if (
(durationMode === "count" || durationMode === "both") &&
!occurrenceCount
) {
throw new Error("Inserisci il numero di rate");
}
}
return (
<form
onSubmit={async (event) => {
event.preventDefault();
if (step < steps.length - 1) {
advance();
return;
}
try {
await submit();
} catch (err) {
setError(
err instanceof Error ? err.message : "Salvataggio non riuscito",
);
}
}}
>
<StepIndicator current={step} />
<div className="mt-5">
{step === 0 ? (
<ServiceStep
activationDate={activationDate}
amount={amount}
firstPaymentDate={firstPaymentDate}
onActivationDateChange={setActivationDate}
onAmountChange={setAmount}
onCreateTitle={onCreateTitle}
onFirstPaymentDateChange={setFirstPaymentDate}
onTitleChange={setTitle}
title={title}
titles={titles}
/>
) : null}
{step === 1 ? (
<RenewalStep
billingCycle={billingCycle}
customIntervalUnit={customIntervalUnit}
customIntervalValue={customIntervalValue}
durationMode={durationMode}
endDate={endDate}
occurrenceCount={occurrenceCount}
onBillingCycleChange={setBillingCycle}
onCustomIntervalUnitChange={setCustomIntervalUnit}
onCustomIntervalValueChange={setCustomIntervalValue}
onDurationModeChange={setDurationMode}
onEndDateChange={setEndDate}
onOccurrenceCountChange={setOccurrenceCount}
/>
) : null}
{step === 2 ? (
<ReviewStep
amount={amount}
billingCycle={billingCycle}
durationMode={durationMode}
editing={editing}
onStatusChange={setSubscriptionStatus}
status={subscriptionStatus}
title={title}
/>
) : null}
</div>
{error ? <p className="mt-4 text-sm text-rose-300">{error}</p> : null}
<div className="mt-6 flex flex-wrap items-center justify-between gap-2 border-t border-white/10 pt-4">
<div>
{step > 0 ? (
<button
className="flex items-center gap-2 rounded-md border border-white/10 px-4 py-2 text-sm text-slate-200 hover:bg-white/5"
onClick={() => setStep((current) => current - 1)}
type="button"
>
<LuArrowLeft size={15} />
Indietro
</button>
) : onCancel ? (
<button
className="rounded-md border border-white/10 px-4 py-2 text-sm text-slate-200 hover:bg-white/5"
disabled={saving}
onClick={onCancel}
type="button"
>
Annulla
</button>
) : null}
</div>
<button
className="flex items-center gap-2 rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white hover:bg-sky-400 disabled:opacity-50"
disabled={saving}
type="submit"
>
{step < steps.length - 1 ? (
<>
Avanti
<LuArrowRight size={15} />
</>
) : (
<>
<LuCircleCheck size={16} />
{saving ? "Salvataggio..." : "Salva abbonamento"}
</>
)}
</button>
</div>
</form>
);
}
function StepIndicator({ current }: { current: number }) {
return (
<div className="grid grid-cols-3 gap-2">
{steps.map((label, index) => (
<div
className={`rounded-lg border px-3 py-2 text-center text-xs font-medium ${
index === current
? "border-sky-400/30 bg-sky-400/10 text-sky-100"
: index < current
? "border-emerald-400/20 bg-emerald-400/5 text-emerald-200"
: "border-white/10 text-slate-500"
}`}
key={label}
>
{index + 1}. {label}
</div>
))}
</div>
);
}
function ServiceStep({
titles,
title,
amount,
activationDate,
firstPaymentDate,
onTitleChange,
onAmountChange,
onActivationDateChange,
onFirstPaymentDateChange,
onCreateTitle,
}: {
titles: PaymentTitle[];
title: string;
amount: string;
activationDate: string;
firstPaymentDate: string;
onTitleChange: (value: string) => void;
onAmountChange: (value: string) => void;
onActivationDateChange: (value: string) => void;
onFirstPaymentDateChange: (value: string) => void;
onCreateTitle?: (name: string) => Promise<void>;
}) {
return (
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<TitleCombobox
titles={titles}
type="subscription"
value={title}
onChange={onTitleChange}
onCreate={onCreateTitle}
/>
</div>
<Field label="Importo di ogni rinnovo">
<input
className={inputClass}
value={amount}
onChange={(event) => onAmountChange(event.target.value)}
/>
</Field>
<div />
<DateField
label="Attivazione del servizio"
name="activationDate"
value={activationDate}
onChange={onActivationDateChange}
/>
<DateField
label="Primo pagamento"
name="firstPaymentDate"
value={firstPaymentDate}
onChange={onFirstPaymentDateChange}
/>
</div>
);
}
function RenewalStep({
billingCycle,
durationMode,
endDate,
occurrenceCount,
customIntervalValue,
customIntervalUnit,
onBillingCycleChange,
onDurationModeChange,
onEndDateChange,
onOccurrenceCountChange,
onCustomIntervalValueChange,
onCustomIntervalUnitChange,
}: {
billingCycle: BillingCycle;
durationMode: DurationMode;
endDate: string;
occurrenceCount: string;
customIntervalValue: string;
customIntervalUnit: RenewalIntervalUnit;
onBillingCycleChange: (value: BillingCycle) => void;
onDurationModeChange: (value: DurationMode) => void;
onEndDateChange: (value: string) => void;
onOccurrenceCountChange: (value: string) => void;
onCustomIntervalValueChange: (value: string) => void;
onCustomIntervalUnitChange: (value: RenewalIntervalUnit) => void;
}) {
return (
<div className="grid gap-4 sm:grid-cols-2">
<Select
label="Frequenza"
value={billingCycle}
onChange={(value) => onBillingCycleChange(value as BillingCycle)}
options={[
{ value: "monthly", label: "Mensile" },
{ value: "weekly", label: "Settimanale" },
{ value: "yearly", label: "Annuale" },
{ value: "custom", label: "Personalizzata" },
]}
/>
{billingCycle === "custom" ? (
<div className="grid grid-cols-[1fr_1.4fr] gap-2">
<Field label="Ogni">
<input
className={inputClass}
inputMode="numeric"
min="1"
type="number"
value={customIntervalValue}
onChange={(event) =>
onCustomIntervalValueChange(event.target.value)
}
/>
</Field>
<Select
label="Unità"
value={customIntervalUnit}
onChange={(value) =>
onCustomIntervalUnitChange(value as RenewalIntervalUnit)
}
options={[
{ value: "days", label: "Giorni" },
{ value: "weeks", label: "Settimane" },
{ value: "months", label: "Mesi" },
{ value: "years", label: "Anni" },
]}
/>
</div>
) : (
<div />
)}
<Select
label="Durata"
value={durationMode}
onChange={(value) => onDurationModeChange(value as DurationMode)}
options={[
{ value: "indefinite", label: "Senza scadenza" },
{ value: "date", label: "Fino a una data" },
{ value: "count", label: "Numero di rate" },
{ value: "both", label: "Data e numero di rate" },
]}
/>
{durationMode === "date" || durationMode === "both" ? (
<DateField
label="Data fine"
name="endDate"
value={endDate}
onChange={onEndDateChange}
/>
) : null}
{durationMode === "count" || durationMode === "both" ? (
<Field label="Numero di rate">
<input
className={inputClass}
inputMode="numeric"
min="1"
type="number"
value={occurrenceCount}
onChange={(event) => onOccurrenceCountChange(event.target.value)}
/>
</Field>
) : null}
<p className="text-xs leading-relaxed text-slate-500 sm:col-span-2">
Senza scadenza il rinnovo continua finché non sospendi o termini il
contratto.
</p>
</div>
);
}
function ReviewStep({
title,
amount,
billingCycle,
durationMode,
editing,
status,
onStatusChange,
}: {
title: string;
amount: string;
billingCycle: BillingCycle;
durationMode: DurationMode;
editing?: Subscription;
status: SubscriptionStatus;
onStatusChange: (value: SubscriptionStatus) => void;
}) {
return (
<div className="grid gap-4">
<div className="rounded-lg border border-sky-400/15 bg-sky-950/20 p-4">
<p className="text-xs font-medium uppercase tracking-wide text-sky-300">
Riepilogo
</p>
<p className="mt-2 text-lg font-semibold text-white">{title}</p>
<p className="mt-1 text-sm text-slate-300">
{amount} · {cycleLabels[billingCycle]} ·{" "}
{durationLabels[durationMode]}
</p>
</div>
{editing ? (
<Select
label="Stato del contratto"
value={status}
onChange={(value) => onStatusChange(value as SubscriptionStatus)}
options={
editing.subscriptionStatus === "terminated"
? [{ value: "terminated", label: "Terminato" }]
: [
{ value: "active", label: "Attivo" },
{ value: "suspended", label: "Sospeso" },
{ value: "terminated", label: "Terminato" },
]
}
/>
) : (
<p className="text-sm text-slate-400">
Il nuovo abbonamento sarà creato come attivo.
</p>
)}
</div>
);
}
function durationModeFor(subscription?: Subscription): DurationMode {
if (subscription?.endDate && subscription.occurrenceCount) return "both";
if (subscription?.endDate) return "date";
if (subscription?.occurrenceCount) return "count";
return "indefinite";
}
const cycleLabels: Record<BillingCycle, string> = {
monthly: "rinnovo mensile",
weekly: "rinnovo settimanale",
yearly: "rinnovo annuale",
custom: "rinnovo personalizzato",
};
const durationLabels: Record<DurationMode, string> = {
indefinite: "senza scadenza",
date: "con data di fine",
count: "con numero di rate",
both: "con limite di data e rate",
};

View File

@@ -0,0 +1,142 @@
"use client";
import { useState } from "react";
import { LuCalendarClock, LuPencil, LuRepeat2, LuTrash2 } from "react-icons/lu";
import { EmptyState } from "@/components/shared/EmptyState";
import { financeListCardClass } from "@/components/shared/financeListCard";
import { ListCardAction } from "@/components/shared/ListCardAction";
import { SubscriptionStatusBadge } from "@/components/subscriptions/SubscriptionStatusBadge";
import { formatItalianDate } from "@/lib/date/formatDate";
import { euroFormatter } from "@/lib/finance/money";
import { getEffectiveSubscriptionStatus } from "@/lib/finance/subscriptionOccurrences";
import type { Subscription } from "@/types/finance";
export function SubscriptionList({
subscriptions,
onEdit,
onDelete,
}: {
subscriptions: Subscription[];
onEdit: (item: Subscription) => void;
onDelete: (item: Subscription) => Promise<void>;
}) {
const [busy, setBusy] = useState("");
if (!subscriptions.length) {
return (
<EmptyState
title="Nessun abbonamento"
text="Aggiungi il primo rinnovo ricorrente."
/>
);
}
const orderedSubscriptions = [...subscriptions].sort((left, right) => {
const order = { active: 0, suspended: 1, terminated: 2 };
return (
order[getEffectiveSubscriptionStatus(left)] -
order[getEffectiveSubscriptionStatus(right)]
);
});
return (
<div className="grid gap-2">
{orderedSubscriptions.map((item) => {
const status = getEffectiveSubscriptionStatus(item);
return (
<div
className={`${
status === "active"
? financeListCardClass
: `${financeListCardClass} opacity-75`
}`}
key={item.$id}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate font-medium text-white">
{item.title}
</p>
<SubscriptionStatusBadge status={status} />
</div>
<p className="mt-1 text-lg font-semibold text-slate-100">
{euroFormatter.format(item.amount)}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<ListCardAction
icon={LuPencil}
label={`Modifica ${item.title}`}
onClick={() => onEdit(item)}
/>
<ListCardAction
confirmDescription={`Sei sicuro di voler eliminare l'abbonamento "${item.title}" e la relativa cronologia?`}
danger
disabled={busy === item.$id}
icon={LuTrash2}
label={`Elimina ${item.title}`}
onClick={async () => {
setBusy(item.$id);
try {
await onDelete(item);
} finally {
setBusy("");
}
}}
/>
</div>
</div>
<div className="mt-3 grid gap-1 text-xs text-slate-400 sm:grid-cols-2">
<p className="flex items-center gap-1.5">
<LuRepeat2 className="text-sky-300" size={14} />
{formatBillingCycle(item)}
{item.occurrenceCount ? `, ${item.occurrenceCount} rate` : ""}
</p>
<p className="flex items-center gap-1.5">
<LuCalendarClock className="text-sky-300" size={14} />
Primo pagamento {formatItalianDate(item.firstPaymentDate)}
</p>
{item.endDate ? (
<p className="text-slate-500 sm:col-span-2">
Attivo dal {formatItalianDate(item.activationDate)} al{" "}
{formatItalianDate(item.endDate)}
</p>
) : (
<p className="text-slate-500 sm:col-span-2">
Attivo dal {formatItalianDate(item.activationDate)}, senza
scadenza
</p>
)}
</div>
</div>
);
})}
</div>
);
}
const billingCycleLabels = {
monthly: "Rinnovo mensile",
weekly: "Rinnovo settimanale",
yearly: "Rinnovo annuale",
custom: "Rinnovo personalizzato",
};
function formatBillingCycle(subscription: Subscription) {
if (
subscription.billingCycle !== "custom" ||
!subscription.customIntervalValue ||
!subscription.customIntervalUnit
) {
return billingCycleLabels[subscription.billingCycle];
}
const labels = {
days: ["giorno", "giorni"],
weeks: ["settimana", "settimane"],
months: ["mese", "mesi"],
years: ["anno", "anni"],
};
const [singular, plural] = labels[subscription.customIntervalUnit];
return `Ogni ${subscription.customIntervalValue} ${
subscription.customIntervalValue === 1 ? singular : plural
}`;
}

View File

@@ -0,0 +1,69 @@
"use client";
import { LuCalendarDays, LuCircleCheckBig, LuRotateCcw } from "react-icons/lu";
import { EmptyState } from "@/components/shared/EmptyState";
import { financeListCardClass } from "@/components/shared/financeListCard";
import { PaymentBadge } from "@/components/shared/PaymentBadge";
import { formatItalianDate } from "@/lib/date/formatDate";
import { paymentStatusLabel } from "@/lib/finance/labels";
import { euroFormatter } from "@/lib/finance/money";
import type { SubscriptionOccurrence } from "@/types/finance";
export function SubscriptionOccurrenceList({
occurrences,
onTogglePaid,
}: {
occurrences: SubscriptionOccurrence[];
onTogglePaid: (item: SubscriptionOccurrence) => Promise<void>;
}) {
if (!occurrences.length) {
return <EmptyState title="Nessuna rata nel mese" />;
}
return (
<div className="grid gap-2">
{occurrences.map((item) => (
<div
className={financeListCardClass}
key={`${item.subscription.$id}-${item.renewalDate}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate font-medium text-white">
{item.subscription.title}
</p>
<PaymentBadge sourceType="subscription" status={item.status}>
{paymentStatusLabel(item.status)}
</PaymentBadge>
</div>
<p className="mt-1 text-lg font-semibold text-slate-100">
{euroFormatter.format(item.subscription.amount)}
</p>
</div>
<button
aria-label={
item.status === "paid"
? `Segna ${item.subscription.title} come non pagato`
: `Segna ${item.subscription.title} come pagato`
}
className="grid size-9 place-items-center rounded-md border border-white/10 text-slate-200 transition hover:bg-white/5"
onClick={() => onTogglePaid(item)}
type="button"
>
{item.status === "paid" ? (
<LuRotateCcw size={15} />
) : (
<LuCircleCheckBig size={16} />
)}
</button>
</div>
<p className="mt-3 flex items-center gap-1.5 text-xs text-slate-400">
<LuCalendarDays className="text-sky-300" size={14} />
Rinnovo previsto il {formatItalianDate(item.renewalDate)}
</p>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { PaymentBadge } from "@/components/shared/PaymentBadge";
import type { SubscriptionStatus } from "@/types/finance";
export function SubscriptionStatusBadge({
status,
}: {
status: SubscriptionStatus;
}) {
return (
<PaymentBadge
sourceType="subscription"
status={status === "active" ? "upcoming" : "neutral"}
>
{
{
active: "Attivo",
suspended: "Sospeso",
terminated: "Terminato",
}[status]
}
</PaymentBadge>
);
}

View File

@@ -0,0 +1,262 @@
"use client";
import { useMemo, useState } from "react";
import { LuFileText, LuReceiptText } from "react-icons/lu";
import { EntityFormModal } from "@/components/shared/EntityFormModal";
import { MonthlyPageToolbar } from "@/components/shared/MonthlyPageToolbar";
import { PageShell } from "@/components/shared/PageShell";
import { SectionAddButton } from "@/components/shared/SectionAddButton";
import { notify } from "@/components/shared/Toast";
import {
apiSave,
evictCachedLedgerSource,
invalidateResource,
usePaymentTitlesResource,
useSubscriptionPaymentsResource,
useSubscriptionsResource,
} from "@/components/shared/useFinanceData";
import { SubscriptionForm } from "@/components/subscriptions/SubscriptionForm";
import { SubscriptionList } from "@/components/subscriptions/SubscriptionList";
import { SubscriptionOccurrenceList } from "@/components/subscriptions/SubscriptionOccurrenceList";
import { monthKey, todayIso, yearMonthKeys } from "@/lib/date/formatDate";
import { generateSubscriptionOccurrences } from "@/lib/finance/subscriptionOccurrences";
import type {
PaymentTitle,
Subscription,
SubscriptionOccurrence,
SubscriptionPayment,
} from "@/types/finance";
export function SubscriptionsPageClient() {
const subscriptions = useSubscriptionsResource();
const subscriptionPayments = useSubscriptionPaymentsResource();
const titles = usePaymentTitlesResource();
const [editing, setEditing] = useState<Subscription | undefined>();
const [modalOpen, setModalOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [month, setMonth] = useState(monthKey);
const [workspace, setWorkspace] = useState<"payments" | "contracts">(
"payments",
);
const occurrences = useMemo(
() =>
generateSubscriptionOccurrences(
subscriptions.data ?? [],
subscriptionPayments.data ?? [],
month,
),
[subscriptions.data, subscriptionPayments.data, month],
);
const populatedMonths = useMemo(
() =>
new Set(
yearMonthKeys(Number(month.slice(0, 4))).filter(
(candidate) =>
generateSubscriptionOccurrences(
subscriptions.data ?? [],
subscriptionPayments.data ?? [],
candidate,
).length > 0,
),
),
[month, subscriptionPayments.data, subscriptions.data],
);
async function createTitle(name: string) {
const saved = await apiSave<PaymentTitle>("/api/payment-titles", "POST", {
name,
paymentType: "subscription",
});
titles.mutate((current) => {
const list = current ?? [];
return list.some((item) => item.$id === saved.$id)
? list
: [...list, saved];
});
}
async function save(payload: object) {
setSaving(true);
try {
if ("title" in payload) await createTitle(String(payload.title));
const saved = await apiSave<Subscription>(
editing ? `/api/subscriptions/${editing.$id}` : "/api/subscriptions",
editing ? "PATCH" : "POST",
payload,
);
subscriptions.mutate((current) => {
const list = current ?? [];
return editing
? list.map((item) => (item.$id === saved.$id ? saved : item))
: [saved, ...list];
});
setModalOpen(false);
setEditing(undefined);
notify("Abbonamento salvato");
} finally {
setSaving(false);
}
}
async function toggle(item: SubscriptionOccurrence) {
const saved = await apiSave<SubscriptionPayment>(
"/api/subscription-payments",
"POST",
{
subscriptionId: item.subscription.$id,
renewalDate: item.renewalDate,
status: item.status === "paid" ? "unpaid" : "paid",
paymentDate: todayIso(),
},
);
subscriptionPayments.mutate((current) => {
const list = current ?? [];
return [saved, ...list.filter((payment) => payment.$id !== saved.$id)];
});
if (saved.status !== "paid") {
evictCachedLedgerSource("subscription", saved.$id);
}
invalidateResource("payments-ledger");
notify("Stato rinnovo aggiornato");
}
async function removeSubscription(item: Subscription) {
await apiSave(`/api/subscriptions/${item.$id}`, "DELETE");
subscriptions.mutate((current) =>
(current ?? []).filter((entry) => entry.$id !== item.$id),
);
subscriptionPayments.mutate((current) => {
const removed = (current ?? []).filter(
(payment) => payment.subscriptionId === item.$id,
);
for (const payment of removed) {
evictCachedLedgerSource("subscription", payment.$id);
}
return (current ?? []).filter(
(payment) => payment.subscriptionId !== item.$id,
);
});
invalidateResource("payments-ledger");
notify("Abbonamento eliminato");
}
return (
<PageShell
title="Abbonamenti"
description="Gestisci abbonamenti e rinnovi."
actions={
<SectionAddButton
label="Aggiungi abbonamento"
onClick={() => {
setEditing(undefined);
setModalOpen(true);
}}
/>
}
>
<section className="rounded-xl border border-white/10 bg-white/[0.025] p-4">
<div className="mb-4 grid grid-cols-2 rounded-lg bg-slate-950/60 p-1">
<WorkspaceButton
active={workspace === "payments"}
icon={<LuReceiptText size={16} />}
label="Rate del mese"
onClick={() => setWorkspace("payments")}
/>
<WorkspaceButton
active={workspace === "contracts"}
icon={<LuFileText size={16} />}
label="Gestisci"
onClick={() => setWorkspace("contracts")}
/>
</div>
{workspace === "payments" ? (
<>
<div>
<div className="flex items-center justify-end">
<MonthlyPageToolbar
month={month}
onChange={setMonth}
populatedMonths={populatedMonths}
/>
</div>
</div>
<div className="mt-4">
<SubscriptionOccurrenceList
occurrences={occurrences}
onTogglePaid={toggle}
/>
<p className="mt-1 text-right text-sm text-slate-400 pt-3 px-2">
{occurrences.length} rinnovi previsti nel mese selezionato.
</p>
</div>
</>
) : (
<>
<div>
<h2 className="font-medium text-white">I tuoi abbonamenti</h2>
</div>
<div className="mt-4">
<SubscriptionList
subscriptions={subscriptions.data ?? []}
onEdit={(item) => {
setEditing(item);
setModalOpen(true);
}}
onDelete={removeSubscription}
/>
</div>
</>
)}
</section>
{modalOpen ? (
<EntityFormModal
onClose={() => {
setModalOpen(false);
setEditing(undefined);
}}
open
saving={saving}
title={editing ? "Modifica Abbonamento" : "Nuovo Abbonamento"}
>
<SubscriptionForm
editing={editing}
titles={titles.data ?? []}
onCancel={() => {
setModalOpen(false);
setEditing(undefined);
}}
onCreateTitle={createTitle}
onSubmit={save}
saving={saving}
/>
</EntityFormModal>
) : null}
</PageShell>
);
}
function WorkspaceButton({
active,
icon,
label,
onClick,
}: {
active: boolean;
icon: React.ReactNode;
label: string;
onClick: () => void;
}) {
return (
<button
className={`flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition ${
active
? "bg-sky-400/15 text-sky-100"
: "text-slate-400 hover:bg-white/5 hover:text-slate-200"
}`}
onClick={onClick}
type="button"
>
{icon}
{label}
</button>
);
}

Some files were not shown because too many files have changed in this diff Show More