diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..320297d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.next +.git +.env* +*.tsbuildinfo diff --git a/.gitignore b/.gitignore index 5ef6a52..a84f41a 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2a98a2f --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index e215bc4..3e72e8a 100644 --- a/README.md +++ b/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 +``` diff --git a/database/init.sql b/database/init.sql new file mode 100644 index 0000000..0239d94 --- /dev/null +++ b/database/init.sql @@ -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 +); diff --git a/database/migrations/001_subscription_lifecycle.sql b/database/migrations/001_subscription_lifecycle.sql new file mode 100644 index 0000000..1fb5f94 --- /dev/null +++ b/database/migrations/001_subscription_lifecycle.sql @@ -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; diff --git a/database/migrations/002_subscription_custom_intervals.sql b/database/migrations/002_subscription_custom_intervals.sql new file mode 100644 index 0000000..c38b6f7 --- /dev/null +++ b/database/migrations/002_subscription_custom_intervals.sql @@ -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; diff --git a/database/migrations/003_subscription_ledger_renewal_dates.sql b/database/migrations/003_subscription_ledger_renewal_dates.sql new file mode 100644 index 0000000..db398f3 --- /dev/null +++ b/database/migrations/003_subscription_ledger_renewal_dates.sql @@ -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; diff --git a/database/migrations/004_income_titles.sql b/database/migrations/004_income_titles.sql new file mode 100644 index 0000000..8afc1ce --- /dev/null +++ b/database/migrations/004_income_titles.sql @@ -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; diff --git a/database/migrations/005_user_avatar.sql b/database/migrations/005_user_avatar.sql new file mode 100644 index 0000000..376fbe4 --- /dev/null +++ b/database/migrations/005_user_avatar.sql @@ -0,0 +1,2 @@ +ALTER TABLE users + ADD COLUMN IF NOT EXISTS avatar_file text; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..591de70 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/next.config.ts b/next.config.ts index e9ffa30..dbbd989 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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; diff --git a/package.json b/package.json index d27aac9..ce18567 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dafec85..26c06ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 581a9d5..72b211f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,5 @@ +allowBuilds: + sharp: false ignoredBuiltDependencies: - sharp - unrs-resolver diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..e492596 Binary files /dev/null and b/public/logo.png differ diff --git a/src/app/(protected)/bills/page.tsx b/src/app/(protected)/bills/page.tsx new file mode 100644 index 0000000..86a1428 --- /dev/null +++ b/src/app/(protected)/bills/page.tsx @@ -0,0 +1,5 @@ +import { BillsPageClient } from "@/components/bills/BillsPageClient"; + +export default function BillsPage() { + return ; +} diff --git a/src/app/(protected)/dashboard/page.tsx b/src/app/(protected)/dashboard/page.tsx new file mode 100644 index 0000000..ca0aafe --- /dev/null +++ b/src/app/(protected)/dashboard/page.tsx @@ -0,0 +1,5 @@ +import { DashboardPageClient } from "@/components/dashboard/DashboardPageClient"; + +export default function DashboardPage() { + return ; +} diff --git a/src/app/(protected)/income/page.tsx b/src/app/(protected)/income/page.tsx new file mode 100644 index 0000000..8a89003 --- /dev/null +++ b/src/app/(protected)/income/page.tsx @@ -0,0 +1,5 @@ +import { IncomePageClient } from "@/components/income/IncomePageClient"; + +export default function IncomePage() { + return ; +} diff --git a/src/app/(protected)/layout.tsx b/src/app/(protected)/layout.tsx new file mode 100644 index 0000000..eac5c0f --- /dev/null +++ b/src/app/(protected)/layout.tsx @@ -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 {children}; +} diff --git a/src/app/(protected)/one-time-payments/page.tsx b/src/app/(protected)/one-time-payments/page.tsx new file mode 100644 index 0000000..ced90de --- /dev/null +++ b/src/app/(protected)/one-time-payments/page.tsx @@ -0,0 +1,5 @@ +import { OneTimePaymentsPageClient } from "@/components/one-time-payments/OneTimePaymentsPageClient"; + +export default function OneTimePaymentsPage() { + return ; +} diff --git a/src/app/(protected)/profile/page.tsx b/src/app/(protected)/profile/page.tsx new file mode 100644 index 0000000..0684479 --- /dev/null +++ b/src/app/(protected)/profile/page.tsx @@ -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 ; +} diff --git a/src/app/(protected)/reports/page.tsx b/src/app/(protected)/reports/page.tsx new file mode 100644 index 0000000..131e484 --- /dev/null +++ b/src/app/(protected)/reports/page.tsx @@ -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 ; +} + +function normalizeYear(value?: string) { + const year = Number(value); + return Number.isInteger(year) && year >= 2000 && year <= 2100 + ? year + : new Date().getFullYear(); +} diff --git a/src/app/(protected)/subscriptions/page.tsx b/src/app/(protected)/subscriptions/page.tsx new file mode 100644 index 0000000..be7d229 --- /dev/null +++ b/src/app/(protected)/subscriptions/page.tsx @@ -0,0 +1,5 @@ +import { SubscriptionsPageClient } from "@/components/subscriptions/SubscriptionsPageClient"; + +export default function SubscriptionsPage() { + return ; +} diff --git a/src/app/(public)/login/page.tsx b/src/app/(public)/login/page.tsx new file mode 100644 index 0000000..939f35d --- /dev/null +++ b/src/app/(public)/login/page.tsx @@ -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 ; +} diff --git a/src/app/(public)/setup/page.tsx b/src/app/(public)/setup/page.tsx new file mode 100644 index 0000000..3c050ec --- /dev/null +++ b/src/app/(public)/setup/page.tsx @@ -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 ; +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..110e235 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -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 }); + } +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..f7c7512 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,6 @@ +import { deleteCurrentSession } from "@/lib/auth"; + +export async function POST() { + await deleteCurrentSession(); + return Response.json({ ok: true }); +} diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts new file mode 100644 index 0000000..6a3f62d --- /dev/null +++ b/src/app/api/auth/session/route.ts @@ -0,0 +1,6 @@ +import { getAuthenticatedUser } from "@/lib/auth"; + +export async function GET() { + const user = await getAuthenticatedUser(); + return Response.json({ authenticated: Boolean(user), user }); +} diff --git a/src/app/api/auth/setup/route.ts b/src/app/api/auth/setup/route.ts new file mode 100644 index 0000000..9977091 --- /dev/null +++ b/src/app/api/auth/setup/route.ts @@ -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; + 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 }); + } +} diff --git a/src/app/api/bills/[id]/route.ts b/src/app/api/bills/[id]/route.ts new file mode 100644 index 0000000..24aa0bf --- /dev/null +++ b/src/app/api/bills/[id]/route.ts @@ -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( + collections.bills, + id, + auth.user.id, + ); + let bill = await updateDocument(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(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( + 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 }); +} diff --git a/src/app/api/bills/route.ts b/src/app/api/bills/route.ts new file mode 100644 index 0000000..0ff76f0 --- /dev/null +++ b/src/app/api/bills/route.ts @@ -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(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(collections.bills, { + ...parsed.data, + userId: auth.user.id, + ...nowFields(), + }); + const ledger = await syncBillLedger(bill); + if (ledger && bill.linkedPaymentId !== ledger.$id) { + bill = await updateDocument(collections.bills, bill.$id, { + linkedPaymentId: ledger.$id, + updatedAt: new Date().toISOString(), + }); + } + return Response.json(bill, { status: 201 }); +} diff --git a/src/app/api/income/[id]/route.ts b/src/app/api/income/[id]/route.ts new file mode 100644 index 0000000..b3aeb7f --- /dev/null +++ b/src/app/api/income/[id]/route.ts @@ -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(collections.income, id, auth.user.id); + const document = await updateDocument(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(collections.income, id, auth.user.id); + await deleteDocument(collections.income, id); + return Response.json({ ok: true }); +} diff --git a/src/app/api/income/route.ts b/src/app/api/income/route.ts new file mode 100644 index 0000000..5c2b220 --- /dev/null +++ b/src/app/api/income/route.ts @@ -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(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(collections.income, { + ...parsed.data, + userId: auth.user.id, + ...nowFields(), + }); + return Response.json(document, { status: 201 }); +} diff --git a/src/app/api/one-time-payments/[id]/route.ts b/src/app/api/one-time-payments/[id]/route.ts new file mode 100644 index 0000000..8ddb063 --- /dev/null +++ b/src/app/api/one-time-payments/[id]/route.ts @@ -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( + collections.oneTimePayments, + id, + auth.user.id, + ); + let payment = await updateDocument( + collections.oneTimePayments, + id, + { + ...parsed.data, + linkedPaymentId: current.linkedPaymentId, + ...updatedField(), + }, + ); + const ledger = await syncOneTimeLedger(payment); + payment = await updateDocument( + 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( + 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 }); +} diff --git a/src/app/api/one-time-payments/route.ts b/src/app/api/one-time-payments/route.ts new file mode 100644 index 0000000..38e66de --- /dev/null +++ b/src/app/api/one-time-payments/route.ts @@ -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( + 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( + collections.oneTimePayments, + { + ...parsed.data, + userId: auth.user.id, + ...nowFields(), + }, + ); + const ledger = await syncOneTimeLedger(payment); + payment = await updateDocument( + collections.oneTimePayments, + payment.$id, + { + linkedPaymentId: ledger.$id, + updatedAt: new Date().toISOString(), + }, + ); + return Response.json(payment, { status: 201 }); +} diff --git a/src/app/api/payment-titles/route.ts b/src/app/api/payment-titles/route.ts new file mode 100644 index 0000000..df29420 --- /dev/null +++ b/src/app/api/payment-titles/route.ts @@ -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(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( + 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( + collections.paymentTitles, + { + ...parsed.data, + userId: auth.user.id, + ...nowFields(), + }, + ); + return Response.json(document, { status: 201 }); +} diff --git a/src/app/api/payments-ledger/route.ts b/src/app/api/payments-ledger/route.ts new file mode 100644 index 0000000..50081de --- /dev/null +++ b/src/app/api/payments-ledger/route.ts @@ -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( + collections.paymentsLedger, + [ + query.equal("userId", auth.user.id), + query.orderDesc("date"), + query.limit(5000), + ], + ); + return Response.json(result.documents); +} diff --git a/src/app/api/profile/account/route.ts b/src/app/api/profile/account/route.ts new file mode 100644 index 0000000..1c54483 --- /dev/null +++ b/src/app/api/profile/account/route.ts @@ -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()` + 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 + ); +} diff --git a/src/app/api/profile/avatar/route.ts b/src/app/api/profile/avatar/route.ts new file mode 100644 index 0000000..1f6fbe2 --- /dev/null +++ b/src/app/api/profile/avatar/route.ts @@ -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()` + 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; +} diff --git a/src/app/api/profile/data/route.ts b/src/app/api/profile/data/route.ts new file mode 100644 index 0000000..d7b4b1b --- /dev/null +++ b/src/app/api/profile/data/route.ts @@ -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); + } + } +} diff --git a/src/app/api/profile/settings/route.ts b/src/app/api/profile/settings/route.ts new file mode 100644 index 0000000..4284764 --- /dev/null +++ b/src/app/api/profile/settings/route.ts @@ -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)); +} diff --git a/src/app/api/subscription-payments/[id]/route.ts b/src/app/api/subscription-payments/[id]/route.ts new file mode 100644 index 0000000..c11e62a --- /dev/null +++ b/src/app/api/subscription-payments/[id]/route.ts @@ -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; + const status = body.status === "paid" ? "paid" : "unpaid"; + const { collections } = databaseConfig(); + const current = await getOwnedDocument( + collections.subscriptionPayments, + id, + auth.user.id, + ); + const subscription = await getOwnedDocument( + collections.subscriptions, + current.subscriptionId, + auth.user.id, + ); + let payment = await updateDocument( + 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( + 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( + 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 }); +} diff --git a/src/app/api/subscription-payments/route.ts b/src/app/api/subscription-payments/route.ts new file mode 100644 index 0000000..d3626d6 --- /dev/null +++ b/src/app/api/subscription-payments/route.ts @@ -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) { + 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( + 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( + 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; + const parsed = parsePayment(body); + const { collections } = databaseConfig(); + const subscription = await getOwnedDocument( + 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( + 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( + collections.subscriptionPayments, + current.$id, + { + ...data, + linkedPaymentId: current.linkedPaymentId, + ...updatedField(), + }, + ); + } + + function createPayment(data: typeof parsed) { + return createDocument( + 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 }); + } +} diff --git a/src/app/api/subscriptions/[id]/route.ts b/src/app/api/subscriptions/[id]/route.ts new file mode 100644 index 0000000..4845b4f --- /dev/null +++ b/src/app/api/subscriptions/[id]/route.ts @@ -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( + 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( + 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( + collections.subscriptions, + id, + auth.user.id, + ); + const payments = await listDocuments( + 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 }); +} diff --git a/src/app/api/subscriptions/route.ts b/src/app/api/subscriptions/route.ts new file mode 100644 index 0000000..6258955 --- /dev/null +++ b/src/app/api/subscriptions/route.ts @@ -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(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( + collections.subscriptions, + { + ...parsed.data, + userId: auth.user.id, + ...nowFields(), + }, + ); + return Response.json(document, { status: 201 }); +} diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..4a7ff6e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; + } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 976eb90..86a7e0b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( - {children} + {children} ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 3f36f7c..938978c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
- -
-
- ); +export default async function Home() { + const user = await getAuthenticatedUser(); + if (user) redirect("/dashboard"); + redirect((await isInstanceConfigured()) ? "/login" : "/setup"); } diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx new file mode 100644 index 0000000..3a9e894 --- /dev/null +++ b/src/components/AppShell.tsx @@ -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
{children}
; +} diff --git a/src/components/AuthForm.tsx b/src/components/AuthForm.tsx new file mode 100644 index 0000000..bfd74a3 --- /dev/null +++ b/src/components/AuthForm.tsx @@ -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 ( +
+
+
+ MyMoney +

MyMoney

+
+ +

+ Accedi a MyMoney - l'app per tracciare le tue finanze. +

+
+ setEmail(event.target.value)} + /> + setPassword(event.target.value)} + /> + {error ?

{error}

: null} + +
+
+
+ ); +} diff --git a/src/components/SetupForm.tsx b/src/components/SetupForm.tsx new file mode 100644 index 0000000..4ab8ac7 --- /dev/null +++ b/src/components/SetupForm.tsx @@ -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 ( +
+
+
+ MyMoney +

MyMoney

+
+ +

+ Configura il primo account per iniziare a usare la tua istanza. +

+
+ setName(event.target.value)} + /> + setEmail(event.target.value)} + /> + setPassword(event.target.value)} + /> + {error ?

{error}

: null} + +
+
+
+ ); +} diff --git a/src/components/bills/BillForm.tsx b/src/components/bills/BillForm.tsx new file mode 100644 index 0000000..933ba39 --- /dev/null +++ b/src/components/bills/BillForm.tsx @@ -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; + onCreateTitle?: (name: string) => Promise; + 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 ( +
{ + 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", + ); + } + }} + > + + {review ? ( + + ) : ( +
+ + + setAmount(event.target.value)} + /> + + + setAmount(event.target.value)} + /> + + setAmount(event.target.value)} + /> + + +
+ )} + {error ?

{error}

: null} + setReview(false)} + onCancel={onCancel} + review={review} + saving={saving} + /> + + ); +} diff --git a/src/components/one-time-payments/OneTimePaymentList.tsx b/src/components/one-time-payments/OneTimePaymentList.tsx new file mode 100644 index 0000000..033b791 --- /dev/null +++ b/src/components/one-time-payments/OneTimePaymentList.tsx @@ -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; +}) { + const [busy, setBusy] = useState(""); + if (!payments.length) return ; + return ( +
+ {payments.map((payment) => ( +
+
+
+
+

+ {payment.title} +

+ + Manuale + +
+

+ {euroFormatter.format(payment.amount)} +

+
+
+ onEdit(payment)} + /> + { + setBusy(payment.$id); + try { + await onDelete(payment); + } finally { + setBusy(""); + } + }} + /> +
+
+
+

+ + Pagamento singolo +

+

+ + Registrato il {formatItalianDate(payment.date)} +

+
+
+ ))} +
+ ); +} diff --git a/src/components/one-time-payments/OneTimePaymentsPageClient.tsx b/src/components/one-time-payments/OneTimePaymentsPageClient.tsx new file mode 100644 index 0000000..4434ef8 --- /dev/null +++ b/src/components/one-time-payments/OneTimePaymentsPageClient.tsx @@ -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(); + 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("/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( + 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 ( + { + setEditing(undefined); + setModalOpen(true); + }} + /> + } + > +
+ + { + 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"); + }} + /> +
+ {modalOpen ? ( + { + setModalOpen(false); + setEditing(undefined); + }} + open + saving={saving} + title={editing ? "Modifica Pagamento" : "Nuovo Pagamento"} + > + { + setModalOpen(false); + setEditing(undefined); + }} + onCreateTitle={createTitle} + onSubmit={save} + saving={saving} + /> + + ) : null} +
+ ); +} diff --git a/src/components/profile/AccountCard.tsx b/src/components/profile/AccountCard.tsx new file mode 100644 index 0000000..3e493c1 --- /dev/null +++ b/src/components/profile/AccountCard.tsx @@ -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; + onEditAccount: () => void; + onEditAvatar: () => void; + onEditPassword: () => void; + onSelectAvatar: (file: File) => void; + savingAvatar: boolean; + user: UserSession; +}) { + const inputRef = useRef(null); + + return ( + +
+
+
+ +
+

+ {user.name} +

+

+ {user.email} +

+

+ JPG, PNG o WebP fino a 10 MB +

+
+
+
+ { + const file = event.target.files?.[0]; + if (!file) return; + onSelectAvatar(file); + event.target.value = ""; + }} + ref={inputRef} + type="file" + /> + + user.avatarUrl ? onEditAvatar() : inputRef.current?.click() + } + /> + {user.avatarUrl ? ( + + ) : null} +
+
+
+ + } + icon={LuUserRound} + label="Nome" + value={user.name} + /> + + + } + icon={LuKeyRound} + label="Password" + value="••••••••" + /> +
+
+
+ ); +} + +function InfoRow({ + action, + icon: Icon, + label, + value, +}: { + action?: React.ReactNode; + icon: IconType; + label: string; + value: string; +}) { + return ( +
+ +
+

{label}

+

{value}

+
+ {action} +
+ ); +} diff --git a/src/components/profile/AccountEditModal.tsx b/src/components/profile/AccountEditModal.tsx new file mode 100644 index 0000000..9e44d4d --- /dev/null +++ b/src/components/profile/AccountEditModal.tsx @@ -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; + 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) { + event.preventDefault(); + if (!review) { + setReview(true); + return; + } + if (await onSubmit({ name, email })) onClose(); + } + + return ( + + +
+ {review ? ( + + ) : ( +
+ + setName(event.target.value)} + required + value={name} + /> + + + setEmail(event.target.value)} + required + type="email" + value={email} + /> + +
+ )} + setReview(false)} + onCancel={onClose} + review={review} + saving={saving} + /> + +
+ ); +} diff --git a/src/components/profile/AvatarCropModal.tsx b/src/components/profile/AvatarCropModal.tsx new file mode 100644 index 0000000..fb5f8ec --- /dev/null +++ b/src/components/profile/AvatarCropModal.tsx @@ -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; + onSelectFile: (file: File) => void; + saving: boolean; +}) { + const fileInputRef = useRef(null); + const imageRef = useRef(null); + const previewRef = useRef(null); + const dragRef = useRef< + { pointerId: number; x: number; y: number } | undefined + >(undefined); + const [source, setSource] = useState(""); + const [imageSize, setImageSize] = useState(); + 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 ( + +

+ L'immagine parte interamente visibile. Trascinala o aumenta lo zoom solo + se vuoi regolare l'inquadratura. +

+
+
{ + 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 ? ( + Anteprima ritaglio avatar { + 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} +
+
+
+
+ + Zoom +
+ { + const nextZoom = Number(event.target.value); + setZoom(nextZoom); + setPosition((current) => + clampPosition(current, imageSize, previewSize, nextZoom), + ); + }} + step="0.01" + type="range" + value={zoom} + /> +
+ { + const nextFile = event.target.files?.[0]; + if (nextFile) onSelectFile(nextFile); + event.target.value = ""; + }} + ref={fileInputRef} + type="file" + /> +
+ + +
+
+
+
+ + +
+
+ ); +} + +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((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"; +} diff --git a/src/components/profile/ConfirmDangerModal.tsx b/src/components/profile/ConfirmDangerModal.tsx new file mode 100644 index 0000000..dba4149 --- /dev/null +++ b/src/components/profile/ConfirmDangerModal.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { EntityFormModal } from "@/components/shared/EntityFormModal"; + +export function ConfirmDangerModal({ + saving, + onClose, + onConfirm, +}: { + saving: boolean; + onClose: () => void; + onConfirm: () => Promise; +}) { + return ( + +

+ Questa azione elimina definitivamente tutti i tuoi dati finanziari. I + dati di accesso rimangono salvati. +

+
+ + +
+
+ ); +} diff --git a/src/components/profile/DangerZoneCard.tsx b/src/components/profile/DangerZoneCard.tsx new file mode 100644 index 0000000..80c6832 --- /dev/null +++ b/src/components/profile/DangerZoneCard.tsx @@ -0,0 +1,34 @@ +import { ProfileCard } from "@/components/profile/ProfileCard"; + +export function DangerZoneCard({ + onDeleteAccount, + onDeleteAll, +}: { + onDeleteAccount: () => void; + onDeleteAll: () => void; +}) { + return ( + +
+ + +
+
+ ); +} diff --git a/src/components/profile/DeleteAccountModal.tsx b/src/components/profile/DeleteAccountModal.tsx new file mode 100644 index 0000000..79e659f --- /dev/null +++ b/src/components/profile/DeleteAccountModal.tsx @@ -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; + open: boolean; + saving: boolean; +}) { + const [currentPassword, setCurrentPassword] = useState(""); + + useEffect(() => { + if (open) setCurrentPassword(""); + }, [open]); + + return ( + +

+ Verranno eliminati definitivamente account, sessioni, avatar e tutti i + dati finanziari. +

+

+ Questa operazione non può essere annullata. +

+
{ + event.preventDefault(); + if (await onConfirm(currentPassword)) onClose(); + }} + > + + setCurrentPassword(event.target.value)} + required + type="password" + value={currentPassword} + /> + +
+ + +
+
+
+ ); +} diff --git a/src/components/profile/PasswordEditModal.tsx b/src/components/profile/PasswordEditModal.tsx new file mode 100644 index 0000000..74623df --- /dev/null +++ b/src/components/profile/PasswordEditModal.tsx @@ -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; + 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) { + event.preventDefault(); + if (!review) { + setReview(true); + return; + } + if (await onSubmit({ currentPassword, newPassword, confirmPassword })) { + onClose(); + } + } + + return ( + + +
+ {review ? ( + + ) : ( +
+ + setCurrentPassword(event.target.value)} + required + type="password" + value={currentPassword} + /> + +
+ + setNewPassword(event.target.value)} + required + type="password" + value={newPassword} + /> + + + setConfirmPassword(event.target.value)} + required + type="password" + value={confirmPassword} + /> + +
+
+ )} + setReview(false)} + onCancel={onClose} + review={review} + saving={saving} + /> + +
+ ); +} diff --git a/src/components/profile/ProfileCard.tsx b/src/components/profile/ProfileCard.tsx new file mode 100644 index 0000000..0f5e3f9 --- /dev/null +++ b/src/components/profile/ProfileCard.tsx @@ -0,0 +1,25 @@ +export function ProfileCard({ + title, + description, + children, + className = "", + contentClassName = "", +}: { + title: string; + description?: string; + children: React.ReactNode; + className?: string; + contentClassName?: string; +}) { + return ( +
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
{children}
+
+ ); +} diff --git a/src/components/profile/ProfilePageClient.tsx b/src/components/profile/ProfilePageClient.tsx new file mode 100644 index 0000000..6c40243 --- /dev/null +++ b/src/components/profile/ProfilePageClient.tsx @@ -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(); + + async function saveAccount(payload: { name: string; email: string }) { + setSavingAccount(true); + try { + const saved = await apiSave( + "/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 ( + +
+ setEditingAccount(true)} + onEditAvatar={editAvatar} + onEditPassword={() => setEditingPassword(true)} + onSelectAvatar={setSelectedAvatar} + savingAvatar={savingAvatar} + user={account} + /> + setConfirmDeleteAccount(true)} + onDeleteAll={() => setConfirmDelete(true)} + /> +
+ setEditingAccount(false)} + onSubmit={saveAccount} + open={editingAccount} + saving={savingAccount} + user={account} + /> + setSelectedAvatar(undefined)} + onConfirm={async (file) => { + if (await uploadAvatar(file)) setSelectedAvatar(undefined); + }} + onSelectFile={setSelectedAvatar} + saving={savingAvatar} + /> + setEditingPassword(false)} + onSubmit={savePassword} + open={editingPassword} + saving={savingPassword} + /> + {confirmDelete ? ( + setConfirmDelete(false)} + onConfirm={deleteFinancialData} + saving={deletingData} + /> + ) : null} + setConfirmDeleteAccount(false)} + onConfirm={deleteAccount} + open={confirmDeleteAccount} + saving={deletingAccount} + /> +
+ ); +} + +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; +} diff --git a/src/components/reports/BillsInsights.tsx b/src/components/reports/BillsInsights.tsx new file mode 100644 index 0000000..545fecf --- /dev/null +++ b/src/components/reports/BillsInsights.tsx @@ -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; + +export function BillsInsights({ + insights, + periodLabel, +}: { + insights: Insights; + periodLabel: string; +}) { + const biggest = insights.topBills[0]; + return ( +
+

Analisi bollette

+

+ Una lettura rapida delle bollette per {periodLabel}. +

+ {biggest ? ( +
+ + + +
+ ) : ( +
+ +
+ )} +
+ ); +} + +function Insight({ + label, + text, + value, +}: { + label: string; + text?: string; + value: string; +}) { + return ( +
+

{label}

+

{value}

+ {text ?

{text}

: null} +
+ ); +} diff --git a/src/components/reports/ExpenseBreakdown.tsx b/src/components/reports/ExpenseBreakdown.tsx new file mode 100644 index 0000000..26edc3c --- /dev/null +++ b/src/components/reports/ExpenseBreakdown.tsx @@ -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 ( +
+

Dettaglio spese

+

+ Quanto incidono le diverse tipologie sulla spesa reale. +

+
+ {items.map((item) => ( +
+
+ +

{item.label}

+
+

+ {euroFormatter.format(item.amount)} +

+

+ {item.percentage.toFixed(1)}% delle spese per {periodLabel} +

+
+ ))} +
+
+ ); +} diff --git a/src/components/reports/MonthSummary.tsx b/src/components/reports/MonthSummary.tsx new file mode 100644 index 0000000..b787f85 --- /dev/null +++ b/src/components/reports/MonthSummary.tsx @@ -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 ( +
+
+

+ Periodo selezionato +

+

{label}

+
+
+ {cards.map(([cardLabel, value]) => ( +
+

{cardLabel}

+

+ {value} +

+
+ ))} +
+
+ ); +} diff --git a/src/components/reports/ReportsPageClient.tsx b/src/components/reports/ReportsPageClient.tsx new file mode 100644 index 0000000..fe9e781 --- /dev/null +++ b/src/components/reports/ReportsPageClient.tsx @@ -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 ( + + {loading ? ( +

Caricamento dati...

+ ) : null} + {error ?

{error}

: null} +
+ + +
+ + +
+
+ + +
+
+
+ ); +} + +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}`; +} diff --git a/src/components/reports/SubscriptionInsights.tsx b/src/components/reports/SubscriptionInsights.tsx new file mode 100644 index 0000000..674c249 --- /dev/null +++ b/src/components/reports/SubscriptionInsights.tsx @@ -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[number]; + +export function SubscriptionInsights({ + insights, + periodLabel, +}: { + insights: Insight[]; + periodLabel: string; +}) { + const maximum = Math.max(1, ...insights.map((item) => item.periodCost)); + return ( +
+

Impatto abbonamenti

+

+ Abbonamenti attivi ordinati per costo stimato nel periodo selezionato. +

+
+ {insights.length ? ( + insights.map(({ subscription, periodCost }) => ( +
+
+ + {subscription.title} + + + {euroFormatter.format(periodCost)} + +
+

+ {euroFormatter.format(subscription.amount)} -{" "} + {cycleLabel(subscription.billingCycle)} +

+
+
+
+
+ )) + ) : ( + + )} +
+

{periodLabel}

+
+ ); +} + +function cycleLabel(cycle: Insight["subscription"]["billingCycle"]) { + return ( + { + monthly: "al mese", + weekly: "a settimana", + yearly: "all'anno", + custom: "personalizzato", + }[cycle] ?? cycle + ); +} diff --git a/src/components/shared/AnnualPageToolbar.tsx b/src/components/shared/AnnualPageToolbar.tsx new file mode 100644 index 0000000..b14f3b0 --- /dev/null +++ b/src/components/shared/AnnualPageToolbar.tsx @@ -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 ( +
+ + {children} +
+ ); +} diff --git a/src/components/shared/Badge.tsx b/src/components/shared/Badge.tsx new file mode 100644 index 0000000..e9b24a5 --- /dev/null +++ b/src/components/shared/Badge.tsx @@ -0,0 +1,26 @@ +type BadgeTone = "green" | "yellow" | "red" | "blue" | "violet" | "neutral"; + +const tones: Record = { + 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 ( + + {children} + + ); +} diff --git a/src/components/shared/DateField.tsx b/src/components/shared/DateField.tsx new file mode 100644 index 0000000..b296868 --- /dev/null +++ b/src/components/shared/DateField.tsx @@ -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(null); + const buttonRef = useRef(null); + const calendarRef = useRef(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 ( +
+ +
+ commit(display)} + onChange={(event) => { + setDisplay(event.target.value); + setError(""); + }} + /> + +
+ {error ? {error} : null} +
+ {open + ? createPortal( +
+
+ + + {activeMonth} + + +
+
+ {[ + ["mon", "L"], + ["tue", "M"], + ["wed", "M"], + ["thu", "G"], + ["fri", "V"], + ["sat", "S"], + ["sun", "D"], + ].map(([key, day]) => ( + {day} + ))} +
+
+ {days.map((day) => { + const active = day === value; + const inMonth = day.startsWith(activeMonth); + return ( + + ); + })} +
+
, + document.body, + ) + : null} +
+ ); +} + +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); + }); +} diff --git a/src/components/shared/EmptyState.tsx b/src/components/shared/EmptyState.tsx new file mode 100644 index 0000000..6b97658 --- /dev/null +++ b/src/components/shared/EmptyState.tsx @@ -0,0 +1,8 @@ +export function EmptyState({ title, text }: { title: string; text?: string }) { + return ( +
+

{title}

+ {text ?

{text}

: null} +
+ ); +} diff --git a/src/components/shared/EntityFormModal.tsx b/src/components/shared/EntityFormModal.tsx new file mode 100644 index 0000000..7652911 --- /dev/null +++ b/src/components/shared/EntityFormModal.tsx @@ -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(null); + const triggerRef = useRef(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(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(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 ( +
{ + if (event.target === event.currentTarget && !saving) onClose(); + }} + role="dialog" + > +
+
+

+ {title} +

+ +
+ {children} +
+
+ ); +} diff --git a/src/components/shared/Field.tsx b/src/components/shared/Field.tsx new file mode 100644 index 0000000..276510e --- /dev/null +++ b/src/components/shared/Field.tsx @@ -0,0 +1,17 @@ +export function Field({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+ {label} + {children} +
+ ); +} + +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"; diff --git a/src/components/shared/Header.tsx b/src/components/shared/Header.tsx new file mode 100644 index 0000000..e37f3eb --- /dev/null +++ b/src/components/shared/Header.tsx @@ -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(null); + + async function logout() { + await fetch("/api/auth/logout", { method: "POST" }); + notify("Disconnessione effettuata"); + router.push("/login"); + router.refresh(); + } + + return ( +
+ + setMobileSidebarOpen(false)} + onLogout={logout} + open={mobileSidebarOpen} + pathname={pathname} + triggerRef={sidebarTriggerRef} + user={user} + /> +
+
+ setMobileSidebarOpen(true)} + open={mobileSidebarOpen} + ref={sidebarTriggerRef} + /> +
+
{children}
+
+ +
+ ); +} diff --git a/src/components/shared/ListCardAction.tsx b/src/components/shared/ListCardAction.tsx new file mode 100644 index 0000000..ae3602e --- /dev/null +++ b/src/components/shared/ListCardAction.tsx @@ -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; +}) { + const [confirmOpen, setConfirmOpen] = useState(false); + const [confirming, setConfirming] = useState(false); + + async function runAction() { + if (danger && confirmDescription) { + setConfirmOpen(true); + return; + } + await onClick(); + } + + return ( + <> + + {confirmOpen ? ( + setConfirmOpen(false)} + open + saving={confirming} + title="Conferma eliminazione" + > +

{confirmDescription}

+

+ Questa operazione non può essere annullata. +

+
+ + +
+
+ ) : null} + + ); +} diff --git a/src/components/shared/LocalMonthNavigator.tsx b/src/components/shared/LocalMonthNavigator.tsx new file mode 100644 index 0000000..0af8140 --- /dev/null +++ b/src/components/shared/LocalMonthNavigator.tsx @@ -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; + 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 ( +
+
+ {allowYearSelection ? ( + + ) : ( + + + {year} + + )} +
+ + + +
+
+
+ {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 ( + + ); + })} +
+
+ ); +} diff --git a/src/components/shared/MonthlyPageToolbar.tsx b/src/components/shared/MonthlyPageToolbar.tsx new file mode 100644 index 0000000..2ed7583 --- /dev/null +++ b/src/components/shared/MonthlyPageToolbar.tsx @@ -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; + allowYearSelection?: boolean; + children?: React.ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} diff --git a/src/components/shared/PageShell.tsx b/src/components/shared/PageShell.tsx new file mode 100644 index 0000000..98af09e --- /dev/null +++ b/src/components/shared/PageShell.tsx @@ -0,0 +1,26 @@ +export function PageShell({ + title, + description, + actions, + children, +}: { + title: string; + description?: string; + actions?: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+
+
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
+ {actions} +
+ {children} +
+ ); +} diff --git a/src/components/shared/PaymentBadge.tsx b/src/components/shared/PaymentBadge.tsx new file mode 100644 index 0000000..3de0f51 --- /dev/null +++ b/src/components/shared/PaymentBadge.tsx @@ -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 ( + + {children} + + ); +} diff --git a/src/components/shared/SectionAddButton.tsx b/src/components/shared/SectionAddButton.tsx new file mode 100644 index 0000000..3b669af --- /dev/null +++ b/src/components/shared/SectionAddButton.tsx @@ -0,0 +1,19 @@ +export function SectionAddButton({ + label, + onClick, +}: { + label: string; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/src/components/shared/SectionPeriodTabs.tsx b/src/components/shared/SectionPeriodTabs.tsx new file mode 100644 index 0000000..9eb5da1 --- /dev/null +++ b/src/components/shared/SectionPeriodTabs.tsx @@ -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 ( +
+ } + label="Mese" + onClick={() => onChange("month")} + /> + } + label="Anno" + onClick={() => onChange("year")} + /> +
+ ); +} + +function Tab({ + active, + icon, + label, + onClick, +}: { + active: boolean; + icon: React.ReactNode; + label: string; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/src/components/shared/Select.tsx b/src/components/shared/Select.tsx new file mode 100644 index 0000000..7efc4e7 --- /dev/null +++ b/src/components/shared/Select.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { LuCheck, LuChevronDown } from "react-icons/lu"; +import { Field, inputClass } from "@/components/shared/Field"; + +type Option = { value: string; label: string }; + +export function Select({ + label, + value, + onChange, + options, +}: { + label: string; + value: string; + onChange: (value: string) => void; + options: Option[]; +}) { + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(() => + selectedIndex(options, value), + ); + const wrapperRef = useRef(null); + const buttonRef = useRef(null); + const dropdownRef = useRef(null); + const [dropdownPosition, setDropdownPosition] = useState({ + left: 0, + top: 0, + width: 0, + }); + const selected = options.find((option) => option.value === value); + + useEffect(() => { + setActiveIndex(selectedIndex(options, value)); + }, [options, value]); + + useEffect(() => { + function close(event: MouseEvent) { + const target = event.target as Node; + if ( + !wrapperRef.current?.contains(target) && + !dropdownRef.current?.contains(target) + ) { + setOpen(false); + } + } + document.addEventListener("mousedown", close); + return () => document.removeEventListener("mousedown", close); + }, []); + + useEffect(() => { + if (!open) return; + function positionDropdown() { + const rect = buttonRef.current?.getBoundingClientRect(); + if (!rect) return; + const gap = 8; + const estimatedHeight = Math.min(options.length * 40 + 8, 320); + const fitsBelow = + rect.bottom + gap + estimatedHeight <= window.innerHeight; + setDropdownPosition({ + left: Math.max( + 8, + Math.min(rect.left, window.innerWidth - rect.width - 8), + ), + top: fitsBelow + ? rect.bottom + gap + : Math.max(8, rect.top - estimatedHeight - gap), + width: rect.width, + }); + } + positionDropdown(); + window.addEventListener("resize", positionDropdown); + window.addEventListener("scroll", positionDropdown, true); + return () => { + window.removeEventListener("resize", positionDropdown); + window.removeEventListener("scroll", positionDropdown, true); + }; + }, [open, options.length]); + + function choose(option: Option) { + onChange(option.value); + setOpen(false); + buttonRef.current?.focus(); + } + + function moveActive(direction: number) { + setActiveIndex((current) => + Math.min(Math.max(current + direction, 0), options.length - 1), + ); + } + + return ( +
+ + + + {open + ? createPortal( +
+ {options.map((option, index) => { + const active = index === activeIndex; + const checked = option.value === value; + return ( + + ); + })} +
, + document.body, + ) + : null} +
+ ); +} + +function selectedIndex(options: Option[], value: string) { + return Math.max( + 0, + options.findIndex((option) => option.value === value), + ); +} diff --git a/src/components/shared/SimpleFormSteps.tsx b/src/components/shared/SimpleFormSteps.tsx new file mode 100644 index 0000000..7de4340 --- /dev/null +++ b/src/components/shared/SimpleFormSteps.tsx @@ -0,0 +1,107 @@ +import { LuArrowLeft, LuArrowRight, LuCircleCheck } from "react-icons/lu"; + +export function SimpleFormStepIndicator({ review }: { review: boolean }) { + return ( +
+ {["Dati", "Conferma"].map((label, index) => { + const active = review ? index === 1 : index === 0; + const completed = review && index === 0; + return ( +
+ {index + 1}. {label} +
+ ); + })} +
+ ); +} + +export function SimpleFormActions({ + onBack, + onCancel, + review, + saving, +}: { + onBack: () => void; + onCancel?: () => void; + review: boolean; + saving: boolean; +}) { + return ( +
+
+ {review ? ( + + ) : onCancel ? ( + + ) : null} +
+ +
+ ); +} + +export function SimpleFormReview({ + rows, + title, +}: { + rows: { label: string; value: string }[]; + title: string; +}) { + return ( +
+

+ Riepilogo +

+

{title}

+
+ {rows.map((row) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+
+ ); +} diff --git a/src/components/shared/TitleCombobox.tsx b/src/components/shared/TitleCombobox.tsx new file mode 100644 index 0000000..b20ee45 --- /dev/null +++ b/src/components/shared/TitleCombobox.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { Field, inputClass } from "@/components/shared/Field"; +import { normalizeTitleName } from "@/lib/validation/paymentTitles"; +import type { PaymentTitleType } from "@/types/finance"; + +type Title = { $id: string; name: string; paymentType: PaymentTitleType }; + +export function TitleCombobox({ + value, + onChange, + onCreate, + titles, + type, +}: { + value: string; + onChange: (value: string) => void; + onCreate?: (name: string) => Promise; + titles: Title[]; + type: PaymentTitleType; +}) { + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + const wrapperRef = useRef(null); + const inputRef = useRef(null); + const dropdownRef = useRef(null); + const [dropdownPosition, setDropdownPosition] = useState({ + left: 0, + top: 0, + width: 0, + }); + const normalizedValue = normalizeTitleName(value); + const filtered = useMemo( + () => + titles + .filter( + (title) => title.paymentType === type || title.paymentType === "any", + ) + .filter((title) => + normalizeTitleName(title.name).includes(normalizedValue), + ) + .slice(0, 8), + [titles, type, normalizedValue], + ); + const exact = titles.find( + (title) => + (title.paymentType === type || title.paymentType === "any") && + normalizeTitleName(title.name) === normalizedValue, + ); + + useEffect(() => { + function close(event: MouseEvent) { + const target = event.target as Node; + if ( + !wrapperRef.current?.contains(target) && + !dropdownRef.current?.contains(target) + ) { + setOpen(false); + } + } + document.addEventListener("mousedown", close); + return () => document.removeEventListener("mousedown", close); + }, []); + + useEffect(() => { + if (!open) return; + function positionDropdown() { + const rect = inputRef.current?.getBoundingClientRect(); + if (!rect) return; + const gap = 8; + const estimatedHeight = 320; + const fitsBelow = + rect.bottom + gap + estimatedHeight <= window.innerHeight; + setDropdownPosition({ + left: Math.max( + 8, + Math.min(rect.left, window.innerWidth - rect.width - 8), + ), + top: fitsBelow + ? rect.bottom + gap + : Math.max(8, rect.top - estimatedHeight - gap), + width: rect.width, + }); + } + positionDropdown(); + window.addEventListener("resize", positionDropdown); + window.addEventListener("scroll", positionDropdown, true); + return () => { + window.removeEventListener("resize", positionDropdown); + window.removeEventListener("scroll", positionDropdown, true); + }; + }, [open]); + + async function createOrSelect() { + const selected = filtered[activeIndex]; + if (selected) { + onChange(selected.name); + setOpen(false); + return; + } + if (value.trim()) { + await onCreate?.(value.trim()); + setOpen(false); + } + } + + return ( +
+ + { + onChange(event.target.value); + setActiveIndex(0); + setOpen(true); + }} + onFocus={() => setOpen(true)} + onKeyDown={async (event) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveIndex((current) => + Math.min(current + 1, Math.max(filtered.length - 1, 0)), + ); + } + if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveIndex((current) => Math.max(current - 1, 0)); + } + if (event.key === "Enter") { + event.preventDefault(); + await createOrSelect(); + } + if (event.key === "Escape") setOpen(false); + }} + /> + + {open + ? createPortal( +
+
+ Titoli salvati +
+ {filtered.map((title, index) => ( + + ))} + {value.trim() && !exact ? ( + + ) : null} + {!filtered.length && !value.trim() ? ( +

+ Inizia a scrivere per cercare o creare. +

+ ) : null} +
, + document.body, + ) + : null} +
+ ); +} diff --git a/src/components/shared/Toast.tsx b/src/components/shared/Toast.tsx new file mode 100644 index 0000000..77677ab --- /dev/null +++ b/src/components/shared/Toast.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect, useState } from "react"; + +type ToastItem = { id: number; message: string; type: "ok" | "error" }; + +export function notify(message: string, type: "ok" | "error" = "ok") { + window.dispatchEvent( + new CustomEvent("appspese:toast", { detail: { message, type } }), + ); +} + +export function Toast() { + const [items, setItems] = useState([]); + + useEffect(() => { + function onToast(event: Event) { + const detail = (event as CustomEvent).detail; + const item = { + id: Date.now(), + message: detail.message, + type: detail.type, + }; + setItems((current) => [...current, item]); + setTimeout( + () => + setItems((current) => + current.filter((entry) => entry.id !== item.id), + ), + 3200, + ); + } + window.addEventListener("appspese:toast", onToast); + return () => window.removeEventListener("appspese:toast", onToast); + }, []); + + return ( +
+ {items.map((item) => ( +
+ {item.message} +
+ ))} +
+ ); +} diff --git a/src/components/shared/UserAvatar.tsx b/src/components/shared/UserAvatar.tsx new file mode 100644 index 0000000..26b7af0 --- /dev/null +++ b/src/components/shared/UserAvatar.tsx @@ -0,0 +1,47 @@ +"use client"; + +import Image from "next/image"; +import { useState } from "react"; +import type { UserSession } from "@/types/finance"; + +export function UserAvatar({ + user, + className = "size-10 text-sm", +}: { + user: UserSession; + className?: string; +}) { + const [failedUrl, setFailedUrl] = useState(); + + return ( + + {user.avatarUrl && user.avatarUrl !== failedUrl ? ( + {`Avatar setFailedUrl(user.avatarUrl ?? undefined)} + src={user.avatarUrl} + unoptimized + width={80} + /> + ) : ( + initials(user.name) + )} + + ); +} + +function initials(name: string) { + return ( + name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]) + .join("") + .toUpperCase() || "?" + ); +} diff --git a/src/components/shared/financeListCard.ts b/src/components/shared/financeListCard.ts new file mode 100644 index 0000000..1f1024c --- /dev/null +++ b/src/components/shared/financeListCard.ts @@ -0,0 +1,2 @@ +export const financeListCardClass = + "rounded-lg border border-sky-400/15 bg-sky-950/15 p-3 transition"; diff --git a/src/components/shared/navigation/MobileSidebar.tsx b/src/components/shared/navigation/MobileSidebar.tsx new file mode 100644 index 0000000..58952ff --- /dev/null +++ b/src/components/shared/navigation/MobileSidebar.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { + SidebarAccount, + SidebarLinks, +} from "@/components/shared/navigation/Sidebar"; +import { SidebarOverlay } from "@/components/shared/navigation/SidebarOverlay"; +import type { UserSession } from "@/types/finance"; + +const focusableSelector = [ + "a[href]", + "button:not([disabled])", + "[tabindex]:not([tabindex='-1'])", +].join(","); + +export function MobileSidebar({ + open, + pathname, + triggerRef, + onClose, + onLogout, + user, +}: { + open: boolean; + pathname: string; + triggerRef: React.RefObject; + onClose: () => void; + onLogout: () => void; + user: UserSession; +}) { + const panelRef = useRef(null); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + + useEffect(() => { + if (!open) return; + panelRef.current?.querySelector(focusableSelector)?.focus(); + + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + onCloseRef.current(); + return; + } + if (event.key !== "Tab" || !panelRef.current) return; + const elements = Array.from( + panelRef.current.querySelectorAll(focusableSelector), + ); + if (!elements.length) return; + const first = elements[0]; + const last = elements[elements.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + triggerRef.current?.focus(); + }; + }, [open, triggerRef]); + + return ( + <> + + + + ); +} diff --git a/src/components/shared/navigation/Sidebar.tsx b/src/components/shared/navigation/Sidebar.tsx new file mode 100644 index 0000000..ecab951 --- /dev/null +++ b/src/components/shared/navigation/Sidebar.tsx @@ -0,0 +1,122 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { + LuChartNoAxesCombined, + LuCreditCard, + LuLayoutDashboard, + LuLogOut, + LuReceiptText, + LuRefreshCw, + LuTrendingUp, + LuUserRound, +} from "react-icons/lu"; +import { UserAvatar } from "@/components/shared/UserAvatar"; +import type { UserSession } from "@/types/finance"; + +export const navigationLinks = [ + { icon: LuLayoutDashboard, title: "Dashboard", href: "/dashboard" }, + { icon: LuTrendingUp, title: "Entrate", href: "/income" }, + { icon: LuReceiptText, title: "Bollette", href: "/bills" }, + { icon: LuRefreshCw, title: "Abbonamenti", href: "/subscriptions" }, + { + icon: LuCreditCard, + title: "Pagamenti Singoli", + href: "/one-time-payments", + }, + { icon: LuChartNoAxesCombined, title: "Report", href: "/reports" }, + { icon: LuUserRound, title: "Profilo", href: "/profile" }, +]; + +export function SidebarBrand() { + return ( +
+ MyMoney +
MyMoney
+
+ ); +} + +export function SidebarLinks({ + pathname, + onNavigate, + className = "", +}: { + pathname: string; + onNavigate?: () => void; + className?: string; +}) { + return ( + + ); +} + +export function SidebarAccount({ + onLogout, + user, +}: { + onLogout: () => void; + user: UserSession; +}) { + return ( +
+ + +
+

{user.name}

+

{user.email}

+
+ + +
+ ); +} + +export function Sidebar({ + onLogout, + pathname, + user, +}: { + onLogout: () => void; + pathname: string; + user: UserSession; +}) { + return ( + + ); +} diff --git a/src/components/shared/navigation/SidebarOverlay.tsx b/src/components/shared/navigation/SidebarOverlay.tsx new file mode 100644 index 0000000..171626d --- /dev/null +++ b/src/components/shared/navigation/SidebarOverlay.tsx @@ -0,0 +1,19 @@ +export function SidebarOverlay({ + open, + onClose, +}: { + open: boolean; + onClose: () => void; +}) { + return ( + + ); +}); diff --git a/src/components/shared/useFinanceData.ts b/src/components/shared/useFinanceData.ts new file mode 100644 index 0000000..9a7358b --- /dev/null +++ b/src/components/shared/useFinanceData.ts @@ -0,0 +1,231 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { notify } from "@/components/shared/Toast"; +import type { + Bill, + Income, + OneTimePayment, + PaymentLedger, + PaymentTitle, + Subscription, + SubscriptionPayment, + UserSettings, +} from "@/types/finance"; + +type CacheEntry = { + data?: T; + error?: string; + promise?: Promise; + refresh?: () => void; + updatedAt: number; + subscribers: Set<() => void>; +}; + +const staleTime = 30_000; +const cache = new Map>(); + +function entryFor(key: string): CacheEntry { + let entry = cache.get(key) as CacheEntry | undefined; + if (!entry) { + entry = { updatedAt: 0, subscribers: new Set() }; + cache.set(key, entry as CacheEntry); + } + return entry; +} + +function notifySubscribers(key: string) { + const entry = cache.get(key); + if (!entry) return; + for (const subscriber of entry.subscribers) subscriber(); +} + +async function getJson(url: string): Promise { + const response = await fetch(url, { cache: "no-store" }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error ?? `Errore ${url}`); + return payload; +} + +export function useApiResource(key: string, url: string) { + const [, forceRender] = useState(0); + const entry = entryFor(key); + + const revalidate = useCallback( + async (force = false) => { + const current = entryFor(key); + const fresh = Date.now() - current.updatedAt < staleTime; + if (!force && current.data !== undefined && fresh) return current.data; + if (current.promise) return current.promise; + + current.promise = getJson(url) + .then((data) => { + current.data = data; + current.error = undefined; + current.updatedAt = Date.now(); + return data; + }) + .catch((error) => { + current.error = + error instanceof Error ? error.message : "Errore di caricamento"; + notify(current.error, "error"); + throw error; + }) + .finally(() => { + current.promise = undefined; + notifySubscribers(key); + }); + + notifySubscribers(key); + return current.promise; + }, + [key, url], + ); + + useEffect(() => { + const current = entryFor(key); + const subscriber = () => forceRender((value) => value + 1); + const refresh = () => { + revalidate(true).catch(() => undefined); + }; + current.subscribers.add(subscriber); + current.refresh = refresh; + revalidate(false).catch(() => undefined); + return () => { + current.subscribers.delete(subscriber); + if (current.refresh === refresh) current.refresh = undefined; + }; + }, [key, revalidate]); + + const mutate = useCallback( + ( + updater?: T | ((current: T | undefined) => T | undefined), + revalidateAfter = true, + ) => { + const current = entryFor(key); + if (updater !== undefined) { + current.data = + typeof updater === "function" + ? (updater as (current: T | undefined) => T | undefined)( + current.data, + ) + : updater; + current.updatedAt = Date.now(); + notifySubscribers(key); + } + return revalidateAfter + ? revalidate(true).catch(() => undefined) + : Promise.resolve(current.data); + }, + [key, revalidate], + ); + + return { + data: entry.data, + loading: entry.data === undefined && Boolean(entry.promise), + error: entry.error ?? "", + refresh: () => revalidate(true).catch(() => undefined), + mutate, + }; +} + +export function invalidateResource(key: string) { + const current = cache.get(key); + if (!current) return; + current.updatedAt = 0; + notifySubscribers(key); + current.refresh?.(); +} + +export function evictCachedLedgerSource( + sourceType: PaymentLedger["sourceType"], + sourceId: string, +) { + const current = cache.get("payments-ledger") as + | CacheEntry + | undefined; + if (!current?.data) return; + current.data = current.data.filter( + (item) => item.sourceType !== sourceType || item.sourceId !== sourceId, + ); + current.updatedAt = 0; + notifySubscribers("payments-ledger"); + current.refresh?.(); +} + +export function invalidateFinancialResources() { + for (const key of [ + "subscriptions", + "subscription-payments", + "bills", + "one-time-payments", + "payments-ledger", + "payment-titles", + "income", + ]) { + const current = cache.get(key); + if (!current) continue; + current.data = undefined; + current.updatedAt = 0; + notifySubscribers(key); + } +} + +export function useSubscriptionsResource() { + return useApiResource("subscriptions", "/api/subscriptions"); +} + +export function useSubscriptionPaymentsResource() { + return useApiResource( + "subscription-payments", + "/api/subscription-payments", + ); +} + +export function useBillsResource() { + return useApiResource("bills", "/api/bills"); +} + +export function useOneTimePaymentsResource() { + return useApiResource( + "one-time-payments", + "/api/one-time-payments", + ); +} + +export function useLedgerResource() { + return useApiResource( + "payments-ledger", + "/api/payments-ledger", + ); +} + +export function usePaymentTitlesResource() { + return useApiResource( + "payment-titles", + "/api/payment-titles", + ); +} + +export function useIncomeResource() { + return useApiResource("income", "/api/income"); +} + +export function useUserSettingsResource() { + return useApiResource("user-settings", "/api/profile/settings"); +} + +export async function apiSave( + url: string, + method: "POST" | "PATCH" | "DELETE", + body?: object, +) { + const response = await fetch(url, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error ?? "Operazione non riuscita"); + return payload as T; +} diff --git a/src/components/subscriptions/SubscriptionForm.tsx b/src/components/subscriptions/SubscriptionForm.tsx new file mode 100644 index 0000000..353771b --- /dev/null +++ b/src/components/subscriptions/SubscriptionForm.tsx @@ -0,0 +1,508 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { LuArrowLeft, LuArrowRight, LuCircleCheck } from "react-icons/lu"; +import { DateField } from "@/components/shared/DateField"; +import { Field, inputClass } from "@/components/shared/Field"; +import { Select } from "@/components/shared/Select"; +import { TitleCombobox } from "@/components/shared/TitleCombobox"; +import { todayIso } from "@/lib/date/formatDate"; +import { formatMoneyInput, parseMoneyInput } from "@/lib/finance/money"; +import type { + BillingCycle, + PaymentTitle, + RenewalIntervalUnit, + Subscription, + SubscriptionStatus, +} from "@/types/finance"; + +type DurationMode = "indefinite" | "date" | "count" | "both"; + +const steps = ["Servizio", "Rinnovo", "Conferma"]; + +export function SubscriptionForm({ + titles, + editing, + onSubmit, + onCreateTitle, + onCancel, + saving = false, +}: { + titles: PaymentTitle[]; + editing?: Subscription; + onSubmit: (data: object) => Promise; + onCreateTitle?: (name: string) => Promise; + onCancel?: () => void; + saving?: boolean; +}) { + const [step, setStep] = useState(0); + const [title, setTitle] = useState(editing?.title ?? ""); + const [amount, setAmount] = useState(formatMoneyInput(editing?.amount)); + const [activationDate, setActivationDate] = useState( + editing?.activationDate ?? todayIso(), + ); + const [firstPaymentDate, setFirstPaymentDate] = useState( + editing?.firstPaymentDate ?? todayIso(), + ); + const [billingCycle, setBillingCycle] = useState( + editing?.billingCycle ?? "monthly", + ); + const [durationMode, setDurationMode] = useState(durationModeFor(editing)); + const [endDate, setEndDate] = useState(editing?.endDate ?? ""); + const [occurrenceCount, setOccurrenceCount] = useState( + String(editing?.occurrenceCount ?? ""), + ); + const [customIntervalValue, setCustomIntervalValue] = useState( + String(editing?.customIntervalValue ?? ""), + ); + const [customIntervalUnit, setCustomIntervalUnit] = useState( + editing?.customIntervalUnit ?? "months", + ); + const [subscriptionStatus, setSubscriptionStatus] = useState( + editing?.subscriptionStatus ?? "active", + ); + const [error, setError] = useState(""); + + useEffect(() => { + setStep(0); + setTitle(editing?.title ?? ""); + setAmount(formatMoneyInput(editing?.amount)); + setActivationDate(editing?.activationDate ?? todayIso()); + setFirstPaymentDate(editing?.firstPaymentDate ?? todayIso()); + setBillingCycle(editing?.billingCycle ?? "monthly"); + setDurationMode(durationModeFor(editing)); + setEndDate(editing?.endDate ?? ""); + setOccurrenceCount(String(editing?.occurrenceCount ?? "")); + setCustomIntervalValue(String(editing?.customIntervalValue ?? "")); + setCustomIntervalUnit(editing?.customIntervalUnit ?? "months"); + setSubscriptionStatus(editing?.subscriptionStatus ?? "active"); + setError(""); + }, [editing]); + + async function submit() { + setError(""); + await onSubmit({ + title, + amount: parseMoneyInput(amount), + activationDate, + firstPaymentDate, + billingCycle, + endDate: + durationMode === "date" || durationMode === "both" ? endDate : "", + occurrenceCount: + durationMode === "count" || durationMode === "both" + ? occurrenceCount + : "", + customIntervalValue: billingCycle === "custom" ? customIntervalValue : "", + customIntervalUnit: billingCycle === "custom" ? customIntervalUnit : "", + subscriptionStatus, + }); + } + + function advance() { + try { + setError(""); + if (step === 0) validateServiceStep(); + if (step === 1) validateRenewalStep(); + setStep((current) => Math.min(current + 1, steps.length - 1)); + } catch (err) { + setError( + err instanceof Error ? err.message : "Controlla i campi inseriti", + ); + } + } + + function validateServiceStep() { + if (!title.trim()) throw new Error("Inserisci il titolo del servizio"); + parseMoneyInput(amount); + if (!activationDate || !firstPaymentDate) { + throw new Error("Inserisci le date del servizio"); + } + if (firstPaymentDate < activationDate) { + throw new Error("Il primo pagamento non può precedere l'attivazione"); + } + } + + function validateRenewalStep() { + if (billingCycle === "custom" && !customIntervalValue) { + throw new Error("Inserisci l'intervallo di rinnovo"); + } + if ((durationMode === "date" || durationMode === "both") && !endDate) { + throw new Error("Inserisci la data di fine"); + } + if ( + (durationMode === "count" || durationMode === "both") && + !occurrenceCount + ) { + throw new Error("Inserisci il numero di rate"); + } + } + + return ( +
{ + event.preventDefault(); + if (step < steps.length - 1) { + advance(); + return; + } + try { + await submit(); + } catch (err) { + setError( + err instanceof Error ? err.message : "Salvataggio non riuscito", + ); + } + }} + > + +
+ {step === 0 ? ( + + ) : null} + {step === 1 ? ( + + ) : null} + {step === 2 ? ( + + ) : null} +
+ {error ?

{error}

: null} +
+
+ {step > 0 ? ( + + ) : onCancel ? ( + + ) : null} +
+ +
+ + ); +} + +function StepIndicator({ current }: { current: number }) { + return ( +
+ {steps.map((label, index) => ( +
+ {index + 1}. {label} +
+ ))} +
+ ); +} + +function ServiceStep({ + titles, + title, + amount, + activationDate, + firstPaymentDate, + onTitleChange, + onAmountChange, + onActivationDateChange, + onFirstPaymentDateChange, + onCreateTitle, +}: { + titles: PaymentTitle[]; + title: string; + amount: string; + activationDate: string; + firstPaymentDate: string; + onTitleChange: (value: string) => void; + onAmountChange: (value: string) => void; + onActivationDateChange: (value: string) => void; + onFirstPaymentDateChange: (value: string) => void; + onCreateTitle?: (name: string) => Promise; +}) { + return ( +
+
+ +
+ + onAmountChange(event.target.value)} + /> + +
+ + +
+ ); +} + +function RenewalStep({ + billingCycle, + durationMode, + endDate, + occurrenceCount, + customIntervalValue, + customIntervalUnit, + onBillingCycleChange, + onDurationModeChange, + onEndDateChange, + onOccurrenceCountChange, + onCustomIntervalValueChange, + onCustomIntervalUnitChange, +}: { + billingCycle: BillingCycle; + durationMode: DurationMode; + endDate: string; + occurrenceCount: string; + customIntervalValue: string; + customIntervalUnit: RenewalIntervalUnit; + onBillingCycleChange: (value: BillingCycle) => void; + onDurationModeChange: (value: DurationMode) => void; + onEndDateChange: (value: string) => void; + onOccurrenceCountChange: (value: string) => void; + onCustomIntervalValueChange: (value: string) => void; + onCustomIntervalUnitChange: (value: RenewalIntervalUnit) => void; +}) { + return ( +
+ + onCustomIntervalValueChange(event.target.value) + } + /> + + onDurationModeChange(value as DurationMode)} + options={[ + { value: "indefinite", label: "Senza scadenza" }, + { value: "date", label: "Fino a una data" }, + { value: "count", label: "Numero di rate" }, + { value: "both", label: "Data e numero di rate" }, + ]} + /> + {durationMode === "date" || durationMode === "both" ? ( + + ) : null} + {durationMode === "count" || durationMode === "both" ? ( + + onOccurrenceCountChange(event.target.value)} + /> + + ) : null} +

+ Senza scadenza il rinnovo continua finché non sospendi o termini il + contratto. +

+
+ ); +} + +function ReviewStep({ + title, + amount, + billingCycle, + durationMode, + editing, + status, + onStatusChange, +}: { + title: string; + amount: string; + billingCycle: BillingCycle; + durationMode: DurationMode; + editing?: Subscription; + status: SubscriptionStatus; + onStatusChange: (value: SubscriptionStatus) => void; +}) { + return ( +
+
+

+ Riepilogo +

+

{title}

+

+ {amount} · {cycleLabels[billingCycle]} ·{" "} + {durationLabels[durationMode]} +

+
+ {editing ? ( +