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

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.next
.git
.env*
*.tsbuildinfo

2
.gitignore vendored
View File

@@ -23,6 +23,7 @@
# misc # misc
.DS_Store .DS_Store
*.pem *.pem
/.data/
# debug # debug
npm-debug.log* npm-debug.log*
@@ -32,6 +33,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
!.env.example
# vercel # vercel
.vercel .vercel

23
Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM node:22-alpine AS base
RUN corepack enable
WORKDIR /app
FROM base AS dependencies
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
FROM base AS builder
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
RUN pnpm build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]

109
README.md
View File

@@ -1,36 +1,103 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). # App Spese
## Getting Started Full-stack personal finance app built with Next.js App Router, TypeScript,
Tailwind CSS, and PostgreSQL.
First, run the development server: The browser only talks to Next.js Route Handlers. PostgreSQL credentials and
session tokens stay on the server.
## Docker Compose
Copy the example environment file and choose a strong database password:
```bash ```bash
npm run dev cp .env.example .env
# or docker compose up --build -d
yarn dev
# or
pnpm dev
# or
bun dev
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. The first startup initializes the PostgreSQL schema from `database/init.sql`.
Open `http://localhost:3000`: the app redirects to `/setup` and asks you to
create the first account. The setup page becomes unavailable as soon as that
account exists.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. ## Local Development
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. Start PostgreSQL with Docker:
## Learn More ```bash
docker compose up -d postgres
```
To learn more about Next.js, take a look at the following resources: The compose file publishes PostgreSQL on `localhost:5432` by default so the
local Next.js development server can reach it.
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. Create `.env.local`:
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! ```env
DATABASE_URL=postgresql://appspese:change-me@localhost:5432/appspese
```
## Deploy on Vercel Install dependencies and run Next.js:
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. ```bash
pnpm install
pnpm dev
```
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. ## Data Model
Tables:
- `users` and `sessions`: local credentials and revocable login sessions.
- `subscriptions`: recurring payment rules.
- `subscription_payments`: paid or unpaid state for generated renewals.
- `bills`: expected manual bills with due dates.
- `one_time_payments`: immediate manual outgoing payments.
- `payments_ledger`: single source of truth for real paid outgoing money.
- `payment_titles`: reusable deduplicated payment names.
- `income`: incoming money.
- `user_settings`: one row of preferences per user.
Subscriptions and bills count as projected obligations until paid. Paid
outgoing money is written to `payments_ledger`, with unique database constraints
preventing duplicate ledger rows.
Subscriptions keep service activation and billing separate. Recurring payments
start from the first payment date, can stop at an optional end date or after an
optional number of installments, and may otherwise continue indefinitely.
Suspended and terminated subscriptions do not generate new virtual renewals,
while already recorded payments remain available as history.
Custom renewal intervals use an exact quantity and unit, supporting schedules
such as every two weeks, every three months, or every two years.
## Database Migrations
`database/init.sql` contains the complete schema for new installations.
Versioned files in `database/migrations` update existing volumes. After pulling
an update that includes a new migration, apply it once with:
```bash
cat database/migrations/001_subscription_lifecycle.sql | docker compose exec -T postgres psql -v ON_ERROR_STOP=1 -U appspese -d appspese
cat database/migrations/002_subscription_custom_intervals.sql | docker compose exec -T postgres psql -v ON_ERROR_STOP=1 -U appspese -d appspese
```
## Authentication
There is no public registration page. The `/setup` page and its API endpoint are
available only while the database contains no users. The first request that
creates an account takes a PostgreSQL transaction lock, preventing concurrent
requests from creating multiple initial accounts.
- Passwords are hashed with bcrypt.
- Login sessions use random secrets stored in an HTTP-only cookie.
- Only a SHA-256 hash of each session secret is stored in PostgreSQL.
- API Route Handlers verify the current user server-side and ignore
client-provided user identifiers.
## Verification
```bash
pnpm lint
pnpm build
docker compose config
```

150
database/init.sql Normal file
View File

@@ -0,0 +1,150 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
email text NOT NULL UNIQUE,
password_hash text NOT NULL,
avatar_file text,
created_at timestamptz NOT NULL DEFAULT NOW(),
updated_at timestamptz NOT NULL DEFAULT NOW()
);
CREATE TABLE sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash text NOT NULL UNIQUE,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT NOW()
);
CREATE INDEX sessions_user_id_idx ON sessions(user_id);
CREATE INDEX sessions_expires_at_idx ON sessions(expires_at);
CREATE TABLE subscriptions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title text NOT NULL,
amount double precision NOT NULL,
billing_cycle text NOT NULL,
activation_date text NOT NULL,
first_payment_date text NOT NULL,
end_date text,
occurrence_count integer CHECK (occurrence_count > 0),
custom_interval_value integer CHECK (custom_interval_value > 0),
custom_interval_unit text CHECK (
custom_interval_unit IN ('days', 'weeks', 'months', 'years')
),
subscription_status text NOT NULL,
created_at text NOT NULL,
updated_at text NOT NULL,
CONSTRAINT subscriptions_amount_positive CHECK (amount > 0),
CONSTRAINT subscriptions_billing_cycle_valid CHECK (
billing_cycle IN ('weekly', 'monthly', 'yearly', 'custom')
),
CONSTRAINT subscriptions_status_valid CHECK (
subscription_status IN ('active', 'suspended', 'terminated')
),
CONSTRAINT subscriptions_dates_valid CHECK (
activation_date <= first_payment_date
AND (end_date IS NULL OR end_date >= first_payment_date)
)
);
CREATE INDEX subscriptions_user_id_idx ON subscriptions(user_id);
CREATE INDEX subscriptions_first_payment_date_idx ON subscriptions(first_payment_date);
CREATE TABLE subscription_payments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
subscription_id uuid NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
renewal_date text NOT NULL,
status text NOT NULL,
payment_date text,
linked_payment_id uuid,
created_at text NOT NULL,
updated_at text NOT NULL,
UNIQUE (subscription_id, renewal_date, user_id)
);
CREATE INDEX subscription_payments_user_id_idx ON subscription_payments(user_id);
CREATE TABLE bills (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title text NOT NULL,
amount double precision NOT NULL,
due_date text NOT NULL,
status text NOT NULL,
payment_date text,
linked_payment_id uuid,
created_at text NOT NULL,
updated_at text NOT NULL
);
CREATE INDEX bills_user_id_idx ON bills(user_id);
CREATE INDEX bills_due_date_idx ON bills(due_date);
CREATE TABLE one_time_payments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title text NOT NULL,
amount double precision NOT NULL,
date text NOT NULL,
linked_payment_id uuid,
created_at text NOT NULL,
updated_at text NOT NULL
);
CREATE INDEX one_time_payments_user_id_idx ON one_time_payments(user_id);
CREATE TABLE payments_ledger (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title text NOT NULL,
amount double precision NOT NULL,
date text NOT NULL,
source_type text NOT NULL,
source_id uuid NOT NULL,
created_at text NOT NULL,
updated_at text NOT NULL,
UNIQUE (source_type, source_id, user_id)
);
CREATE INDEX payments_ledger_user_id_idx ON payments_ledger(user_id);
CREATE INDEX payments_ledger_date_idx ON payments_ledger(date);
CREATE TABLE payment_titles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name text NOT NULL,
normalized_name text NOT NULL,
payment_type text NOT NULL,
created_at text NOT NULL,
updated_at text NOT NULL,
CONSTRAINT payment_titles_type_valid CHECK (
payment_type IN ('subscription', 'bill', 'one_time', 'income', 'any')
),
UNIQUE (user_id, normalized_name, payment_type)
);
CREATE INDEX payment_titles_user_id_idx ON payment_titles(user_id);
CREATE TABLE income (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title text NOT NULL,
amount double precision NOT NULL,
type text NOT NULL,
date text NOT NULL,
created_at text NOT NULL,
updated_at text NOT NULL
);
CREATE INDEX income_user_id_idx ON income(user_id);
CREATE TABLE user_settings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
full_name text,
monthly_income_goal double precision,
monthly_savings_goal double precision,
currency text NOT NULL,
locale text NOT NULL,
first_day_of_week text NOT NULL,
default_dashboard_view text NOT NULL,
created_at text NOT NULL,
updated_at text NOT NULL
);

View File

@@ -0,0 +1,104 @@
BEGIN;
ALTER TABLE subscriptions
ADD COLUMN IF NOT EXISTS activation_date text,
ADD COLUMN IF NOT EXISTS first_payment_date text,
ADD COLUMN IF NOT EXISTS end_date text,
ADD COLUMN IF NOT EXISTS occurrence_count integer,
ADD COLUMN IF NOT EXISTS custom_interval_days integer;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'subscriptions' AND column_name = 'renewal_date'
) THEN
UPDATE subscriptions
SET
activation_date = COALESCE(activation_date, renewal_date),
first_payment_date = COALESCE(first_payment_date, renewal_date),
subscription_status = CASE
WHEN subscription_status = 'disabled' THEN 'terminated'
ELSE subscription_status
END;
ELSE
UPDATE subscriptions
SET subscription_status = 'terminated'
WHERE subscription_status = 'disabled';
END IF;
END $$;
ALTER TABLE subscriptions
ALTER COLUMN activation_date SET NOT NULL,
ALTER COLUMN first_payment_date SET NOT NULL;
ALTER TABLE subscriptions
DROP COLUMN IF EXISTS renewal_date,
DROP COLUMN IF EXISTS auto_renew;
DROP INDEX IF EXISTS subscriptions_renewal_date_idx;
CREATE INDEX IF NOT EXISTS subscriptions_first_payment_date_idx
ON subscriptions(first_payment_date);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'subscriptions_occurrence_count_positive'
) THEN
ALTER TABLE subscriptions
ADD CONSTRAINT subscriptions_occurrence_count_positive
CHECK (occurrence_count > 0);
END IF;
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'subscriptions_custom_interval_days_positive'
) THEN
ALTER TABLE subscriptions
ADD CONSTRAINT subscriptions_custom_interval_days_positive
CHECK (custom_interval_days > 0);
END IF;
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'subscriptions_amount_positive'
) THEN
ALTER TABLE subscriptions
ADD CONSTRAINT subscriptions_amount_positive CHECK (amount > 0);
END IF;
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'subscriptions_billing_cycle_valid'
) THEN
ALTER TABLE subscriptions
ADD CONSTRAINT subscriptions_billing_cycle_valid
CHECK (billing_cycle IN ('weekly', 'monthly', 'yearly', 'custom'));
END IF;
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'subscriptions_status_valid'
) THEN
ALTER TABLE subscriptions
ADD CONSTRAINT subscriptions_status_valid
CHECK (subscription_status IN ('active', 'suspended', 'terminated'));
END IF;
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'subscriptions_dates_valid'
) THEN
ALTER TABLE subscriptions
ADD CONSTRAINT subscriptions_dates_valid
CHECK (
activation_date <= first_payment_date
AND (end_date IS NULL OR end_date >= first_payment_date)
);
END IF;
END $$;
COMMIT;

View File

@@ -0,0 +1,49 @@
BEGIN;
ALTER TABLE subscriptions
ADD COLUMN IF NOT EXISTS custom_interval_value integer,
ADD COLUMN IF NOT EXISTS custom_interval_unit text;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'subscriptions' AND column_name = 'custom_interval_days'
) THEN
UPDATE subscriptions
SET
custom_interval_value = COALESCE(custom_interval_value, custom_interval_days),
custom_interval_unit = CASE
WHEN custom_interval_days IS NOT NULL THEN COALESCE(custom_interval_unit, 'days')
ELSE custom_interval_unit
END;
END IF;
END $$;
ALTER TABLE subscriptions
DROP COLUMN IF EXISTS custom_interval_days;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'subscriptions_custom_interval_value_positive'
) THEN
ALTER TABLE subscriptions
ADD CONSTRAINT subscriptions_custom_interval_value_positive
CHECK (custom_interval_value > 0);
END IF;
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'subscriptions_custom_interval_unit_valid'
) THEN
ALTER TABLE subscriptions
ADD CONSTRAINT subscriptions_custom_interval_unit_valid
CHECK (custom_interval_unit IN ('days', 'weeks', 'months', 'years'));
END IF;
END $$;
COMMIT;

View File

@@ -0,0 +1,7 @@
UPDATE payments_ledger AS ledger
SET date = payment.renewal_date,
updated_at = NOW()
FROM subscription_payments AS payment
WHERE ledger.source_type = 'subscription'
AND ledger.source_id = payment.id
AND ledger.date IS DISTINCT FROM payment.renewal_date;

View File

@@ -0,0 +1,32 @@
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'payment_titles_type_valid'
) THEN
ALTER TABLE payment_titles
ADD CONSTRAINT payment_titles_type_valid CHECK (
payment_type IN ('subscription', 'bill', 'one_time', 'income', 'any')
);
END IF;
END $$;
INSERT INTO payment_titles (
user_id,
name,
normalized_name,
payment_type,
created_at,
updated_at
)
SELECT DISTINCT
user_id,
title,
LOWER(REGEXP_REPLACE(TRIM(title), '\s+', ' ', 'g')),
'income',
NOW()::text,
NOW()::text
FROM income
WHERE TRIM(title) <> ''
ON CONFLICT (user_id, normalized_name, payment_type) DO NOTHING;

View File

@@ -0,0 +1,2 @@
ALTER TABLE users
ADD COLUMN IF NOT EXISTS avatar_file text;

35
docker-compose.yml Normal file
View File

@@ -0,0 +1,35 @@
services:
app:
build: .
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-appspese}:${POSTGRES_PASSWORD:-change-me}@postgres:5432/${POSTGRES_DB:-appspese}
AVATAR_STORAGE_DIR: /data/avatars
ports:
- "${APP_PORT:-3000}:3000"
volumes:
- ./.data/avatars:/data/avatars
postgres:
image: postgres:17-alpine
restart: unless-stopped
ports:
- "${POSTGRES_PORT:-5432}:5432"
environment:
POSTGRES_DB: ${POSTGRES_DB:-appspese}
POSTGRES_USER: ${POSTGRES_USER:-appspese}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-appspese} -d ${POSTGRES_DB:-appspese}"]
interval: 5s
timeout: 5s
retries: 10
volumes:
- postgres_data:/var/lib/postgresql/data
- ./database/init.sql:/docker-entrypoint-initdb.d/001-init.sql:ro
volumes:
postgres_data:

View File

@@ -1,7 +1,8 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ output: "standalone",
allowedDevOrigins: ["100.64.0.3"],
}; };
export default nextConfig; export default nextConfig;

View File

@@ -2,6 +2,7 @@
"name": "appspese", "name": "appspese",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"packageManager": "pnpm@11.4.0",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
@@ -10,9 +11,12 @@
"format": "biome format --write" "format": "biome format --write"
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3",
"next": "16.2.6", "next": "16.2.6",
"postgres": "^3.4.9",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"react-icons": "^5.6.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.2.0", "@biomejs/biome": "2.2.0",

38
pnpm-lock.yaml generated
View File

@@ -8,15 +8,24 @@ importers:
.: .:
dependencies: dependencies:
bcryptjs:
specifier: ^3.0.3
version: 3.0.3
next: next:
specifier: 16.2.6 specifier: 16.2.6
version: 16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
postgres:
specifier: ^3.4.9
version: 3.4.9
react: react:
specifier: 19.2.4 specifier: 19.2.4
version: 19.2.4 version: 19.2.4
react-dom: react-dom:
specifier: 19.2.4 specifier: 19.2.4
version: 19.2.4(react@19.2.4) version: 19.2.4(react@19.2.4)
react-icons:
specifier: ^5.6.0
version: 5.6.0(react@19.2.4)
devDependencies: devDependencies:
'@biomejs/biome': '@biomejs/biome':
specifier: 2.2.0 specifier: 2.2.0
@@ -441,6 +450,10 @@ packages:
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
bcryptjs@3.0.3:
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
hasBin: true
caniuse-lite@1.0.30001793: caniuse-lite@1.0.30001793:
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
@@ -454,8 +467,8 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
enhanced-resolve@5.22.1: enhanced-resolve@5.22.0:
resolution: {integrity: sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==} resolution: {integrity: sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
graceful-fs@4.2.11: graceful-fs@4.2.11:
@@ -579,11 +592,20 @@ packages:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
postgres@3.4.9:
resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==}
engines: {node: '>=12'}
react-dom@19.2.4: react-dom@19.2.4:
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
peerDependencies: peerDependencies:
react: ^19.2.4 react: ^19.2.4
react-icons@5.6.0:
resolution: {integrity: sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==}
peerDependencies:
react: '*'
react@19.2.4: react@19.2.4:
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -828,7 +850,7 @@ snapshots:
'@tailwindcss/node@4.3.0': '@tailwindcss/node@4.3.0':
dependencies: dependencies:
'@jridgewell/remapping': 2.3.5 '@jridgewell/remapping': 2.3.5
enhanced-resolve: 5.22.1 enhanced-resolve: 5.22.0
jiti: 2.7.0 jiti: 2.7.0
lightningcss: 1.32.0 lightningcss: 1.32.0
magic-string: 0.30.21 magic-string: 0.30.21
@@ -908,6 +930,8 @@ snapshots:
baseline-browser-mapping@2.10.32: {} baseline-browser-mapping@2.10.32: {}
bcryptjs@3.0.3: {}
caniuse-lite@1.0.30001793: {} caniuse-lite@1.0.30001793: {}
client-only@0.0.1: {} client-only@0.0.1: {}
@@ -916,7 +940,7 @@ snapshots:
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
enhanced-resolve@5.22.1: enhanced-resolve@5.22.0:
dependencies: dependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
tapable: 2.3.3 tapable: 2.3.3
@@ -1018,11 +1042,17 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
postgres@3.4.9: {}
react-dom@19.2.4(react@19.2.4): react-dom@19.2.4(react@19.2.4):
dependencies: dependencies:
react: 19.2.4 react: 19.2.4
scheduler: 0.27.0 scheduler: 0.27.0
react-icons@5.6.0(react@19.2.4):
dependencies:
react: 19.2.4
react@19.2.4: {} react@19.2.4: {}
scheduler@0.27.0: {} scheduler@0.27.0: {}

View File

@@ -1,3 +1,5 @@
allowBuilds:
sharp: false
ignoredBuiltDependencies: ignoredBuiltDependencies:
- sharp - sharp
- unrs-resolver - unrs-resolver

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

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"; @import "tailwindcss";
:root { /* Riserva sempre lo spazio della scrollbar */
--background: #ffffff; html {
--foreground: #171717; overflow-y: auto;
scrollbar-gutter: stable;
} }
@theme inline { /* Firefox */
--color-background: var(--background); * {
--color-foreground: var(--foreground); scrollbar-width: thin;
--font-sans: var(--font-geist-sans); scrollbar-color: rgba(255, 255, 255, 0.25) transparent;
--font-mono: var(--font-geist-mono);
} }
@media (prefers-color-scheme: dark) { /* Chrome, Edge, Brave, Opera */
:root { ::-webkit-scrollbar {
--background: #0a0a0a; width: 10px;
--foreground: #ededed; 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 { @keyframes modal-out {
background: var(--background); from {
color: var(--foreground); opacity: 1;
font-family: Arial, Helvetica, sans-serif; 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 = { export const metadata: Metadata = {
title: "Create Next App", title: "MyMoney",
description: "Generated by create next app", description:
"Tieni traccia delle tue finanze con MyMoney, in modo semplice ed efficace.",
}; };
export default function RootLayout({ export default function RootLayout({
@@ -24,10 +25,10 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html <html
lang="en" lang="it"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} 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> </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() { export default async function Home() {
return ( const user = await getAuthenticatedUser();
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black"> if (user) redirect("/dashboard");
<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"> redirect((await isInstanceConfigured()) ? "/login" : "/setup");
<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>
);
} }

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

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