first commit
This commit is contained in:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env*
|
||||
*.tsbuildinfo
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -23,6 +23,7 @@
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
/.data/
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
@@ -32,6 +33,7 @@ yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal 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
109
README.md
@@ -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
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
cp .env.example .env
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
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.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
Create `.env.local`:
|
||||
|
||||
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
150
database/init.sql
Normal 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
|
||||
);
|
||||
104
database/migrations/001_subscription_lifecycle.sql
Normal file
104
database/migrations/001_subscription_lifecycle.sql
Normal 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;
|
||||
49
database/migrations/002_subscription_custom_intervals.sql
Normal file
49
database/migrations/002_subscription_custom_intervals.sql
Normal 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;
|
||||
@@ -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;
|
||||
32
database/migrations/004_income_titles.sql
Normal file
32
database/migrations/004_income_titles.sql
Normal 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;
|
||||
2
database/migrations/005_user_avatar.sql
Normal file
2
database/migrations/005_user_avatar.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS avatar_file text;
|
||||
35
docker-compose.yml
Normal file
35
docker-compose.yml
Normal 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:
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
allowedDevOrigins: ["100.64.0.3"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "appspese",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@11.4.0",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
@@ -10,9 +11,12 @@
|
||||
"format": "biome format --write"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"next": "16.2.6",
|
||||
"postgres": "^3.4.9",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
"react-dom": "19.2.4",
|
||||
"react-icons": "^5.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.0",
|
||||
|
||||
38
pnpm-lock.yaml
generated
38
pnpm-lock.yaml
generated
@@ -8,15 +8,24 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
bcryptjs:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
next:
|
||||
specifier: 16.2.6
|
||||
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:
|
||||
specifier: 19.2.4
|
||||
version: 19.2.4
|
||||
react-dom:
|
||||
specifier: 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:
|
||||
'@biomejs/biome':
|
||||
specifier: 2.2.0
|
||||
@@ -441,6 +450,10 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
bcryptjs@3.0.3:
|
||||
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
|
||||
hasBin: true
|
||||
|
||||
caniuse-lite@1.0.30001793:
|
||||
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
|
||||
|
||||
@@ -454,8 +467,8 @@ packages:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
enhanced-resolve@5.22.1:
|
||||
resolution: {integrity: sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==}
|
||||
enhanced-resolve@5.22.0:
|
||||
resolution: {integrity: sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
graceful-fs@4.2.11:
|
||||
@@ -579,11 +592,20 @@ packages:
|
||||
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postgres@3.4.9:
|
||||
resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
react-dom@19.2.4:
|
||||
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
|
||||
peerDependencies:
|
||||
react: ^19.2.4
|
||||
|
||||
react-icons@5.6.0:
|
||||
resolution: {integrity: sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==}
|
||||
peerDependencies:
|
||||
react: '*'
|
||||
|
||||
react@19.2.4:
|
||||
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -828,7 +850,7 @@ snapshots:
|
||||
'@tailwindcss/node@4.3.0':
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
enhanced-resolve: 5.22.1
|
||||
enhanced-resolve: 5.22.0
|
||||
jiti: 2.7.0
|
||||
lightningcss: 1.32.0
|
||||
magic-string: 0.30.21
|
||||
@@ -908,6 +930,8 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.10.32: {}
|
||||
|
||||
bcryptjs@3.0.3: {}
|
||||
|
||||
caniuse-lite@1.0.30001793: {}
|
||||
|
||||
client-only@0.0.1: {}
|
||||
@@ -916,7 +940,7 @@ snapshots:
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
enhanced-resolve@5.22.1:
|
||||
enhanced-resolve@5.22.0:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.3.3
|
||||
@@ -1018,11 +1042,17 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postgres@3.4.9: {}
|
||||
|
||||
react-dom@19.2.4(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
scheduler: 0.27.0
|
||||
|
||||
react-icons@5.6.0(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
react@19.2.4: {}
|
||||
|
||||
scheduler@0.27.0: {}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
allowBuilds:
|
||||
sharp: false
|
||||
ignoredBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
|
||||
BIN
public/logo.png
Normal file
BIN
public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 194 KiB |
5
src/app/(protected)/bills/page.tsx
Normal file
5
src/app/(protected)/bills/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { BillsPageClient } from "@/components/bills/BillsPageClient";
|
||||
|
||||
export default function BillsPage() {
|
||||
return <BillsPageClient />;
|
||||
}
|
||||
5
src/app/(protected)/dashboard/page.tsx
Normal file
5
src/app/(protected)/dashboard/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { DashboardPageClient } from "@/components/dashboard/DashboardPageClient";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <DashboardPageClient />;
|
||||
}
|
||||
5
src/app/(protected)/income/page.tsx
Normal file
5
src/app/(protected)/income/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { IncomePageClient } from "@/components/income/IncomePageClient";
|
||||
|
||||
export default function IncomePage() {
|
||||
return <IncomePageClient />;
|
||||
}
|
||||
17
src/app/(protected)/layout.tsx
Normal file
17
src/app/(protected)/layout.tsx
Normal 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>;
|
||||
}
|
||||
5
src/app/(protected)/one-time-payments/page.tsx
Normal file
5
src/app/(protected)/one-time-payments/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { OneTimePaymentsPageClient } from "@/components/one-time-payments/OneTimePaymentsPageClient";
|
||||
|
||||
export default function OneTimePaymentsPage() {
|
||||
return <OneTimePaymentsPageClient />;
|
||||
}
|
||||
8
src/app/(protected)/profile/page.tsx
Normal file
8
src/app/(protected)/profile/page.tsx
Normal 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} />;
|
||||
}
|
||||
17
src/app/(protected)/reports/page.tsx
Normal file
17
src/app/(protected)/reports/page.tsx
Normal 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();
|
||||
}
|
||||
5
src/app/(protected)/subscriptions/page.tsx
Normal file
5
src/app/(protected)/subscriptions/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SubscriptionsPageClient } from "@/components/subscriptions/SubscriptionsPageClient";
|
||||
|
||||
export default function SubscriptionsPage() {
|
||||
return <SubscriptionsPageClient />;
|
||||
}
|
||||
11
src/app/(public)/login/page.tsx
Normal file
11
src/app/(public)/login/page.tsx
Normal 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 />;
|
||||
}
|
||||
11
src/app/(public)/setup/page.tsx
Normal file
11
src/app/(public)/setup/page.tsx
Normal 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 />;
|
||||
}
|
||||
39
src/app/api/auth/login/route.ts
Normal file
39
src/app/api/auth/login/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
6
src/app/api/auth/logout/route.ts
Normal file
6
src/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { deleteCurrentSession } from "@/lib/auth";
|
||||
|
||||
export async function POST() {
|
||||
await deleteCurrentSession();
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
6
src/app/api/auth/session/route.ts
Normal file
6
src/app/api/auth/session/route.ts
Normal 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 });
|
||||
}
|
||||
49
src/app/api/auth/setup/route.ts
Normal file
49
src/app/api/auth/setup/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
55
src/app/api/bills/[id]/route.ts
Normal file
55
src/app/api/bills/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
46
src/app/api/bills/route.ts
Normal file
46
src/app/api/bills/route.ts
Normal 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 });
|
||||
}
|
||||
38
src/app/api/income/[id]/route.ts
Normal file
38
src/app/api/income/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
37
src/app/api/income/route.ts
Normal file
37
src/app/api/income/route.ts
Normal 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 });
|
||||
}
|
||||
67
src/app/api/one-time-payments/[id]/route.ts
Normal file
67
src/app/api/one-time-payments/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
54
src/app/api/one-time-payments/route.ts
Normal file
54
src/app/api/one-time-payments/route.ts
Normal 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 });
|
||||
}
|
||||
49
src/app/api/payment-titles/route.ts
Normal file
49
src/app/api/payment-titles/route.ts
Normal 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 });
|
||||
}
|
||||
18
src/app/api/payments-ledger/route.ts
Normal file
18
src/app/api/payments-ledger/route.ts
Normal 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);
|
||||
}
|
||||
120
src/app/api/profile/account/route.ts
Normal file
120
src/app/api/profile/account/route.ts
Normal 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
|
||||
);
|
||||
}
|
||||
98
src/app/api/profile/avatar/route.ts
Normal file
98
src/app/api/profile/avatar/route.ts
Normal 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;
|
||||
}
|
||||
37
src/app/api/profile/data/route.ts
Normal file
37
src/app/api/profile/data/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/app/api/profile/settings/route.ts
Normal file
19
src/app/api/profile/settings/route.ts
Normal 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));
|
||||
}
|
||||
79
src/app/api/subscription-payments/[id]/route.ts
Normal file
79
src/app/api/subscription-payments/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
160
src/app/api/subscription-payments/route.ts
Normal file
160
src/app/api/subscription-payments/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
79
src/app/api/subscriptions/[id]/route.ts
Normal file
79
src/app/api/subscriptions/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
40
src/app/api/subscriptions/route.ts
Normal file
40
src/app/api/subscriptions/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -1,26 +1,85 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
/* Riserva sempre lo spazio della scrollbar */
|
||||
html {
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
/* Firefox */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.25) transparent;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
/* Chrome, Edge, Brave, Opera */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:active {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@keyframes modal-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
@keyframes modal-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-backdrop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-backdrop-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,9 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "MyMoney",
|
||||
description:
|
||||
"Tieni traccia delle tue finanze con MyMoney, in modo semplice ed efficace.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -24,10 +25,10 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
lang="it"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<body className="min-h-full bg-[#182033]">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,65 +1,9 @@
|
||||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAuthenticatedUser } from "@/lib/auth";
|
||||
import { isInstanceConfigured } from "@/lib/database/setup";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
export default async function Home() {
|
||||
const user = await getAuthenticatedUser();
|
||||
if (user) redirect("/dashboard");
|
||||
redirect((await isInstanceConfigured()) ? "/login" : "/setup");
|
||||
}
|
||||
|
||||
12
src/components/AppShell.tsx
Normal file
12
src/components/AppShell.tsx
Normal 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>;
|
||||
}
|
||||
75
src/components/AuthForm.tsx
Normal file
75
src/components/AuthForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
src/components/SetupForm.tsx
Normal file
82
src/components/SetupForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
162
src/components/bills/BillForm.tsx
Normal file
162
src/components/bills/BillForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
src/components/bills/BillList.tsx
Normal file
109
src/components/bills/BillList.tsx
Normal 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(),
|
||||
};
|
||||
}
|
||||
13
src/components/bills/BillStatusBadge.tsx
Normal file
13
src/components/bills/BillStatusBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
162
src/components/bills/BillsPageClient.tsx
Normal file
162
src/components/bills/BillsPageClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/components/dashboard/DashboardCards.tsx
Normal file
35
src/components/dashboard/DashboardCards.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { DashboardStatCard } from "@/components/dashboard/DashboardStatCard";
|
||||
import { euroFormatter } from "@/lib/finance/money";
|
||||
|
||||
export function DashboardCards({
|
||||
summary,
|
||||
scope,
|
||||
}: {
|
||||
summary: {
|
||||
income: number;
|
||||
realSpent: number;
|
||||
projectedSpent: number;
|
||||
projectedRemainingBalance: number;
|
||||
};
|
||||
scope: "anno" | "mese";
|
||||
}) {
|
||||
const cards = [
|
||||
[`Entrate ${scope}`, summary.income],
|
||||
[`Spesa reale ${scope}`, summary.realSpent],
|
||||
[`Previsione ${scope}`, summary.projectedSpent],
|
||||
["Saldo previsto", summary.projectedRemainingBalance],
|
||||
];
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-2 md:grid-cols-2 md:gap-3 xl:grid-cols-4">
|
||||
{cards.map(([label, value]) => (
|
||||
<DashboardStatCard
|
||||
desktopClassName="md:col-span-1"
|
||||
key={label}
|
||||
label={String(label)}
|
||||
mobileSpan={2}
|
||||
value={euroFormatter.format(Number(value))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/components/dashboard/DashboardKpiRow.tsx
Normal file
48
src/components/dashboard/DashboardKpiRow.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { DashboardStatCard } from "@/components/dashboard/DashboardStatCard";
|
||||
import { euroFormatter } from "@/lib/finance/money";
|
||||
|
||||
export function DashboardKpiRow({
|
||||
savingsRate,
|
||||
recurringCost,
|
||||
billTotal,
|
||||
activeSubscriptions,
|
||||
scope,
|
||||
}: {
|
||||
savingsRate: number;
|
||||
recurringCost: number;
|
||||
billTotal: number;
|
||||
activeSubscriptions: number;
|
||||
scope: "anno" | "mese";
|
||||
}) {
|
||||
return (
|
||||
<section className="grid grid-cols-4 gap-2 md:gap-3">
|
||||
<DashboardStatCard
|
||||
desktopClassName="md:col-span-1"
|
||||
label="Risparmio"
|
||||
mobileSpan={2}
|
||||
tone="green"
|
||||
value={`${(savingsRate * 100).toFixed(1)}%`}
|
||||
/>
|
||||
<DashboardStatCard
|
||||
desktopClassName="md:col-span-1"
|
||||
label={`Costo ricorrente ${scope}`}
|
||||
mobileSpan={2}
|
||||
tone="yellow"
|
||||
value={euroFormatter.format(recurringCost)}
|
||||
/>
|
||||
<DashboardStatCard
|
||||
desktopClassName="md:col-span-1"
|
||||
label={`Totale bollette ${scope}`}
|
||||
mobileSpan={2}
|
||||
value={euroFormatter.format(billTotal)}
|
||||
/>
|
||||
<DashboardStatCard
|
||||
desktopClassName="md:col-span-1"
|
||||
label={scope === "anno" ? "Abbonamenti attivi" : "Abbonamenti nel mese"}
|
||||
mobileSpan={2}
|
||||
tone="violet"
|
||||
value={activeSubscriptions}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
104
src/components/dashboard/DashboardPageClient.tsx
Normal file
104
src/components/dashboard/DashboardPageClient.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { DashboardCards } from "@/components/dashboard/DashboardCards";
|
||||
import { DashboardKpiRow } from "@/components/dashboard/DashboardKpiRow";
|
||||
import { ExpenseDistribution } from "@/components/dashboard/ExpenseDistribution";
|
||||
import { UpcomingPayments } from "@/components/dashboard/UpcomingPayments";
|
||||
import { YearSelector } from "@/components/dashboard/YearSelector";
|
||||
import { PageShell } from "@/components/shared/PageShell";
|
||||
import {
|
||||
useBillsResource,
|
||||
useIncomeResource,
|
||||
useLedgerResource,
|
||||
useSubscriptionPaymentsResource,
|
||||
useSubscriptionsResource,
|
||||
} from "@/components/shared/useFinanceData";
|
||||
import { buildYearlyDashboardSummary } from "@/lib/finance/yearlySummary";
|
||||
|
||||
export function DashboardPageClient() {
|
||||
const subscriptions = useSubscriptionsResource();
|
||||
const subscriptionPayments = useSubscriptionPaymentsResource();
|
||||
const bills = useBillsResource();
|
||||
const ledger = useLedgerResource();
|
||||
const income = useIncomeResource();
|
||||
const [year, setYear] = useState(() => new Date().getFullYear());
|
||||
const loading = [
|
||||
subscriptions,
|
||||
subscriptionPayments,
|
||||
bills,
|
||||
ledger,
|
||||
income,
|
||||
].some((resource) => resource.loading);
|
||||
const error = [
|
||||
subscriptions,
|
||||
subscriptionPayments,
|
||||
bills,
|
||||
ledger,
|
||||
income,
|
||||
].find((resource) => resource.error)?.error;
|
||||
const yearlySummary = useMemo(
|
||||
() =>
|
||||
buildYearlyDashboardSummary({
|
||||
year,
|
||||
ledger: ledger.data ?? [],
|
||||
income: income.data ?? [],
|
||||
subscriptions: subscriptions.data ?? [],
|
||||
subscriptionPayments: subscriptionPayments.data ?? [],
|
||||
bills: bills.data ?? [],
|
||||
}),
|
||||
[
|
||||
bills.data,
|
||||
income.data,
|
||||
ledger.data,
|
||||
year,
|
||||
subscriptionPayments.data,
|
||||
subscriptions.data,
|
||||
],
|
||||
);
|
||||
const summary = {
|
||||
income: yearlySummary.yearlyIncome,
|
||||
realSpent: yearlySummary.realSpent,
|
||||
projectedSpent: yearlySummary.projectedSpent,
|
||||
projectedRemainingBalance: yearlySummary.projectedRemainingBalance,
|
||||
savingsRate: yearlySummary.savingsRate,
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title="Dashboard"
|
||||
description="Panoramica finanziaria annuale reale e prevista."
|
||||
>
|
||||
{loading ? (
|
||||
<p className="text-sm text-slate-400">Caricamento dati...</p>
|
||||
) : null}
|
||||
{error ? <p className="text-sm text-rose-300">{error}</p> : null}
|
||||
<section className="grid gap-4 rounded-lg border border-white/10 bg-white/[0.03] p-4">
|
||||
<YearSelector onChange={setYear} year={year} />
|
||||
<div className="grid gap-3">
|
||||
<DashboardCards scope="anno" summary={summary} />
|
||||
<DashboardKpiRow
|
||||
activeSubscriptions={yearlySummary.activeSubscriptions}
|
||||
billTotal={yearlySummary.annualBillTotal}
|
||||
recurringCost={yearlySummary.annualRecurringCost}
|
||||
savingsRate={summary.savingsRate}
|
||||
scope="anno"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid items-stretch gap-4 xl:grid-cols-[minmax(0,1.12fr)_minmax(22rem,0.88fr)]">
|
||||
<UpcomingPayments
|
||||
bills={bills.data ?? []}
|
||||
subscriptionPayments={subscriptionPayments.data ?? []}
|
||||
subscriptions={subscriptions.data ?? []}
|
||||
/>
|
||||
<ExpenseDistribution
|
||||
items={yearlySummary.expenseDistribution.items}
|
||||
periodLabel={`l'anno ${year}`}
|
||||
total={yearlySummary.expenseDistribution.total}
|
||||
year={year}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
62
src/components/dashboard/DashboardStatCard.tsx
Normal file
62
src/components/dashboard/DashboardStatCard.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
type StatTone = "default" | "green" | "yellow" | "red" | "violet";
|
||||
type MobileSpan = 1 | 2 | 4;
|
||||
|
||||
const spanClasses: Record<MobileSpan, string> = {
|
||||
1: "col-span-1",
|
||||
2: "col-span-2",
|
||||
4: "col-span-4",
|
||||
};
|
||||
|
||||
const tones: Record<StatTone, { border: string; value: string }> = {
|
||||
default: {
|
||||
border: "border-white/10",
|
||||
value: "text-white",
|
||||
},
|
||||
green: {
|
||||
border: "border-emerald-400/20",
|
||||
value: "text-emerald-200",
|
||||
},
|
||||
yellow: {
|
||||
border: "border-amber-400/20",
|
||||
value: "text-amber-200",
|
||||
},
|
||||
red: {
|
||||
border: "border-rose-400/20",
|
||||
value: "text-rose-200",
|
||||
},
|
||||
violet: {
|
||||
border: "border-violet-400/20",
|
||||
value: "text-violet-200",
|
||||
},
|
||||
};
|
||||
|
||||
export function DashboardStatCard({
|
||||
label,
|
||||
value,
|
||||
mobileSpan = 1,
|
||||
tone = "default",
|
||||
desktopClassName = "",
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
mobileSpan?: MobileSpan;
|
||||
tone?: StatTone;
|
||||
desktopClassName?: string;
|
||||
}) {
|
||||
const style = tones[tone];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${spanClasses[mobileSpan]} min-w-0 overflow-hidden rounded-lg border bg-white/[0.04] ${style.border} ${desktopClassName}`}
|
||||
>
|
||||
<p className="px-2 pt-2 text-[10px] leading-tight text-slate-400 md:px-4 md:pt-3 md:text-sm md:leading-normal">
|
||||
{label}
|
||||
</p>
|
||||
<div
|
||||
className={`flex min-h-10 items-center justify-center px-1 pb-2 pt-1 text-center text-lg font-semibold leading-none md:min-h-14 md:px-4 md:pb-3 md:pt-2 md:text-2xl ${style.value}`}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
src/components/dashboard/ExpenseDistribution.tsx
Normal file
126
src/components/dashboard/ExpenseDistribution.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { LuArrowRight } from "react-icons/lu";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
import type { ExpenseDistributionItem } from "@/lib/finance/expenseDistribution";
|
||||
import { euroFormatter } from "@/lib/finance/money";
|
||||
|
||||
export function ExpenseDistribution({
|
||||
items,
|
||||
total,
|
||||
year,
|
||||
periodLabel,
|
||||
showReportLink = true,
|
||||
}: {
|
||||
items: ExpenseDistributionItem[];
|
||||
total: number;
|
||||
year: number;
|
||||
periodLabel?: string;
|
||||
showReportLink?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section className="flex min-h-full flex-col rounded-lg border border-white/10 bg-white/[0.03] p-4">
|
||||
<div>
|
||||
<h2 className="font-medium text-white">Ripartizione spese</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Spesa reale {periodLabel ?? year}: {euroFormatter.format(total)}
|
||||
</p>
|
||||
</div>
|
||||
{total ? (
|
||||
<div className="my-5 grid flex-1 items-center gap-5 sm:grid-cols-[minmax(8rem,11rem)_1fr]">
|
||||
<div className="relative mx-auto grid aspect-square w-full max-w-44 place-items-center">
|
||||
<DistributionRing items={items} />
|
||||
<div className="absolute grid h-[64%] w-[64%] place-items-center rounded-full bg-[#0b101a] text-center">
|
||||
<div>
|
||||
<p className="text-[10px] uppercase text-slate-500">Totale</p>
|
||||
<p className="mt-1 text-sm font-semibold text-white">
|
||||
{euroFormatter.format(total)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{items.map((item) => (
|
||||
<DistributionLegendItem item={item} key={item.sourceType} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="my-4 flex-1">
|
||||
<EmptyState
|
||||
title="Nessuna spesa registrata"
|
||||
text={`Non ci sono pagamenti reali per ${periodLabel ?? `il ${year}`}. Prova a selezionare un altro periodo. I pagamenti futuri non ancora saldati sono visibili in Dashboard.`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showReportLink ? (
|
||||
<Link
|
||||
className="flex items-center justify-center gap-2 rounded-md border border-white/10 px-3 py-2 text-sm text-slate-200 transition hover:bg-white/5 hover:text-white"
|
||||
href={`/reports?year=${year}`}
|
||||
>
|
||||
Apri report
|
||||
<LuArrowRight size={16} />
|
||||
</Link>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DistributionRing({ items }: { items: ExpenseDistributionItem[] }) {
|
||||
let offset = 0;
|
||||
const ring = (
|
||||
<svg aria-hidden="true" className="h-full w-full" viewBox="0 0 42 42">
|
||||
<title>Ripartizione delle spese</title>
|
||||
<circle
|
||||
className="stroke-slate-800"
|
||||
cx="21"
|
||||
cy="21"
|
||||
fill="none"
|
||||
r="15.915"
|
||||
strokeWidth="7"
|
||||
/>
|
||||
{items.map((item) => {
|
||||
const dashOffset = -offset;
|
||||
offset += item.percentage;
|
||||
return (
|
||||
<circle
|
||||
className="opacity-100"
|
||||
cx="21"
|
||||
cy="21"
|
||||
fill="none"
|
||||
key={item.sourceType}
|
||||
pathLength="100"
|
||||
r="15.915"
|
||||
stroke={item.color}
|
||||
strokeDasharray={`${item.percentage} ${100 - item.percentage}`}
|
||||
strokeDashoffset={dashOffset}
|
||||
strokeWidth="7"
|
||||
transform="rotate(-90 21 21)"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
return ring;
|
||||
}
|
||||
|
||||
function DistributionLegendItem({ item }: { item: ExpenseDistributionItem }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_auto] items-center gap-3 rounded-md px-2 py-1 text-sm">
|
||||
<span className="flex min-w-0 items-center gap-2 text-slate-300">
|
||||
<span
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<span className="truncate">{item.label}</span>
|
||||
</span>
|
||||
<span className="text-right text-slate-200">
|
||||
{euroFormatter.format(item.amount)}
|
||||
<span className="ml-2 text-xs text-slate-500">
|
||||
{item.percentage.toFixed(1)}%
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
src/components/dashboard/FinancialSummary.tsx
Normal file
46
src/components/dashboard/FinancialSummary.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { DashboardStatCard } from "@/components/dashboard/DashboardStatCard";
|
||||
|
||||
export function FinancialSummary({
|
||||
unpaidSubscriptions,
|
||||
unpaidBills,
|
||||
overdueSubscriptions,
|
||||
overdueBills,
|
||||
}: {
|
||||
unpaidSubscriptions: unknown[];
|
||||
unpaidBills: unknown[];
|
||||
overdueSubscriptions: unknown[];
|
||||
overdueBills: unknown[];
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-2 md:gap-3">
|
||||
<Metric
|
||||
label="Abbonamenti non pagati"
|
||||
value={unpaidSubscriptions.length}
|
||||
tone="violet"
|
||||
/>
|
||||
<Metric
|
||||
label="Bollette non pagate"
|
||||
value={unpaidBills.length}
|
||||
tone="yellow"
|
||||
/>
|
||||
<Metric
|
||||
label="Abbonamenti scaduti"
|
||||
value={overdueSubscriptions.length}
|
||||
tone="red"
|
||||
/>
|
||||
<Metric label="Bollette scadute" value={overdueBills.length} tone="red" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone: "violet" | "yellow" | "red";
|
||||
}) {
|
||||
return <DashboardStatCard label={label} tone={tone} value={value} />;
|
||||
}
|
||||
62
src/components/dashboard/GoalProgress.tsx
Normal file
62
src/components/dashboard/GoalProgress.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { euroFormatter } from "@/lib/finance/money";
|
||||
import type { UserSettings } from "@/types/finance";
|
||||
|
||||
export function GoalProgress({
|
||||
settings,
|
||||
yearlyIncome,
|
||||
yearlySavings,
|
||||
}: {
|
||||
settings?: UserSettings;
|
||||
yearlyIncome: number;
|
||||
yearlySavings: number;
|
||||
}) {
|
||||
const goals = [
|
||||
{
|
||||
label: "Obiettivo entrate",
|
||||
current: yearlyIncome,
|
||||
goal: settings?.monthlyIncomeGoal
|
||||
? settings.monthlyIncomeGoal * 12
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
label: "Obiettivo risparmio",
|
||||
current: yearlySavings,
|
||||
goal: settings?.monthlySavingsGoal
|
||||
? settings.monthlySavingsGoal * 12
|
||||
: undefined,
|
||||
},
|
||||
].filter((item) => item.goal);
|
||||
|
||||
if (!goals.length) return null;
|
||||
|
||||
return (
|
||||
<section className="grid gap-3 md:grid-cols-2">
|
||||
{goals.map((item) => {
|
||||
const percentage = Math.min(
|
||||
100,
|
||||
(item.current / Number(item.goal)) * 100,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border border-white/10 bg-white/[0.03] p-4"
|
||||
key={item.label}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-slate-300">{item.label}</span>
|
||||
<span className="text-slate-400">
|
||||
{euroFormatter.format(item.current)} /{" "}
|
||||
{euroFormatter.format(Number(item.goal))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 h-2 overflow-hidden rounded-full bg-slate-950">
|
||||
<div
|
||||
className="h-full rounded-full bg-emerald-400 transition-[width]"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
127
src/components/dashboard/UpcomingPayments.tsx
Normal file
127
src/components/dashboard/UpcomingPayments.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
import { PaymentBadge } from "@/components/shared/PaymentBadge";
|
||||
import { addDays, formatItalianDate, todayIso } from "@/lib/date/formatDate";
|
||||
import { paymentStatusLabel } from "@/lib/finance/labels";
|
||||
import { euroFormatter } from "@/lib/finance/money";
|
||||
import { getPaymentVisualStyle } from "@/lib/finance/paymentVisualStyle";
|
||||
import { getUpcomingPayments } from "@/lib/finance/upcomingPayments";
|
||||
import type { Bill, Subscription, SubscriptionPayment } from "@/types/finance";
|
||||
|
||||
const ranges = [7, 15, 30, 60];
|
||||
|
||||
export function UpcomingPayments({
|
||||
bills,
|
||||
subscriptions,
|
||||
subscriptionPayments,
|
||||
}: {
|
||||
bills: Bill[];
|
||||
subscriptions: Subscription[];
|
||||
subscriptionPayments: SubscriptionPayment[];
|
||||
}) {
|
||||
const [days, setDays] = useState(30);
|
||||
const referenceDate = todayIso();
|
||||
const items = useMemo(
|
||||
() =>
|
||||
getUpcomingPayments({
|
||||
bills,
|
||||
subscriptions,
|
||||
subscriptionPayments,
|
||||
days,
|
||||
referenceDate,
|
||||
}),
|
||||
[bills, days, referenceDate, subscriptionPayments, subscriptions],
|
||||
);
|
||||
const total = items.reduce((sum, item) => sum + item.amount, 0);
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-white/10 bg-white/[0.03] p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-white">
|
||||
Prossimi pagamenti
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Da oggi: {items.length} pagamenti · {euroFormatter.format(total)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex rounded-md border border-white/10 bg-slate-950/60 p-1">
|
||||
{ranges.map((range) => (
|
||||
<button
|
||||
aria-pressed={days === range}
|
||||
className={`rounded px-2.5 py-1.5 text-xs transition ${
|
||||
days === range
|
||||
? "bg-sky-400/15 text-sky-100"
|
||||
: "text-slate-400 hover:text-white"
|
||||
}`}
|
||||
key={range}
|
||||
onClick={() => setDays(range)}
|
||||
type="button"
|
||||
>
|
||||
{range} gg
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-2">
|
||||
{items.length ? (
|
||||
items.map((item) => {
|
||||
const style = getPaymentVisualStyle(item.sourceType, item.status);
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-between gap-2 rounded-md border px-3 py-2.5 ${style.card}`}
|
||||
key={item.id}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-slate-100">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-slate-400">
|
||||
{item.subtitle} · {formatItalianDate(item.dueDate)} ·{" "}
|
||||
{dateDistanceLabel(item.dueDate, referenceDate)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<div className="text-right">
|
||||
<p className={`text-sm ${style.accent}`}>
|
||||
{euroFormatter.format(item.amount)}
|
||||
</p>
|
||||
<div className="mt-1">
|
||||
<PaymentBadge
|
||||
sourceType={item.sourceType}
|
||||
status={item.status}
|
||||
>
|
||||
{paymentStatusLabel(item.status)}
|
||||
</PaymentBadge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Nessun pagamento previsto"
|
||||
text={`Non ci sono bollette o abbonamenti nei prossimi ${days} giorni.`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function dateDistanceLabel(dueDate: string, referenceDate: string) {
|
||||
if (dueDate === referenceDate) return "oggi";
|
||||
let distance = 0;
|
||||
let cursor = referenceDate;
|
||||
const direction = dueDate > referenceDate ? 1 : -1;
|
||||
while (cursor !== dueDate && Math.abs(distance) < 3660) {
|
||||
cursor = addDays(cursor, direction);
|
||||
distance += direction;
|
||||
}
|
||||
return distance > 0
|
||||
? `tra ${distance} ${distance === 1 ? "giorno" : "giorni"}`
|
||||
: `${Math.abs(distance)} ${distance === -1 ? "giorno" : "giorni"} fa`;
|
||||
}
|
||||
45
src/components/dashboard/YearSelector.tsx
Normal file
45
src/components/dashboard/YearSelector.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { LuCalendarRange, LuChevronLeft, LuChevronRight } from "react-icons/lu";
|
||||
|
||||
export function YearSelector({
|
||||
year,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: {
|
||||
year: number;
|
||||
onChange: (year: number) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`w-full inline-flex items-center justify-between gap-1 rounded-xl border border-sky-400/15 bg-slate-950/25 p-1 shadow-sm shadow-sky-950/30 transition ${
|
||||
disabled ? "opacity-40" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="flex min-w-24 items-center gap-2 px-2 text-center text-sm font-semibold text-white">
|
||||
<LuCalendarRange className="text-sky-300" size={16} />
|
||||
{year}
|
||||
</span>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
aria-label="Anno precedente"
|
||||
className="grid size-9 place-items-center rounded-lg text-slate-300 transition hover:bg-sky-400/10 hover:text-sky-100 disabled:cursor-default"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(year - 1)}
|
||||
type="button"
|
||||
>
|
||||
<LuChevronLeft size={18} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
aria-label="Anno successivo"
|
||||
className="grid size-9 place-items-center rounded-lg text-slate-300 transition hover:bg-sky-400/10 hover:text-sky-100 disabled:cursor-default"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(year + 1)}
|
||||
type="button"
|
||||
>
|
||||
<LuChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
src/components/income/IncomeForm.tsx
Normal file
132
src/components/income/IncomeForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
src/components/income/IncomeList.tsx
Normal file
83
src/components/income/IncomeList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
src/components/income/IncomePageClient.tsx
Normal file
138
src/components/income/IncomePageClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/components/income/IncomePeriodToolbar.tsx
Normal file
26
src/components/income/IncomePeriodToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
src/components/one-time-payments/OneTimePaymentForm.tsx
Normal file
116
src/components/one-time-payments/OneTimePaymentForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
src/components/one-time-payments/OneTimePaymentList.tsx
Normal file
84
src/components/one-time-payments/OneTimePaymentList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
src/components/one-time-payments/OneTimePaymentsPageClient.tsx
Normal file
147
src/components/one-time-payments/OneTimePaymentsPageClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
148
src/components/profile/AccountCard.tsx
Normal file
148
src/components/profile/AccountCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
src/components/profile/AccountEditModal.tsx
Normal file
95
src/components/profile/AccountEditModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
306
src/components/profile/AvatarCropModal.tsx
Normal file
306
src/components/profile/AvatarCropModal.tsx
Normal 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";
|
||||
}
|
||||
45
src/components/profile/ConfirmDangerModal.tsx
Normal file
45
src/components/profile/ConfirmDangerModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/components/profile/DangerZoneCard.tsx
Normal file
34
src/components/profile/DangerZoneCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
src/components/profile/DeleteAccountModal.tsx
Normal file
77
src/components/profile/DeleteAccountModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
src/components/profile/PasswordEditModal.tsx
Normal file
118
src/components/profile/PasswordEditModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/components/profile/ProfileCard.tsx
Normal file
25
src/components/profile/ProfileCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
225
src/components/profile/ProfilePageClient.tsx
Normal file
225
src/components/profile/ProfilePageClient.tsx
Normal 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;
|
||||
}
|
||||
66
src/components/reports/BillsInsights.tsx
Normal file
66
src/components/reports/BillsInsights.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
src/components/reports/ExpenseBreakdown.tsx
Normal file
41
src/components/reports/ExpenseBreakdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/components/reports/MonthSummary.tsx
Normal file
48
src/components/reports/MonthSummary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
src/components/reports/ReportsPageClient.tsx
Normal file
186
src/components/reports/ReportsPageClient.tsx
Normal 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}`;
|
||||
}
|
||||
63
src/components/reports/SubscriptionInsights.tsx
Normal file
63
src/components/reports/SubscriptionInsights.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
20
src/components/shared/AnnualPageToolbar.tsx
Normal file
20
src/components/shared/AnnualPageToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/components/shared/Badge.tsx
Normal file
26
src/components/shared/Badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
220
src/components/shared/DateField.tsx
Normal file
220
src/components/shared/DateField.tsx
Normal 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);
|
||||
});
|
||||
}
|
||||
8
src/components/shared/EmptyState.tsx
Normal file
8
src/components/shared/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
src/components/shared/EntityFormModal.tsx
Normal file
126
src/components/shared/EntityFormModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/components/shared/Field.tsx
Normal file
17
src/components/shared/Field.tsx
Normal 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";
|
||||
54
src/components/shared/Header.tsx
Normal file
54
src/components/shared/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
src/components/shared/ListCardAction.tsx
Normal file
90
src/components/shared/ListCardAction.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
148
src/components/shared/LocalMonthNavigator.tsx
Normal file
148
src/components/shared/LocalMonthNavigator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/components/shared/MonthlyPageToolbar.tsx
Normal file
29
src/components/shared/MonthlyPageToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/components/shared/PageShell.tsx
Normal file
26
src/components/shared/PageShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
src/components/shared/PaymentBadge.tsx
Normal file
20
src/components/shared/PaymentBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
src/components/shared/SectionAddButton.tsx
Normal file
19
src/components/shared/SectionAddButton.tsx
Normal 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"> Aggiungi</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
57
src/components/shared/SectionPeriodTabs.tsx
Normal file
57
src/components/shared/SectionPeriodTabs.tsx
Normal 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
Reference in New Issue
Block a user