first commit
This commit is contained in:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.env*
|
||||||
|
*.tsbuildinfo
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -23,6 +23,7 @@
|
|||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
/.data/
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
@@ -32,6 +33,7 @@ yarn-error.log*
|
|||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
FROM node:22-alpine AS base
|
||||||
|
RUN corepack enable
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
FROM base AS dependencies
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
COPY --from=dependencies /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "server.js"]
|
||||||
109
README.md
109
README.md
@@ -1,36 +1,103 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
# App Spese
|
||||||
|
|
||||||
## Getting Started
|
Full-stack personal finance app built with Next.js App Router, TypeScript,
|
||||||
|
Tailwind CSS, and PostgreSQL.
|
||||||
|
|
||||||
First, run the development server:
|
The browser only talks to Next.js Route Handlers. PostgreSQL credentials and
|
||||||
|
session tokens stay on the server.
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
Copy the example environment file and choose a strong database password:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
cp .env.example .env
|
||||||
# or
|
docker compose up --build -d
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
The first startup initializes the PostgreSQL schema from `database/init.sql`.
|
||||||
|
Open `http://localhost:3000`: the app redirects to `/setup` and asks you to
|
||||||
|
create the first account. The setup page becomes unavailable as soon as that
|
||||||
|
account exists.
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
## Local Development
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
Start PostgreSQL with Docker:
|
||||||
|
|
||||||
## Learn More
|
```bash
|
||||||
|
docker compose up -d postgres
|
||||||
|
```
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
The compose file publishes PostgreSQL on `localhost:5432` by default so the
|
||||||
|
local Next.js development server can reach it.
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
Create `.env.local`:
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
```env
|
||||||
|
DATABASE_URL=postgresql://appspese:change-me@localhost:5432/appspese
|
||||||
|
```
|
||||||
|
|
||||||
## Deploy on Vercel
|
Install dependencies and run Next.js:
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
## Data Model
|
||||||
|
|
||||||
|
Tables:
|
||||||
|
|
||||||
|
- `users` and `sessions`: local credentials and revocable login sessions.
|
||||||
|
- `subscriptions`: recurring payment rules.
|
||||||
|
- `subscription_payments`: paid or unpaid state for generated renewals.
|
||||||
|
- `bills`: expected manual bills with due dates.
|
||||||
|
- `one_time_payments`: immediate manual outgoing payments.
|
||||||
|
- `payments_ledger`: single source of truth for real paid outgoing money.
|
||||||
|
- `payment_titles`: reusable deduplicated payment names.
|
||||||
|
- `income`: incoming money.
|
||||||
|
- `user_settings`: one row of preferences per user.
|
||||||
|
|
||||||
|
Subscriptions and bills count as projected obligations until paid. Paid
|
||||||
|
outgoing money is written to `payments_ledger`, with unique database constraints
|
||||||
|
preventing duplicate ledger rows.
|
||||||
|
|
||||||
|
Subscriptions keep service activation and billing separate. Recurring payments
|
||||||
|
start from the first payment date, can stop at an optional end date or after an
|
||||||
|
optional number of installments, and may otherwise continue indefinitely.
|
||||||
|
Suspended and terminated subscriptions do not generate new virtual renewals,
|
||||||
|
while already recorded payments remain available as history.
|
||||||
|
Custom renewal intervals use an exact quantity and unit, supporting schedules
|
||||||
|
such as every two weeks, every three months, or every two years.
|
||||||
|
|
||||||
|
## Database Migrations
|
||||||
|
|
||||||
|
`database/init.sql` contains the complete schema for new installations.
|
||||||
|
Versioned files in `database/migrations` update existing volumes. After pulling
|
||||||
|
an update that includes a new migration, apply it once with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat database/migrations/001_subscription_lifecycle.sql | docker compose exec -T postgres psql -v ON_ERROR_STOP=1 -U appspese -d appspese
|
||||||
|
cat database/migrations/002_subscription_custom_intervals.sql | docker compose exec -T postgres psql -v ON_ERROR_STOP=1 -U appspese -d appspese
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
There is no public registration page. The `/setup` page and its API endpoint are
|
||||||
|
available only while the database contains no users. The first request that
|
||||||
|
creates an account takes a PostgreSQL transaction lock, preventing concurrent
|
||||||
|
requests from creating multiple initial accounts.
|
||||||
|
|
||||||
|
- Passwords are hashed with bcrypt.
|
||||||
|
- Login sessions use random secrets stored in an HTTP-only cookie.
|
||||||
|
- Only a SHA-256 hash of each session secret is stored in PostgreSQL.
|
||||||
|
- API Route Handlers verify the current user server-side and ignore
|
||||||
|
client-provided user identifiers.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm lint
|
||||||
|
pnpm build
|
||||||
|
docker compose config
|
||||||
|
```
|
||||||
|
|||||||
150
database/init.sql
Normal file
150
database/init.sql
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name text NOT NULL,
|
||||||
|
email text NOT NULL UNIQUE,
|
||||||
|
password_hash text NOT NULL,
|
||||||
|
avatar_file text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_hash text NOT NULL UNIQUE,
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX sessions_user_id_idx ON sessions(user_id);
|
||||||
|
CREATE INDEX sessions_expires_at_idx ON sessions(expires_at);
|
||||||
|
|
||||||
|
CREATE TABLE subscriptions (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
title text NOT NULL,
|
||||||
|
amount double precision NOT NULL,
|
||||||
|
billing_cycle text NOT NULL,
|
||||||
|
activation_date text NOT NULL,
|
||||||
|
first_payment_date text NOT NULL,
|
||||||
|
end_date text,
|
||||||
|
occurrence_count integer CHECK (occurrence_count > 0),
|
||||||
|
custom_interval_value integer CHECK (custom_interval_value > 0),
|
||||||
|
custom_interval_unit text CHECK (
|
||||||
|
custom_interval_unit IN ('days', 'weeks', 'months', 'years')
|
||||||
|
),
|
||||||
|
subscription_status text NOT NULL,
|
||||||
|
created_at text NOT NULL,
|
||||||
|
updated_at text NOT NULL,
|
||||||
|
CONSTRAINT subscriptions_amount_positive CHECK (amount > 0),
|
||||||
|
CONSTRAINT subscriptions_billing_cycle_valid CHECK (
|
||||||
|
billing_cycle IN ('weekly', 'monthly', 'yearly', 'custom')
|
||||||
|
),
|
||||||
|
CONSTRAINT subscriptions_status_valid CHECK (
|
||||||
|
subscription_status IN ('active', 'suspended', 'terminated')
|
||||||
|
),
|
||||||
|
CONSTRAINT subscriptions_dates_valid CHECK (
|
||||||
|
activation_date <= first_payment_date
|
||||||
|
AND (end_date IS NULL OR end_date >= first_payment_date)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CREATE INDEX subscriptions_user_id_idx ON subscriptions(user_id);
|
||||||
|
CREATE INDEX subscriptions_first_payment_date_idx ON subscriptions(first_payment_date);
|
||||||
|
|
||||||
|
CREATE TABLE subscription_payments (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
subscription_id uuid NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
|
||||||
|
renewal_date text NOT NULL,
|
||||||
|
status text NOT NULL,
|
||||||
|
payment_date text,
|
||||||
|
linked_payment_id uuid,
|
||||||
|
created_at text NOT NULL,
|
||||||
|
updated_at text NOT NULL,
|
||||||
|
UNIQUE (subscription_id, renewal_date, user_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX subscription_payments_user_id_idx ON subscription_payments(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE bills (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
title text NOT NULL,
|
||||||
|
amount double precision NOT NULL,
|
||||||
|
due_date text NOT NULL,
|
||||||
|
status text NOT NULL,
|
||||||
|
payment_date text,
|
||||||
|
linked_payment_id uuid,
|
||||||
|
created_at text NOT NULL,
|
||||||
|
updated_at text NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX bills_user_id_idx ON bills(user_id);
|
||||||
|
CREATE INDEX bills_due_date_idx ON bills(due_date);
|
||||||
|
|
||||||
|
CREATE TABLE one_time_payments (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
title text NOT NULL,
|
||||||
|
amount double precision NOT NULL,
|
||||||
|
date text NOT NULL,
|
||||||
|
linked_payment_id uuid,
|
||||||
|
created_at text NOT NULL,
|
||||||
|
updated_at text NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX one_time_payments_user_id_idx ON one_time_payments(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE payments_ledger (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
title text NOT NULL,
|
||||||
|
amount double precision NOT NULL,
|
||||||
|
date text NOT NULL,
|
||||||
|
source_type text NOT NULL,
|
||||||
|
source_id uuid NOT NULL,
|
||||||
|
created_at text NOT NULL,
|
||||||
|
updated_at text NOT NULL,
|
||||||
|
UNIQUE (source_type, source_id, user_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX payments_ledger_user_id_idx ON payments_ledger(user_id);
|
||||||
|
CREATE INDEX payments_ledger_date_idx ON payments_ledger(date);
|
||||||
|
|
||||||
|
CREATE TABLE payment_titles (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
normalized_name text NOT NULL,
|
||||||
|
payment_type text NOT NULL,
|
||||||
|
created_at text NOT NULL,
|
||||||
|
updated_at text NOT NULL,
|
||||||
|
CONSTRAINT payment_titles_type_valid CHECK (
|
||||||
|
payment_type IN ('subscription', 'bill', 'one_time', 'income', 'any')
|
||||||
|
),
|
||||||
|
UNIQUE (user_id, normalized_name, payment_type)
|
||||||
|
);
|
||||||
|
CREATE INDEX payment_titles_user_id_idx ON payment_titles(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE income (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
title text NOT NULL,
|
||||||
|
amount double precision NOT NULL,
|
||||||
|
type text NOT NULL,
|
||||||
|
date text NOT NULL,
|
||||||
|
created_at text NOT NULL,
|
||||||
|
updated_at text NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX income_user_id_idx ON income(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE user_settings (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
full_name text,
|
||||||
|
monthly_income_goal double precision,
|
||||||
|
monthly_savings_goal double precision,
|
||||||
|
currency text NOT NULL,
|
||||||
|
locale text NOT NULL,
|
||||||
|
first_day_of_week text NOT NULL,
|
||||||
|
default_dashboard_view text NOT NULL,
|
||||||
|
created_at text NOT NULL,
|
||||||
|
updated_at text NOT NULL
|
||||||
|
);
|
||||||
104
database/migrations/001_subscription_lifecycle.sql
Normal file
104
database/migrations/001_subscription_lifecycle.sql
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE subscriptions
|
||||||
|
ADD COLUMN IF NOT EXISTS activation_date text,
|
||||||
|
ADD COLUMN IF NOT EXISTS first_payment_date text,
|
||||||
|
ADD COLUMN IF NOT EXISTS end_date text,
|
||||||
|
ADD COLUMN IF NOT EXISTS occurrence_count integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS custom_interval_days integer;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'subscriptions' AND column_name = 'renewal_date'
|
||||||
|
) THEN
|
||||||
|
UPDATE subscriptions
|
||||||
|
SET
|
||||||
|
activation_date = COALESCE(activation_date, renewal_date),
|
||||||
|
first_payment_date = COALESCE(first_payment_date, renewal_date),
|
||||||
|
subscription_status = CASE
|
||||||
|
WHEN subscription_status = 'disabled' THEN 'terminated'
|
||||||
|
ELSE subscription_status
|
||||||
|
END;
|
||||||
|
ELSE
|
||||||
|
UPDATE subscriptions
|
||||||
|
SET subscription_status = 'terminated'
|
||||||
|
WHERE subscription_status = 'disabled';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
ALTER TABLE subscriptions
|
||||||
|
ALTER COLUMN activation_date SET NOT NULL,
|
||||||
|
ALTER COLUMN first_payment_date SET NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE subscriptions
|
||||||
|
DROP COLUMN IF EXISTS renewal_date,
|
||||||
|
DROP COLUMN IF EXISTS auto_renew;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS subscriptions_renewal_date_idx;
|
||||||
|
CREATE INDEX IF NOT EXISTS subscriptions_first_payment_date_idx
|
||||||
|
ON subscriptions(first_payment_date);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'subscriptions_occurrence_count_positive'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE subscriptions
|
||||||
|
ADD CONSTRAINT subscriptions_occurrence_count_positive
|
||||||
|
CHECK (occurrence_count > 0);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'subscriptions_custom_interval_days_positive'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE subscriptions
|
||||||
|
ADD CONSTRAINT subscriptions_custom_interval_days_positive
|
||||||
|
CHECK (custom_interval_days > 0);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'subscriptions_amount_positive'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE subscriptions
|
||||||
|
ADD CONSTRAINT subscriptions_amount_positive CHECK (amount > 0);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'subscriptions_billing_cycle_valid'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE subscriptions
|
||||||
|
ADD CONSTRAINT subscriptions_billing_cycle_valid
|
||||||
|
CHECK (billing_cycle IN ('weekly', 'monthly', 'yearly', 'custom'));
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'subscriptions_status_valid'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE subscriptions
|
||||||
|
ADD CONSTRAINT subscriptions_status_valid
|
||||||
|
CHECK (subscription_status IN ('active', 'suspended', 'terminated'));
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'subscriptions_dates_valid'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE subscriptions
|
||||||
|
ADD CONSTRAINT subscriptions_dates_valid
|
||||||
|
CHECK (
|
||||||
|
activation_date <= first_payment_date
|
||||||
|
AND (end_date IS NULL OR end_date >= first_payment_date)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
49
database/migrations/002_subscription_custom_intervals.sql
Normal file
49
database/migrations/002_subscription_custom_intervals.sql
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE subscriptions
|
||||||
|
ADD COLUMN IF NOT EXISTS custom_interval_value integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS custom_interval_unit text;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'subscriptions' AND column_name = 'custom_interval_days'
|
||||||
|
) THEN
|
||||||
|
UPDATE subscriptions
|
||||||
|
SET
|
||||||
|
custom_interval_value = COALESCE(custom_interval_value, custom_interval_days),
|
||||||
|
custom_interval_unit = CASE
|
||||||
|
WHEN custom_interval_days IS NOT NULL THEN COALESCE(custom_interval_unit, 'days')
|
||||||
|
ELSE custom_interval_unit
|
||||||
|
END;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
ALTER TABLE subscriptions
|
||||||
|
DROP COLUMN IF EXISTS custom_interval_days;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'subscriptions_custom_interval_value_positive'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE subscriptions
|
||||||
|
ADD CONSTRAINT subscriptions_custom_interval_value_positive
|
||||||
|
CHECK (custom_interval_value > 0);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'subscriptions_custom_interval_unit_valid'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE subscriptions
|
||||||
|
ADD CONSTRAINT subscriptions_custom_interval_unit_valid
|
||||||
|
CHECK (custom_interval_unit IN ('days', 'weeks', 'months', 'years'));
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
UPDATE payments_ledger AS ledger
|
||||||
|
SET date = payment.renewal_date,
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM subscription_payments AS payment
|
||||||
|
WHERE ledger.source_type = 'subscription'
|
||||||
|
AND ledger.source_id = payment.id
|
||||||
|
AND ledger.date IS DISTINCT FROM payment.renewal_date;
|
||||||
32
database/migrations/004_income_titles.sql
Normal file
32
database/migrations/004_income_titles.sql
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'payment_titles_type_valid'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE payment_titles
|
||||||
|
ADD CONSTRAINT payment_titles_type_valid CHECK (
|
||||||
|
payment_type IN ('subscription', 'bill', 'one_time', 'income', 'any')
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
INSERT INTO payment_titles (
|
||||||
|
user_id,
|
||||||
|
name,
|
||||||
|
normalized_name,
|
||||||
|
payment_type,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT DISTINCT
|
||||||
|
user_id,
|
||||||
|
title,
|
||||||
|
LOWER(REGEXP_REPLACE(TRIM(title), '\s+', ' ', 'g')),
|
||||||
|
'income',
|
||||||
|
NOW()::text,
|
||||||
|
NOW()::text
|
||||||
|
FROM income
|
||||||
|
WHERE TRIM(title) <> ''
|
||||||
|
ON CONFLICT (user_id, normalized_name, payment_type) DO NOTHING;
|
||||||
2
database/migrations/005_user_avatar.sql
Normal file
2
database/migrations/005_user_avatar.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS avatar_file text;
|
||||||
35
docker-compose.yml
Normal file
35
docker-compose.yml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER:-appspese}:${POSTGRES_PASSWORD:-change-me}@postgres:5432/${POSTGRES_DB:-appspese}
|
||||||
|
AVATAR_STORAGE_DIR: /data/avatars
|
||||||
|
ports:
|
||||||
|
- "${APP_PORT:-3000}:3000"
|
||||||
|
volumes:
|
||||||
|
- ./.data/avatars:/data/avatars
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${POSTGRES_PORT:-5432}:5432"
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-appspese}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-appspese}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-appspese} -d ${POSTGRES_DB:-appspese}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./database/init.sql:/docker-entrypoint-initdb.d/001-init.sql:ro
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
output: "standalone",
|
||||||
|
allowedDevOrigins: ["100.64.0.3"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"name": "appspese",
|
"name": "appspese",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"packageManager": "pnpm@11.4.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
@@ -10,9 +11,12 @@
|
|||||||
"format": "biome format --write"
|
"format": "biome format --write"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
|
"postgres": "^3.4.9",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"react-icons": "^5.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.2.0",
|
"@biomejs/biome": "2.2.0",
|
||||||
|
|||||||
38
pnpm-lock.yaml
generated
38
pnpm-lock.yaml
generated
@@ -8,15 +8,24 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
bcryptjs:
|
||||||
|
specifier: ^3.0.3
|
||||||
|
version: 3.0.3
|
||||||
next:
|
next:
|
||||||
specifier: 16.2.6
|
specifier: 16.2.6
|
||||||
version: 16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
postgres:
|
||||||
|
specifier: ^3.4.9
|
||||||
|
version: 3.4.9
|
||||||
react:
|
react:
|
||||||
specifier: 19.2.4
|
specifier: 19.2.4
|
||||||
version: 19.2.4
|
version: 19.2.4
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: 19.2.4
|
specifier: 19.2.4
|
||||||
version: 19.2.4(react@19.2.4)
|
version: 19.2.4(react@19.2.4)
|
||||||
|
react-icons:
|
||||||
|
specifier: ^5.6.0
|
||||||
|
version: 5.6.0(react@19.2.4)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@biomejs/biome':
|
'@biomejs/biome':
|
||||||
specifier: 2.2.0
|
specifier: 2.2.0
|
||||||
@@ -441,6 +450,10 @@ packages:
|
|||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
bcryptjs@3.0.3:
|
||||||
|
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
caniuse-lite@1.0.30001793:
|
caniuse-lite@1.0.30001793:
|
||||||
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
|
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
|
||||||
|
|
||||||
@@ -454,8 +467,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
enhanced-resolve@5.22.1:
|
enhanced-resolve@5.22.0:
|
||||||
resolution: {integrity: sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==}
|
resolution: {integrity: sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
graceful-fs@4.2.11:
|
graceful-fs@4.2.11:
|
||||||
@@ -579,11 +592,20 @@ packages:
|
|||||||
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
|
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
postgres@3.4.9:
|
||||||
|
resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
react-dom@19.2.4:
|
react-dom@19.2.4:
|
||||||
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
|
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.2.4
|
react: ^19.2.4
|
||||||
|
|
||||||
|
react-icons@5.6.0:
|
||||||
|
resolution: {integrity: sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '*'
|
||||||
|
|
||||||
react@19.2.4:
|
react@19.2.4:
|
||||||
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
|
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -828,7 +850,7 @@ snapshots:
|
|||||||
'@tailwindcss/node@4.3.0':
|
'@tailwindcss/node@4.3.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/remapping': 2.3.5
|
'@jridgewell/remapping': 2.3.5
|
||||||
enhanced-resolve: 5.22.1
|
enhanced-resolve: 5.22.0
|
||||||
jiti: 2.7.0
|
jiti: 2.7.0
|
||||||
lightningcss: 1.32.0
|
lightningcss: 1.32.0
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
@@ -908,6 +930,8 @@ snapshots:
|
|||||||
|
|
||||||
baseline-browser-mapping@2.10.32: {}
|
baseline-browser-mapping@2.10.32: {}
|
||||||
|
|
||||||
|
bcryptjs@3.0.3: {}
|
||||||
|
|
||||||
caniuse-lite@1.0.30001793: {}
|
caniuse-lite@1.0.30001793: {}
|
||||||
|
|
||||||
client-only@0.0.1: {}
|
client-only@0.0.1: {}
|
||||||
@@ -916,7 +940,7 @@ snapshots:
|
|||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
enhanced-resolve@5.22.1:
|
enhanced-resolve@5.22.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
tapable: 2.3.3
|
tapable: 2.3.3
|
||||||
@@ -1018,11 +1042,17 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
postgres@3.4.9: {}
|
||||||
|
|
||||||
react-dom@19.2.4(react@19.2.4):
|
react-dom@19.2.4(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
scheduler: 0.27.0
|
scheduler: 0.27.0
|
||||||
|
|
||||||
|
react-icons@5.6.0(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
|
||||||
react@19.2.4: {}
|
react@19.2.4: {}
|
||||||
|
|
||||||
scheduler@0.27.0: {}
|
scheduler@0.27.0: {}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
allowBuilds:
|
||||||
|
sharp: false
|
||||||
ignoredBuiltDependencies:
|
ignoredBuiltDependencies:
|
||||||
- sharp
|
- sharp
|
||||||
- unrs-resolver
|
- unrs-resolver
|
||||||
|
|||||||
BIN
public/logo.png
Normal file
BIN
public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 194 KiB |
5
src/app/(protected)/bills/page.tsx
Normal file
5
src/app/(protected)/bills/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { BillsPageClient } from "@/components/bills/BillsPageClient";
|
||||||
|
|
||||||
|
export default function BillsPage() {
|
||||||
|
return <BillsPageClient />;
|
||||||
|
}
|
||||||
5
src/app/(protected)/dashboard/page.tsx
Normal file
5
src/app/(protected)/dashboard/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { DashboardPageClient } from "@/components/dashboard/DashboardPageClient";
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
return <DashboardPageClient />;
|
||||||
|
}
|
||||||
5
src/app/(protected)/income/page.tsx
Normal file
5
src/app/(protected)/income/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { IncomePageClient } from "@/components/income/IncomePageClient";
|
||||||
|
|
||||||
|
export default function IncomePage() {
|
||||||
|
return <IncomePageClient />;
|
||||||
|
}
|
||||||
17
src/app/(protected)/layout.tsx
Normal file
17
src/app/(protected)/layout.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { getAuthenticatedUser } from "@/lib/auth";
|
||||||
|
import { isInstanceConfigured } from "@/lib/database/setup";
|
||||||
|
|
||||||
|
export default async function ProtectedLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
if (!user) {
|
||||||
|
redirect((await isInstanceConfigured()) ? "/login" : "/setup");
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AppShell user={user}>{children}</AppShell>;
|
||||||
|
}
|
||||||
5
src/app/(protected)/one-time-payments/page.tsx
Normal file
5
src/app/(protected)/one-time-payments/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { OneTimePaymentsPageClient } from "@/components/one-time-payments/OneTimePaymentsPageClient";
|
||||||
|
|
||||||
|
export default function OneTimePaymentsPage() {
|
||||||
|
return <OneTimePaymentsPageClient />;
|
||||||
|
}
|
||||||
8
src/app/(protected)/profile/page.tsx
Normal file
8
src/app/(protected)/profile/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { ProfilePageClient } from "@/components/profile/ProfilePageClient";
|
||||||
|
import { getAuthenticatedUser } from "@/lib/auth";
|
||||||
|
|
||||||
|
export default async function ProfilePage() {
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
if (!user) return null;
|
||||||
|
return <ProfilePageClient user={user} />;
|
||||||
|
}
|
||||||
17
src/app/(protected)/reports/page.tsx
Normal file
17
src/app/(protected)/reports/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { ReportsPageClient } from "@/components/reports/ReportsPageClient";
|
||||||
|
|
||||||
|
export default async function ReportsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ year?: string }>;
|
||||||
|
}) {
|
||||||
|
const { year } = await searchParams;
|
||||||
|
return <ReportsPageClient initialYear={normalizeYear(year)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeYear(value?: string) {
|
||||||
|
const year = Number(value);
|
||||||
|
return Number.isInteger(year) && year >= 2000 && year <= 2100
|
||||||
|
? year
|
||||||
|
: new Date().getFullYear();
|
||||||
|
}
|
||||||
5
src/app/(protected)/subscriptions/page.tsx
Normal file
5
src/app/(protected)/subscriptions/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { SubscriptionsPageClient } from "@/components/subscriptions/SubscriptionsPageClient";
|
||||||
|
|
||||||
|
export default function SubscriptionsPage() {
|
||||||
|
return <SubscriptionsPageClient />;
|
||||||
|
}
|
||||||
11
src/app/(public)/login/page.tsx
Normal file
11
src/app/(public)/login/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { AuthForm } from "@/components/AuthForm";
|
||||||
|
import { getAuthenticatedUser } from "@/lib/auth";
|
||||||
|
import { isInstanceConfigured } from "@/lib/database/setup";
|
||||||
|
|
||||||
|
export default async function LoginPage() {
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
if (user) redirect("/dashboard");
|
||||||
|
if (!(await isInstanceConfigured())) redirect("/setup");
|
||||||
|
return <AuthForm />;
|
||||||
|
}
|
||||||
11
src/app/(public)/setup/page.tsx
Normal file
11
src/app/(public)/setup/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { SetupForm } from "@/components/SetupForm";
|
||||||
|
import { getAuthenticatedUser } from "@/lib/auth";
|
||||||
|
import { isInstanceConfigured } from "@/lib/database/setup";
|
||||||
|
|
||||||
|
export default async function SetupPage() {
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
if (user) redirect("/dashboard");
|
||||||
|
if (await isInstanceConfigured()) redirect("/login");
|
||||||
|
return <SetupForm />;
|
||||||
|
}
|
||||||
39
src/app/api/auth/login/route.ts
Normal file
39
src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { createSession } from "@/lib/auth";
|
||||||
|
import { database } from "@/lib/database/server";
|
||||||
|
import { ensureUserSettings } from "@/lib/profile/settings";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const email = String(body.email ?? "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const password = String(body.password ?? "");
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Email e password sono obbligatorie" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [user] = await database()<
|
||||||
|
{ id: string; password_hash: string }[]
|
||||||
|
>`SELECT id, password_hash FROM users WHERE email = ${email} LIMIT 1`;
|
||||||
|
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Credenziali non valide" },
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await createSession(user.id);
|
||||||
|
await ensureUserSettings(user.id);
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Accesso non riuscito";
|
||||||
|
return Response.json({ error: message }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/app/api/auth/logout/route.ts
Normal file
6
src/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { deleteCurrentSession } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
await deleteCurrentSession();
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
6
src/app/api/auth/session/route.ts
Normal file
6
src/app/api/auth/session/route.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { getAuthenticatedUser } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const user = await getAuthenticatedUser();
|
||||||
|
return Response.json({ authenticated: Boolean(user), user });
|
||||||
|
}
|
||||||
49
src/app/api/auth/setup/route.ts
Normal file
49
src/app/api/auth/setup/route.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { createSession } from "@/lib/auth";
|
||||||
|
import { database } from "@/lib/database/server";
|
||||||
|
import { ensureUserSettings } from "@/lib/profile/settings";
|
||||||
|
import { requireString } from "@/lib/validation/common";
|
||||||
|
|
||||||
|
class SetupCompletedError extends Error {}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await request.json()) as Record<string, unknown>;
|
||||||
|
const name = requireString(body.name, "name");
|
||||||
|
const email = requireString(body.email, "email").toLowerCase();
|
||||||
|
const password = requireString(body.password, "password");
|
||||||
|
if (!email.includes("@")) throw new Error("L'email non è valida");
|
||||||
|
if (password.length < 8) {
|
||||||
|
throw new Error("La password deve contenere almeno 8 caratteri");
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
const user = await database().begin(async (sql) => {
|
||||||
|
await sql`SELECT pg_advisory_xact_lock(727743)`;
|
||||||
|
const [existing] = await sql<[{ configured: boolean }]>`
|
||||||
|
SELECT EXISTS(SELECT 1 FROM users) AS configured
|
||||||
|
`;
|
||||||
|
if (existing.configured) throw new SetupCompletedError();
|
||||||
|
const [created] = await sql<[{ id: string }]>`
|
||||||
|
INSERT INTO users (email, name, password_hash)
|
||||||
|
VALUES (${email}, ${name}, ${passwordHash})
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
await createSession(user.id);
|
||||||
|
await ensureUserSettings(user.id);
|
||||||
|
return Response.json({ ok: true }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SetupCompletedError) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "L'istanza è già configurata" },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Configurazione non riuscita";
|
||||||
|
return Response.json({ error: message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/app/api/bills/[id]/route.ts
Normal file
55
src/app/api/bills/[id]/route.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import {
|
||||||
|
databaseConfig,
|
||||||
|
deleteDocument,
|
||||||
|
getOwnedDocument,
|
||||||
|
updateDocument,
|
||||||
|
} from "@/lib/database/server";
|
||||||
|
import { removeLinkedLedger, syncBillLedger } from "@/lib/finance/ledger";
|
||||||
|
import { parseBillInput } from "@/lib/validation/bills";
|
||||||
|
import { parseBody, updatedField } from "@/lib/validation/common";
|
||||||
|
import type { Bill } from "@/types/finance";
|
||||||
|
|
||||||
|
type Ctx = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export async function PATCH(request: Request, ctx: Ctx) {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const parsed = await parseBody(request, parseBillInput);
|
||||||
|
if (!parsed.ok)
|
||||||
|
return Response.json({ error: parsed.error }, { status: 400 });
|
||||||
|
const { collections } = databaseConfig();
|
||||||
|
const current = await getOwnedDocument<Bill>(
|
||||||
|
collections.bills,
|
||||||
|
id,
|
||||||
|
auth.user.id,
|
||||||
|
);
|
||||||
|
let bill = await updateDocument<Bill>(collections.bills, id, {
|
||||||
|
...parsed.data,
|
||||||
|
paymentDate: parsed.data.status === "paid" ? parsed.data.paymentDate : null,
|
||||||
|
linkedPaymentId: current.linkedPaymentId ?? null,
|
||||||
|
...updatedField(),
|
||||||
|
});
|
||||||
|
const ledger = await syncBillLedger(bill);
|
||||||
|
bill = await updateDocument<Bill>(collections.bills, id, {
|
||||||
|
linkedPaymentId: ledger?.$id ?? null,
|
||||||
|
...updatedField(),
|
||||||
|
});
|
||||||
|
return Response.json(bill);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: Request, ctx: Ctx) {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const { collections } = databaseConfig();
|
||||||
|
const bill = await getOwnedDocument<Bill>(
|
||||||
|
collections.bills,
|
||||||
|
id,
|
||||||
|
auth.user.id,
|
||||||
|
);
|
||||||
|
await removeLinkedLedger(auth.user.id, bill.linkedPaymentId, "bill", id);
|
||||||
|
await deleteDocument(collections.bills, id);
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
46
src/app/api/bills/route.ts
Normal file
46
src/app/api/bills/route.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import {
|
||||||
|
createDocument,
|
||||||
|
databaseConfig,
|
||||||
|
listDocuments,
|
||||||
|
query,
|
||||||
|
updateDocument,
|
||||||
|
} from "@/lib/database/server";
|
||||||
|
import { syncBillLedger } from "@/lib/finance/ledger";
|
||||||
|
import { parseBillInput } from "@/lib/validation/bills";
|
||||||
|
import { nowFields, parseBody } from "@/lib/validation/common";
|
||||||
|
import type { Bill } from "@/types/finance";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const { collections } = databaseConfig();
|
||||||
|
const result = await listDocuments<Bill>(collections.bills, [
|
||||||
|
query.equal("userId", auth.user.id),
|
||||||
|
query.orderAsc("dueDate"),
|
||||||
|
query.limit(5000),
|
||||||
|
]);
|
||||||
|
return Response.json(result.documents);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const parsed = await parseBody(request, parseBillInput);
|
||||||
|
if (!parsed.ok)
|
||||||
|
return Response.json({ error: parsed.error }, { status: 400 });
|
||||||
|
const { collections } = databaseConfig();
|
||||||
|
let bill = await createDocument<Bill>(collections.bills, {
|
||||||
|
...parsed.data,
|
||||||
|
userId: auth.user.id,
|
||||||
|
...nowFields(),
|
||||||
|
});
|
||||||
|
const ledger = await syncBillLedger(bill);
|
||||||
|
if (ledger && bill.linkedPaymentId !== ledger.$id) {
|
||||||
|
bill = await updateDocument<Bill>(collections.bills, bill.$id, {
|
||||||
|
linkedPaymentId: ledger.$id,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Response.json(bill, { status: 201 });
|
||||||
|
}
|
||||||
38
src/app/api/income/[id]/route.ts
Normal file
38
src/app/api/income/[id]/route.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import {
|
||||||
|
databaseConfig,
|
||||||
|
deleteDocument,
|
||||||
|
getOwnedDocument,
|
||||||
|
updateDocument,
|
||||||
|
} from "@/lib/database/server";
|
||||||
|
import { parseBody, updatedField } from "@/lib/validation/common";
|
||||||
|
import { parseIncomeInput } from "@/lib/validation/income";
|
||||||
|
import type { Income } from "@/types/finance";
|
||||||
|
|
||||||
|
type Ctx = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export async function PATCH(request: Request, ctx: Ctx) {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const parsed = await parseBody(request, parseIncomeInput);
|
||||||
|
if (!parsed.ok)
|
||||||
|
return Response.json({ error: parsed.error }, { status: 400 });
|
||||||
|
const { collections } = databaseConfig();
|
||||||
|
await getOwnedDocument<Income>(collections.income, id, auth.user.id);
|
||||||
|
const document = await updateDocument<Income>(collections.income, id, {
|
||||||
|
...parsed.data,
|
||||||
|
...updatedField(),
|
||||||
|
});
|
||||||
|
return Response.json(document);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: Request, ctx: Ctx) {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const { collections } = databaseConfig();
|
||||||
|
await getOwnedDocument<Income>(collections.income, id, auth.user.id);
|
||||||
|
await deleteDocument(collections.income, id);
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
37
src/app/api/income/route.ts
Normal file
37
src/app/api/income/route.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import {
|
||||||
|
createDocument,
|
||||||
|
databaseConfig,
|
||||||
|
listDocuments,
|
||||||
|
query,
|
||||||
|
} from "@/lib/database/server";
|
||||||
|
import { nowFields, parseBody } from "@/lib/validation/common";
|
||||||
|
import { parseIncomeInput } from "@/lib/validation/income";
|
||||||
|
import type { Income } from "@/types/finance";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const { collections } = databaseConfig();
|
||||||
|
const result = await listDocuments<Income>(collections.income, [
|
||||||
|
query.equal("userId", auth.user.id),
|
||||||
|
query.orderDesc("date"),
|
||||||
|
query.limit(5000),
|
||||||
|
]);
|
||||||
|
return Response.json(result.documents);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const parsed = await parseBody(request, parseIncomeInput);
|
||||||
|
if (!parsed.ok)
|
||||||
|
return Response.json({ error: parsed.error }, { status: 400 });
|
||||||
|
const { collections } = databaseConfig();
|
||||||
|
const document = await createDocument<Income>(collections.income, {
|
||||||
|
...parsed.data,
|
||||||
|
userId: auth.user.id,
|
||||||
|
...nowFields(),
|
||||||
|
});
|
||||||
|
return Response.json(document, { status: 201 });
|
||||||
|
}
|
||||||
67
src/app/api/one-time-payments/[id]/route.ts
Normal file
67
src/app/api/one-time-payments/[id]/route.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import {
|
||||||
|
databaseConfig,
|
||||||
|
deleteDocument,
|
||||||
|
getOwnedDocument,
|
||||||
|
updateDocument,
|
||||||
|
} from "@/lib/database/server";
|
||||||
|
import { removeLinkedLedger, syncOneTimeLedger } from "@/lib/finance/ledger";
|
||||||
|
import { parseBody, updatedField } from "@/lib/validation/common";
|
||||||
|
import { parseOneTimePaymentInput } from "@/lib/validation/oneTimePayments";
|
||||||
|
import type { OneTimePayment } from "@/types/finance";
|
||||||
|
|
||||||
|
type Ctx = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export async function PATCH(request: Request, ctx: Ctx) {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const parsed = await parseBody(request, parseOneTimePaymentInput);
|
||||||
|
if (!parsed.ok)
|
||||||
|
return Response.json({ error: parsed.error }, { status: 400 });
|
||||||
|
const { collections } = databaseConfig();
|
||||||
|
const current = await getOwnedDocument<OneTimePayment>(
|
||||||
|
collections.oneTimePayments,
|
||||||
|
id,
|
||||||
|
auth.user.id,
|
||||||
|
);
|
||||||
|
let payment = await updateDocument<OneTimePayment>(
|
||||||
|
collections.oneTimePayments,
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
...parsed.data,
|
||||||
|
linkedPaymentId: current.linkedPaymentId,
|
||||||
|
...updatedField(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const ledger = await syncOneTimeLedger(payment);
|
||||||
|
payment = await updateDocument<OneTimePayment>(
|
||||||
|
collections.oneTimePayments,
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
linkedPaymentId: ledger.$id,
|
||||||
|
...updatedField(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return Response.json(payment);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: Request, ctx: Ctx) {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const { collections } = databaseConfig();
|
||||||
|
const payment = await getOwnedDocument<OneTimePayment>(
|
||||||
|
collections.oneTimePayments,
|
||||||
|
id,
|
||||||
|
auth.user.id,
|
||||||
|
);
|
||||||
|
await removeLinkedLedger(
|
||||||
|
auth.user.id,
|
||||||
|
payment.linkedPaymentId,
|
||||||
|
"one_time",
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
await deleteDocument(collections.oneTimePayments, id);
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
54
src/app/api/one-time-payments/route.ts
Normal file
54
src/app/api/one-time-payments/route.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import {
|
||||||
|
createDocument,
|
||||||
|
databaseConfig,
|
||||||
|
listDocuments,
|
||||||
|
query,
|
||||||
|
updateDocument,
|
||||||
|
} from "@/lib/database/server";
|
||||||
|
import { syncOneTimeLedger } from "@/lib/finance/ledger";
|
||||||
|
import { nowFields, parseBody } from "@/lib/validation/common";
|
||||||
|
import { parseOneTimePaymentInput } from "@/lib/validation/oneTimePayments";
|
||||||
|
import type { OneTimePayment } from "@/types/finance";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const { collections } = databaseConfig();
|
||||||
|
const result = await listDocuments<OneTimePayment>(
|
||||||
|
collections.oneTimePayments,
|
||||||
|
[
|
||||||
|
query.equal("userId", auth.user.id),
|
||||||
|
query.orderDesc("date"),
|
||||||
|
query.limit(5000),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return Response.json(result.documents);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const parsed = await parseBody(request, parseOneTimePaymentInput);
|
||||||
|
if (!parsed.ok)
|
||||||
|
return Response.json({ error: parsed.error }, { status: 400 });
|
||||||
|
const { collections } = databaseConfig();
|
||||||
|
let payment = await createDocument<OneTimePayment>(
|
||||||
|
collections.oneTimePayments,
|
||||||
|
{
|
||||||
|
...parsed.data,
|
||||||
|
userId: auth.user.id,
|
||||||
|
...nowFields(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const ledger = await syncOneTimeLedger(payment);
|
||||||
|
payment = await updateDocument<OneTimePayment>(
|
||||||
|
collections.oneTimePayments,
|
||||||
|
payment.$id,
|
||||||
|
{
|
||||||
|
linkedPaymentId: ledger.$id,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return Response.json(payment, { status: 201 });
|
||||||
|
}
|
||||||
49
src/app/api/payment-titles/route.ts
Normal file
49
src/app/api/payment-titles/route.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import {
|
||||||
|
createDocument,
|
||||||
|
databaseConfig,
|
||||||
|
listDocuments,
|
||||||
|
query,
|
||||||
|
} from "@/lib/database/server";
|
||||||
|
import { nowFields, parseBody } from "@/lib/validation/common";
|
||||||
|
import { parsePaymentTitleInput } from "@/lib/validation/paymentTitles";
|
||||||
|
import type { PaymentTitle } from "@/types/finance";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const { collections } = databaseConfig();
|
||||||
|
const result = await listDocuments<PaymentTitle>(collections.paymentTitles, [
|
||||||
|
query.equal("userId", auth.user.id),
|
||||||
|
query.orderAsc("normalizedName"),
|
||||||
|
]);
|
||||||
|
return Response.json(result.documents);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const parsed = await parseBody(request, parsePaymentTitleInput);
|
||||||
|
if (!parsed.ok)
|
||||||
|
return Response.json({ error: parsed.error }, { status: 400 });
|
||||||
|
const { collections } = databaseConfig();
|
||||||
|
const existing = await listDocuments<PaymentTitle>(
|
||||||
|
collections.paymentTitles,
|
||||||
|
[
|
||||||
|
query.equal("userId", auth.user.id),
|
||||||
|
query.equal("normalizedName", parsed.data.normalizedName),
|
||||||
|
query.equal("paymentType", parsed.data.paymentType),
|
||||||
|
query.limit(1),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (existing.documents[0]) return Response.json(existing.documents[0]);
|
||||||
|
const document = await createDocument<PaymentTitle>(
|
||||||
|
collections.paymentTitles,
|
||||||
|
{
|
||||||
|
...parsed.data,
|
||||||
|
userId: auth.user.id,
|
||||||
|
...nowFields(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return Response.json(document, { status: 201 });
|
||||||
|
}
|
||||||
18
src/app/api/payments-ledger/route.ts
Normal file
18
src/app/api/payments-ledger/route.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import { databaseConfig, listDocuments, query } from "@/lib/database/server";
|
||||||
|
import type { PaymentLedger } from "@/types/finance";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const { collections } = databaseConfig();
|
||||||
|
const result = await listDocuments<PaymentLedger>(
|
||||||
|
collections.paymentsLedger,
|
||||||
|
[
|
||||||
|
query.equal("userId", auth.user.id),
|
||||||
|
query.orderDesc("date"),
|
||||||
|
query.limit(5000),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return Response.json(result.documents);
|
||||||
|
}
|
||||||
120
src/app/api/profile/account/route.ts
Normal file
120
src/app/api/profile/account/route.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { clearSessionSecret, requireUser } from "@/lib/auth";
|
||||||
|
import { database } from "@/lib/database/server";
|
||||||
|
import { deleteAvatar } from "@/lib/profile/avatarStorage";
|
||||||
|
import {
|
||||||
|
parseAccountPatch,
|
||||||
|
parseDeleteAccount,
|
||||||
|
parsePasswordPatch,
|
||||||
|
} from "@/lib/validation/account";
|
||||||
|
import { parseBody } from "@/lib/validation/common";
|
||||||
|
import type { UserSession } from "@/types/finance";
|
||||||
|
|
||||||
|
export async function PATCH(request: Request) {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const parsed = await parseBody(request, parseAccountPatch);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
return Response.json({ error: parsed.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [user] = await database()<UserSession[]>`
|
||||||
|
UPDATE users
|
||||||
|
SET name = ${parsed.data.name},
|
||||||
|
email = ${parsed.data.email},
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = ${auth.user.id}
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
CASE
|
||||||
|
WHEN avatar_file IS NULL THEN NULL
|
||||||
|
ELSE '/api/profile/avatar?v=' || EXTRACT(EPOCH FROM updated_at)::bigint::text
|
||||||
|
END AS "avatarUrl"
|
||||||
|
`;
|
||||||
|
return Response.json(user);
|
||||||
|
} catch (error) {
|
||||||
|
if (hasDatabaseCode(error, "23505")) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Esiste già un account con questa email" },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Impossibile aggiornare i dati account" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const parsed = await parseBody(request, parsePasswordPatch);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
return Response.json({ error: parsed.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
const [user] = await database()<{ password_hash: string }[]>`
|
||||||
|
SELECT password_hash
|
||||||
|
FROM users
|
||||||
|
WHERE id = ${auth.user.id}
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
if (
|
||||||
|
!user ||
|
||||||
|
!(await bcrypt.compare(parsed.data.currentPassword, user.password_hash))
|
||||||
|
) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "La password attuale non è corretta" },
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const passwordHash = await bcrypt.hash(parsed.data.newPassword, 12);
|
||||||
|
await database()`
|
||||||
|
UPDATE users
|
||||||
|
SET password_hash = ${passwordHash},
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = ${auth.user.id}
|
||||||
|
`;
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const parsed = await parseBody(request, parseDeleteAccount);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
return Response.json({ error: parsed.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
const [user] = await database()<
|
||||||
|
{ avatar_file: string | null; password_hash: string }[]
|
||||||
|
>`
|
||||||
|
SELECT avatar_file, password_hash
|
||||||
|
FROM users
|
||||||
|
WHERE id = ${auth.user.id}
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
if (
|
||||||
|
!user ||
|
||||||
|
!(await bcrypt.compare(parsed.data.currentPassword, user.password_hash))
|
||||||
|
) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "La password attuale non è corretta" },
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await database()`DELETE FROM users WHERE id = ${auth.user.id}`;
|
||||||
|
await clearSessionSecret();
|
||||||
|
await deleteAvatar(user.avatar_file).catch(() => undefined);
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDatabaseCode(error: unknown, code: string) {
|
||||||
|
return (
|
||||||
|
typeof error === "object" &&
|
||||||
|
error !== null &&
|
||||||
|
"code" in error &&
|
||||||
|
error.code === code
|
||||||
|
);
|
||||||
|
}
|
||||||
98
src/app/api/profile/avatar/route.ts
Normal file
98
src/app/api/profile/avatar/route.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import { database } from "@/lib/database/server";
|
||||||
|
import {
|
||||||
|
avatarContentType,
|
||||||
|
deleteAvatar,
|
||||||
|
readAvatar,
|
||||||
|
saveAvatar,
|
||||||
|
} from "@/lib/profile/avatarStorage";
|
||||||
|
import type { UserSession } from "@/types/finance";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const [user] = await database()<{ avatar_file: string | null }[]>`
|
||||||
|
SELECT avatar_file
|
||||||
|
FROM users
|
||||||
|
WHERE id = ${auth.user.id}
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
if (!user?.avatar_file) {
|
||||||
|
return Response.json({ error: "Avatar non impostato" }, { status: 404 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new Response(await readAvatar(user.avatar_file), {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "private, no-cache",
|
||||||
|
"Content-Type": avatarContentType(user.avatar_file),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Avatar non disponibile" }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
try {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get("avatar");
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return Response.json({ error: "Seleziona un'immagine" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const [current] = await database()<{ avatar_file: string | null }[]>`
|
||||||
|
SELECT avatar_file
|
||||||
|
FROM users
|
||||||
|
WHERE id = ${auth.user.id}
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
const filename = await saveAvatar(auth.user.id, file);
|
||||||
|
const user = await updateAvatar(auth.user.id, filename);
|
||||||
|
if (current?.avatar_file && current.avatar_file !== filename) {
|
||||||
|
await deleteAvatar(current.avatar_file);
|
||||||
|
}
|
||||||
|
return Response.json(user);
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: errorMessage(error, "Impossibile salvare l'avatar") },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE() {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const [current] = await database()<{ avatar_file: string | null }[]>`
|
||||||
|
SELECT avatar_file
|
||||||
|
FROM users
|
||||||
|
WHERE id = ${auth.user.id}
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
const user = await updateAvatar(auth.user.id, null);
|
||||||
|
await deleteAvatar(current?.avatar_file);
|
||||||
|
return Response.json(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateAvatar(userId: string, filename: string | null) {
|
||||||
|
const [user] = await database()<UserSession[]>`
|
||||||
|
UPDATE users
|
||||||
|
SET avatar_file = ${filename},
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = ${userId}
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
CASE
|
||||||
|
WHEN avatar_file IS NULL THEN NULL
|
||||||
|
ELSE '/api/profile/avatar?v=' || EXTRACT(EPOCH FROM updated_at)::bigint::text
|
||||||
|
END AS "avatarUrl"
|
||||||
|
`;
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown, fallback: string) {
|
||||||
|
return error instanceof Error ? error.message : fallback;
|
||||||
|
}
|
||||||
37
src/app/api/profile/data/route.ts
Normal file
37
src/app/api/profile/data/route.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import {
|
||||||
|
databaseConfig,
|
||||||
|
deleteDocument,
|
||||||
|
listDocuments,
|
||||||
|
query,
|
||||||
|
} from "@/lib/database/server";
|
||||||
|
|
||||||
|
export async function DELETE() {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
await deleteFinancialData(auth.user.id);
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFinancialData(userId: string) {
|
||||||
|
const { collections } = databaseConfig();
|
||||||
|
const collectionIds = [
|
||||||
|
collections.subscriptionPayments,
|
||||||
|
collections.paymentsLedger,
|
||||||
|
collections.subscriptions,
|
||||||
|
collections.bills,
|
||||||
|
collections.oneTimePayments,
|
||||||
|
collections.paymentTitles,
|
||||||
|
collections.income,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const collectionId of collectionIds) {
|
||||||
|
const result = await listDocuments<{ $id: string }>(collectionId, [
|
||||||
|
query.equal("userId", userId),
|
||||||
|
query.limit(5000),
|
||||||
|
]);
|
||||||
|
for (const document of result.documents) {
|
||||||
|
await deleteDocument(collectionId, document.$id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/app/api/profile/settings/route.ts
Normal file
19
src/app/api/profile/settings/route.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import { ensureUserSettings, saveUserSettings } from "@/lib/profile/settings";
|
||||||
|
import { parseBody } from "@/lib/validation/common";
|
||||||
|
import { parseUserSettingsPatch } from "@/lib/validation/userSettings";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
return Response.json(await ensureUserSettings(auth.user.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: Request) {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const parsed = await parseBody(request, parseUserSettingsPatch);
|
||||||
|
if (!parsed.ok)
|
||||||
|
return Response.json({ error: parsed.error }, { status: 400 });
|
||||||
|
return Response.json(await saveUserSettings(auth.user.id, parsed.data));
|
||||||
|
}
|
||||||
79
src/app/api/subscription-payments/[id]/route.ts
Normal file
79
src/app/api/subscription-payments/[id]/route.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import {
|
||||||
|
databaseConfig,
|
||||||
|
deleteDocument,
|
||||||
|
getOwnedDocument,
|
||||||
|
updateDocument,
|
||||||
|
} from "@/lib/database/server";
|
||||||
|
import {
|
||||||
|
removeLinkedLedger,
|
||||||
|
syncSubscriptionLedger,
|
||||||
|
} from "@/lib/finance/ledger";
|
||||||
|
import { requireDate, updatedField } from "@/lib/validation/common";
|
||||||
|
import type { Subscription, SubscriptionPayment } from "@/types/finance";
|
||||||
|
|
||||||
|
type Ctx = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export async function PATCH(request: Request, ctx: Ctx) {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const body = (await request.json()) as Record<string, unknown>;
|
||||||
|
const status = body.status === "paid" ? "paid" : "unpaid";
|
||||||
|
const { collections } = databaseConfig();
|
||||||
|
const current = await getOwnedDocument<SubscriptionPayment>(
|
||||||
|
collections.subscriptionPayments,
|
||||||
|
id,
|
||||||
|
auth.user.id,
|
||||||
|
);
|
||||||
|
const subscription = await getOwnedDocument<Subscription>(
|
||||||
|
collections.subscriptions,
|
||||||
|
current.subscriptionId,
|
||||||
|
auth.user.id,
|
||||||
|
);
|
||||||
|
let payment = await updateDocument<SubscriptionPayment>(
|
||||||
|
collections.subscriptionPayments,
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
status,
|
||||||
|
paymentDate:
|
||||||
|
status === "paid"
|
||||||
|
? body.paymentDate
|
||||||
|
? requireDate(body.paymentDate, "paymentDate")
|
||||||
|
: current.renewalDate
|
||||||
|
: "",
|
||||||
|
linkedPaymentId: current.linkedPaymentId,
|
||||||
|
...updatedField(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const ledger = await syncSubscriptionLedger(subscription, payment);
|
||||||
|
payment = await updateDocument<SubscriptionPayment>(
|
||||||
|
collections.subscriptionPayments,
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
linkedPaymentId: ledger?.$id ?? "",
|
||||||
|
...updatedField(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return Response.json(payment);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: Request, ctx: Ctx) {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const { collections } = databaseConfig();
|
||||||
|
const payment = await getOwnedDocument<SubscriptionPayment>(
|
||||||
|
collections.subscriptionPayments,
|
||||||
|
id,
|
||||||
|
auth.user.id,
|
||||||
|
);
|
||||||
|
await removeLinkedLedger(
|
||||||
|
auth.user.id,
|
||||||
|
payment.linkedPaymentId,
|
||||||
|
"subscription",
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
await deleteDocument(collections.subscriptionPayments, id);
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
160
src/app/api/subscription-payments/route.ts
Normal file
160
src/app/api/subscription-payments/route.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import {
|
||||||
|
createDocument,
|
||||||
|
DatabaseRequestError,
|
||||||
|
databaseConfig,
|
||||||
|
getOwnedDocument,
|
||||||
|
listDocuments,
|
||||||
|
query,
|
||||||
|
updateDocument,
|
||||||
|
} from "@/lib/database/server";
|
||||||
|
import {
|
||||||
|
removeLinkedLedger,
|
||||||
|
syncSubscriptionLedger,
|
||||||
|
} from "@/lib/finance/ledger";
|
||||||
|
import { isScheduledSubscriptionOccurrence } from "@/lib/finance/subscriptionOccurrences";
|
||||||
|
import {
|
||||||
|
requireDate,
|
||||||
|
requireString,
|
||||||
|
updatedField,
|
||||||
|
} from "@/lib/validation/common";
|
||||||
|
import type { Subscription, SubscriptionPayment } from "@/types/finance";
|
||||||
|
|
||||||
|
function parsePayment(body: Record<string, unknown>) {
|
||||||
|
const status = body.status === "paid" ? "paid" : "unpaid";
|
||||||
|
return {
|
||||||
|
subscriptionId: requireString(body.subscriptionId, "subscriptionId"),
|
||||||
|
renewalDate: requireDate(body.renewalDate, "renewalDate"),
|
||||||
|
status,
|
||||||
|
paymentDate:
|
||||||
|
status === "paid"
|
||||||
|
? body.paymentDate
|
||||||
|
? requireDate(body.paymentDate, "paymentDate")
|
||||||
|
: requireDate(body.renewalDate, "renewalDate")
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findPayment(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: string,
|
||||||
|
renewalDate: string,
|
||||||
|
) {
|
||||||
|
const { collections } = databaseConfig();
|
||||||
|
const result = await listDocuments<SubscriptionPayment>(
|
||||||
|
collections.subscriptionPayments,
|
||||||
|
[
|
||||||
|
query.equal("subscriptionId", subscriptionId),
|
||||||
|
query.equal("renewalDate", renewalDate),
|
||||||
|
query.equal("userId", userId),
|
||||||
|
query.limit(1),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return result.documents[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const { collections } = databaseConfig();
|
||||||
|
const result = await listDocuments<SubscriptionPayment>(
|
||||||
|
collections.subscriptionPayments,
|
||||||
|
[
|
||||||
|
query.equal("userId", auth.user.id),
|
||||||
|
query.orderDesc("renewalDate"),
|
||||||
|
query.limit(5000),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return Response.json(result.documents);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const userId = auth.user.id;
|
||||||
|
try {
|
||||||
|
const body = (await request.json()) as Record<string, unknown>;
|
||||||
|
const parsed = parsePayment(body);
|
||||||
|
const { collections } = databaseConfig();
|
||||||
|
const subscription = await getOwnedDocument<Subscription>(
|
||||||
|
collections.subscriptions,
|
||||||
|
parsed.subscriptionId,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
const existing = await findPayment(
|
||||||
|
userId,
|
||||||
|
parsed.subscriptionId,
|
||||||
|
parsed.renewalDate,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!existing &&
|
||||||
|
!isScheduledSubscriptionOccurrence(subscription, parsed.renewalDate)
|
||||||
|
) {
|
||||||
|
throw new Error("Il rinnovo richiesto non è previsto");
|
||||||
|
}
|
||||||
|
let payment = existing
|
||||||
|
? await updatePayment(existing, parsed)
|
||||||
|
: await createPayment(parsed).catch(async (error) => {
|
||||||
|
if (
|
||||||
|
!(error instanceof DatabaseRequestError) ||
|
||||||
|
error.status !== 409
|
||||||
|
) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const winner = await findPayment(
|
||||||
|
userId,
|
||||||
|
parsed.subscriptionId,
|
||||||
|
parsed.renewalDate,
|
||||||
|
);
|
||||||
|
if (!winner) throw error;
|
||||||
|
return updatePayment(winner, parsed);
|
||||||
|
});
|
||||||
|
const ledger = await syncSubscriptionLedger(subscription, payment);
|
||||||
|
if (parsed.status === "unpaid") {
|
||||||
|
await removeLinkedLedger(
|
||||||
|
userId,
|
||||||
|
payment.linkedPaymentId,
|
||||||
|
"subscription",
|
||||||
|
payment.$id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
payment = await updateDocument<SubscriptionPayment>(
|
||||||
|
collections.subscriptionPayments,
|
||||||
|
payment.$id,
|
||||||
|
{
|
||||||
|
linkedPaymentId: ledger?.$id ?? "",
|
||||||
|
paymentDate: parsed.status === "paid" ? payment.paymentDate : "",
|
||||||
|
...updatedField(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return Response.json(payment, { status: existing ? 200 : 201 });
|
||||||
|
|
||||||
|
function updatePayment(current: SubscriptionPayment, data: typeof parsed) {
|
||||||
|
return updateDocument<SubscriptionPayment>(
|
||||||
|
collections.subscriptionPayments,
|
||||||
|
current.$id,
|
||||||
|
{
|
||||||
|
...data,
|
||||||
|
linkedPaymentId: current.linkedPaymentId,
|
||||||
|
...updatedField(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPayment(data: typeof parsed) {
|
||||||
|
return createDocument<SubscriptionPayment>(
|
||||||
|
collections.subscriptionPayments,
|
||||||
|
{
|
||||||
|
...data,
|
||||||
|
userId,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Richiesta non valida";
|
||||||
|
return Response.json({ error: message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/app/api/subscriptions/[id]/route.ts
Normal file
79
src/app/api/subscriptions/[id]/route.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import {
|
||||||
|
databaseConfig,
|
||||||
|
deleteDocument,
|
||||||
|
getOwnedDocument,
|
||||||
|
listDocuments,
|
||||||
|
query,
|
||||||
|
updateDocument,
|
||||||
|
} from "@/lib/database/server";
|
||||||
|
import { removeLinkedLedger } from "@/lib/finance/ledger";
|
||||||
|
import { parseBody, updatedField } from "@/lib/validation/common";
|
||||||
|
import { parseSubscriptionPatch } from "@/lib/validation/subscriptions";
|
||||||
|
import type { Subscription, SubscriptionPayment } from "@/types/finance";
|
||||||
|
|
||||||
|
type Ctx = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export async function PATCH(request: Request, ctx: Ctx) {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const parsed = await parseBody(request, parseSubscriptionPatch);
|
||||||
|
if (!parsed.ok)
|
||||||
|
return Response.json({ error: parsed.error }, { status: 400 });
|
||||||
|
const { collections } = databaseConfig();
|
||||||
|
const current = await getOwnedDocument<Subscription>(
|
||||||
|
collections.subscriptions,
|
||||||
|
id,
|
||||||
|
auth.user.id,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
current.subscriptionStatus === "terminated" &&
|
||||||
|
parsed.data.subscriptionStatus !== "terminated"
|
||||||
|
) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Un abbonamento terminato non può essere riattivato" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const document = await updateDocument<Subscription>(
|
||||||
|
collections.subscriptions,
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
...parsed.data,
|
||||||
|
...updatedField(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return Response.json(document);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: Request, ctx: Ctx) {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const { id } = await ctx.params;
|
||||||
|
const { collections } = databaseConfig();
|
||||||
|
await getOwnedDocument<Subscription>(
|
||||||
|
collections.subscriptions,
|
||||||
|
id,
|
||||||
|
auth.user.id,
|
||||||
|
);
|
||||||
|
const payments = await listDocuments<SubscriptionPayment>(
|
||||||
|
collections.subscriptionPayments,
|
||||||
|
[
|
||||||
|
query.equal("userId", auth.user.id),
|
||||||
|
query.equal("subscriptionId", id),
|
||||||
|
query.limit(5000),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
for (const payment of payments.documents) {
|
||||||
|
await removeLinkedLedger(
|
||||||
|
auth.user.id,
|
||||||
|
payment.linkedPaymentId,
|
||||||
|
"subscription",
|
||||||
|
payment.$id,
|
||||||
|
);
|
||||||
|
await deleteDocument(collections.subscriptionPayments, payment.$id);
|
||||||
|
}
|
||||||
|
await deleteDocument(collections.subscriptions, id);
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
40
src/app/api/subscriptions/route.ts
Normal file
40
src/app/api/subscriptions/route.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import {
|
||||||
|
createDocument,
|
||||||
|
databaseConfig,
|
||||||
|
listDocuments,
|
||||||
|
query,
|
||||||
|
} from "@/lib/database/server";
|
||||||
|
import { nowFields, parseBody } from "@/lib/validation/common";
|
||||||
|
import { parseSubscriptionInput } from "@/lib/validation/subscriptions";
|
||||||
|
import type { Subscription } from "@/types/finance";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const { collections } = databaseConfig();
|
||||||
|
const result = await listDocuments<Subscription>(collections.subscriptions, [
|
||||||
|
query.equal("userId", auth.user.id),
|
||||||
|
query.orderAsc("firstPaymentDate"),
|
||||||
|
query.limit(5000),
|
||||||
|
]);
|
||||||
|
return Response.json(result.documents);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const auth = await requireUser();
|
||||||
|
if ("response" in auth) return auth.response;
|
||||||
|
const parsed = await parseBody(request, parseSubscriptionInput);
|
||||||
|
if (!parsed.ok)
|
||||||
|
return Response.json({ error: parsed.error }, { status: 400 });
|
||||||
|
const { collections } = databaseConfig();
|
||||||
|
const document = await createDocument<Subscription>(
|
||||||
|
collections.subscriptions,
|
||||||
|
{
|
||||||
|
...parsed.data,
|
||||||
|
userId: auth.user.id,
|
||||||
|
...nowFields(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return Response.json(document, { status: 201 });
|
||||||
|
}
|
||||||
@@ -1,26 +1,85 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
/* Riserva sempre lo spazio della scrollbar */
|
||||||
--background: #ffffff;
|
html {
|
||||||
--foreground: #171717;
|
overflow-y: auto;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
/* Firefox */
|
||||||
--color-background: var(--background);
|
* {
|
||||||
--color-foreground: var(--foreground);
|
scrollbar-width: thin;
|
||||||
--font-sans: var(--font-geist-sans);
|
scrollbar-color: rgba(255, 255, 255, 0.25) transparent;
|
||||||
--font-mono: var(--font-geist-mono);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
/* Chrome, Edge, Brave, Opera */
|
||||||
:root {
|
::-webkit-scrollbar {
|
||||||
--background: #0a0a0a;
|
width: 10px;
|
||||||
--foreground: #ededed;
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.35);
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:active {
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modal-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px) scale(0.98);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
@keyframes modal-out {
|
||||||
background: var(--background);
|
from {
|
||||||
color: var(--foreground);
|
opacity: 1;
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px) scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modal-backdrop-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modal-backdrop-out {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "MyMoney",
|
||||||
description: "Generated by create next app",
|
description:
|
||||||
|
"Tieni traccia delle tue finanze con MyMoney, in modo semplice ed efficace.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -24,10 +25,10 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="it"
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
<body className="min-h-full bg-[#182033]">{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +1,9 @@
|
|||||||
import Image from "next/image";
|
import { redirect } from "next/navigation";
|
||||||
|
import { getAuthenticatedUser } from "@/lib/auth";
|
||||||
|
import { isInstanceConfigured } from "@/lib/database/setup";
|
||||||
|
|
||||||
export default function Home() {
|
export default async function Home() {
|
||||||
return (
|
const user = await getAuthenticatedUser();
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
if (user) redirect("/dashboard");
|
||||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
redirect((await isInstanceConfigured()) ? "/login" : "/setup");
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={100}
|
|
||||||
height={20}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the page.tsx file.
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
12
src/components/AppShell.tsx
Normal file
12
src/components/AppShell.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Header } from "@/components/shared/Header";
|
||||||
|
import type { UserSession } from "@/types/finance";
|
||||||
|
|
||||||
|
export function AppShell({
|
||||||
|
user,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
user: UserSession;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <Header user={user}>{children}</Header>;
|
||||||
|
}
|
||||||
75
src/components/AuthForm.tsx
Normal file
75
src/components/AuthForm.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { inputClass } from "@/components/shared/Field";
|
||||||
|
|
||||||
|
export function AuthForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function submit(event: React.FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
const response = await fetch("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
setLoading(false);
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(payload.error ?? "Accesso non riuscito");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push("/dashboard");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="grid min-h-screen place-items-center bg-[#182033] p-4 text-slate-100">
|
||||||
|
<form
|
||||||
|
className="w-full max-w-sm rounded-lg border border-white/10 bg-slate-950 p-6"
|
||||||
|
onSubmit={submit}
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex flex-row items-center gap-2">
|
||||||
|
<Image src="/logo.png" alt="MyMoney" width={32} height={32} />
|
||||||
|
<h1 className="text-2xl font-semibold text-white">MyMoney</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
|
Accedi a MyMoney - l'app per tracciare le tue finanze.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 grid gap-4">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="Email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="Password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
{error ? <p className="text-sm text-rose-300">{error}</p> : null}
|
||||||
|
<button
|
||||||
|
className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white hover:bg-sky-400 disabled:opacity-60"
|
||||||
|
disabled={loading}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{loading ? "Accesso..." : "Accedi"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
src/components/SetupForm.tsx
Normal file
82
src/components/SetupForm.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { inputClass } from "@/components/shared/Field";
|
||||||
|
|
||||||
|
export function SetupForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function submit(event: React.FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
const response = await fetch("/api/auth/setup", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name, email, password }),
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
setLoading(false);
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(payload.error ?? "Configurazione non riuscita");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push("/dashboard");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="grid min-h-screen place-items-center bg-[#182033] p-4 text-slate-100">
|
||||||
|
<form
|
||||||
|
className="w-full max-w-sm rounded-lg border border-white/10 bg-slate-950 p-6"
|
||||||
|
onSubmit={submit}
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex flex-row items-center gap-2">
|
||||||
|
<Image src="/logo.png" alt="MyMoney" width={32} height={32} />
|
||||||
|
<h1 className="text-2xl font-semibold text-white">MyMoney</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
|
Configura il primo account per iniziare a usare la tua istanza.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 grid gap-4">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="Nome"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="Email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="Password (almeno 8 caratteri)"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
{error ? <p className="text-sm text-rose-300">{error}</p> : null}
|
||||||
|
<button
|
||||||
|
className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white hover:bg-sky-400 disabled:opacity-60"
|
||||||
|
disabled={loading}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{loading ? "Configurazione..." : "Crea account"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
162
src/components/bills/BillForm.tsx
Normal file
162
src/components/bills/BillForm.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { DateField } from "@/components/shared/DateField";
|
||||||
|
import { Field, inputClass } from "@/components/shared/Field";
|
||||||
|
import { Select } from "@/components/shared/Select";
|
||||||
|
import {
|
||||||
|
SimpleFormActions,
|
||||||
|
SimpleFormReview,
|
||||||
|
SimpleFormStepIndicator,
|
||||||
|
} from "@/components/shared/SimpleFormSteps";
|
||||||
|
import { TitleCombobox } from "@/components/shared/TitleCombobox";
|
||||||
|
import { formatItalianDate, todayIso } from "@/lib/date/formatDate";
|
||||||
|
import { paymentStatusLabel } from "@/lib/finance/labels";
|
||||||
|
import {
|
||||||
|
euroFormatter,
|
||||||
|
formatMoneyInput,
|
||||||
|
parseMoneyInput,
|
||||||
|
} from "@/lib/finance/money";
|
||||||
|
import type { Bill, PaymentTitle } from "@/types/finance";
|
||||||
|
|
||||||
|
export function BillForm({
|
||||||
|
titles,
|
||||||
|
editing,
|
||||||
|
onSubmit,
|
||||||
|
onCreateTitle,
|
||||||
|
onCancel,
|
||||||
|
defaultDate = todayIso(),
|
||||||
|
saving = false,
|
||||||
|
}: {
|
||||||
|
titles: PaymentTitle[];
|
||||||
|
editing?: Bill;
|
||||||
|
onSubmit: (data: object) => Promise<void>;
|
||||||
|
onCreateTitle?: (name: string) => Promise<void>;
|
||||||
|
onCancel?: () => void;
|
||||||
|
defaultDate?: string;
|
||||||
|
saving?: boolean;
|
||||||
|
}) {
|
||||||
|
const [review, setReview] = useState(false);
|
||||||
|
const [title, setTitle] = useState(editing?.title ?? "");
|
||||||
|
const [amount, setAmount] = useState(formatMoneyInput(editing?.amount));
|
||||||
|
const [dueDate, setDueDate] = useState(editing?.dueDate ?? defaultDate);
|
||||||
|
const [status, setStatus] = useState(editing?.status ?? "pending");
|
||||||
|
const [paymentDate, setPaymentDate] = useState(
|
||||||
|
editing?.paymentDate ?? todayIso(),
|
||||||
|
);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setReview(false);
|
||||||
|
setTitle(editing?.title ?? "");
|
||||||
|
setAmount(formatMoneyInput(editing?.amount));
|
||||||
|
setDueDate(editing?.dueDate ?? defaultDate);
|
||||||
|
setStatus(editing?.status ?? "pending");
|
||||||
|
setPaymentDate(editing?.paymentDate ?? todayIso());
|
||||||
|
setError("");
|
||||||
|
}, [defaultDate, editing]);
|
||||||
|
|
||||||
|
function payload() {
|
||||||
|
if (!title.trim()) throw new Error("Inserisci il titolo della bolletta");
|
||||||
|
if (!dueDate) throw new Error("Inserisci la scadenza della bolletta");
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
amount: parseMoneyInput(amount),
|
||||||
|
dueDate,
|
||||||
|
status,
|
||||||
|
paymentDate: status === "paid" ? paymentDate : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
setError("");
|
||||||
|
const data = payload();
|
||||||
|
if (!review) {
|
||||||
|
setReview(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onSubmit(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Salvataggio non riuscito",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SimpleFormStepIndicator review={review} />
|
||||||
|
{review ? (
|
||||||
|
<SimpleFormReview
|
||||||
|
rows={[
|
||||||
|
{
|
||||||
|
label: "Importo",
|
||||||
|
value: euroFormatter.format(parseMoneyInput(amount)),
|
||||||
|
},
|
||||||
|
{ label: "Scadenza", value: formatItalianDate(dueDate) },
|
||||||
|
{ label: "Stato", value: paymentStatusLabel(status) },
|
||||||
|
...(status === "paid"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: "Data pagamento",
|
||||||
|
value: formatItalianDate(paymentDate),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]}
|
||||||
|
title={title}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<TitleCombobox
|
||||||
|
titles={titles}
|
||||||
|
type="bill"
|
||||||
|
value={title}
|
||||||
|
onChange={setTitle}
|
||||||
|
onCreate={onCreateTitle}
|
||||||
|
/>
|
||||||
|
<Field label="Importo">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
value={amount}
|
||||||
|
onChange={(event) => setAmount(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<DateField
|
||||||
|
label="Scadenza"
|
||||||
|
name="dueDate"
|
||||||
|
value={dueDate}
|
||||||
|
onChange={setDueDate}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Stato"
|
||||||
|
value={status}
|
||||||
|
onChange={(value) => setStatus(value as typeof status)}
|
||||||
|
options={[
|
||||||
|
{ value: "pending", label: "In scadenza" },
|
||||||
|
{ value: "paid", label: "Pagata" },
|
||||||
|
{ value: "overdue", label: "Scaduta" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{status === "paid" ? (
|
||||||
|
<DateField
|
||||||
|
label="Data pagamento"
|
||||||
|
name="paymentDate"
|
||||||
|
value={paymentDate}
|
||||||
|
onChange={setPaymentDate}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error ? <p className="mt-4 text-sm text-rose-300">{error}</p> : null}
|
||||||
|
<SimpleFormActions
|
||||||
|
onBack={() => setReview(false)}
|
||||||
|
onCancel={onCancel}
|
||||||
|
review={review}
|
||||||
|
saving={saving}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
src/components/bills/BillList.tsx
Normal file
109
src/components/bills/BillList.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
LuCalendarClock,
|
||||||
|
LuCircleCheck,
|
||||||
|
LuCircleX,
|
||||||
|
LuPencil,
|
||||||
|
LuTrash2,
|
||||||
|
} from "react-icons/lu";
|
||||||
|
import { BillStatusBadge } from "@/components/bills/BillStatusBadge";
|
||||||
|
import { EmptyState } from "@/components/shared/EmptyState";
|
||||||
|
import { financeListCardClass } from "@/components/shared/financeListCard";
|
||||||
|
import { ListCardAction } from "@/components/shared/ListCardAction";
|
||||||
|
import { formatItalianDate, todayIso } from "@/lib/date/formatDate";
|
||||||
|
import { euroFormatter } from "@/lib/finance/money";
|
||||||
|
import type { Bill } from "@/types/finance";
|
||||||
|
|
||||||
|
export function BillList({
|
||||||
|
bills,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onTogglePaid,
|
||||||
|
}: {
|
||||||
|
bills: Bill[];
|
||||||
|
onEdit: (bill: Bill) => void;
|
||||||
|
onDelete: (bill: Bill) => Promise<void>;
|
||||||
|
onTogglePaid: (bill: Bill) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [busy, setBusy] = useState("");
|
||||||
|
if (!bills.length) return <EmptyState title="Nessuna bolletta" />;
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{bills.map((bill) => {
|
||||||
|
return (
|
||||||
|
<div className={financeListCardClass} key={bill.$id}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="truncate font-medium text-white">
|
||||||
|
{bill.title}
|
||||||
|
</p>
|
||||||
|
<BillStatusBadge bill={bill} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-lg font-semibold text-slate-100">
|
||||||
|
{euroFormatter.format(bill.amount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<ListCardAction
|
||||||
|
disabled={busy === `${bill.$id}:toggle`}
|
||||||
|
icon={bill.status === "paid" ? LuCircleX : LuCircleCheck}
|
||||||
|
label={
|
||||||
|
bill.status === "paid"
|
||||||
|
? `Segna ${bill.title} come non pagata`
|
||||||
|
: `Segna ${bill.title} come pagata`
|
||||||
|
}
|
||||||
|
onClick={async () => {
|
||||||
|
setBusy(`${bill.$id}:toggle`);
|
||||||
|
try {
|
||||||
|
await onTogglePaid(bill);
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ListCardAction
|
||||||
|
icon={LuPencil}
|
||||||
|
label={`Modifica ${bill.title}`}
|
||||||
|
onClick={() => onEdit(bill)}
|
||||||
|
/>
|
||||||
|
<ListCardAction
|
||||||
|
confirmDescription={`Sei sicuro di voler eliminare la bolletta "${bill.title}"?`}
|
||||||
|
danger
|
||||||
|
disabled={busy === `${bill.$id}:delete`}
|
||||||
|
icon={LuTrash2}
|
||||||
|
label={`Elimina ${bill.title}`}
|
||||||
|
onClick={async () => {
|
||||||
|
setBusy(`${bill.$id}:delete`);
|
||||||
|
try {
|
||||||
|
await onDelete(bill);
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 flex items-center gap-1.5 text-xs text-slate-400">
|
||||||
|
<LuCalendarClock className="text-cyan-300" size={14} />
|
||||||
|
Scadenza {formatItalianDate(bill.dueDate)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggledBillPayload(bill: Bill) {
|
||||||
|
const paid = bill.status === "paid";
|
||||||
|
return {
|
||||||
|
title: bill.title,
|
||||||
|
amount: bill.amount,
|
||||||
|
dueDate: bill.dueDate,
|
||||||
|
status: paid ? "pending" : "paid",
|
||||||
|
paymentDate: paid ? null : todayIso(),
|
||||||
|
};
|
||||||
|
}
|
||||||
13
src/components/bills/BillStatusBadge.tsx
Normal file
13
src/components/bills/BillStatusBadge.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { PaymentBadge } from "@/components/shared/PaymentBadge";
|
||||||
|
import { resolveBillStatus } from "@/lib/finance/billStatus";
|
||||||
|
import { paymentStatusLabel } from "@/lib/finance/labels";
|
||||||
|
import type { Bill } from "@/types/finance";
|
||||||
|
|
||||||
|
export function BillStatusBadge({ bill }: { bill: Bill }) {
|
||||||
|
const status = resolveBillStatus(bill);
|
||||||
|
return (
|
||||||
|
<PaymentBadge sourceType="bill" status={status}>
|
||||||
|
{paymentStatusLabel(status)}
|
||||||
|
</PaymentBadge>
|
||||||
|
);
|
||||||
|
}
|
||||||
162
src/components/bills/BillsPageClient.tsx
Normal file
162
src/components/bills/BillsPageClient.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { BillForm } from "@/components/bills/BillForm";
|
||||||
|
import { BillList, toggledBillPayload } from "@/components/bills/BillList";
|
||||||
|
import { EntityFormModal } from "@/components/shared/EntityFormModal";
|
||||||
|
import { MonthlyPageToolbar } from "@/components/shared/MonthlyPageToolbar";
|
||||||
|
import { PageShell } from "@/components/shared/PageShell";
|
||||||
|
import { SectionAddButton } from "@/components/shared/SectionAddButton";
|
||||||
|
import { notify } from "@/components/shared/Toast";
|
||||||
|
import {
|
||||||
|
apiSave,
|
||||||
|
evictCachedLedgerSource,
|
||||||
|
invalidateResource,
|
||||||
|
useBillsResource,
|
||||||
|
usePaymentTitlesResource,
|
||||||
|
} from "@/components/shared/useFinanceData";
|
||||||
|
import {
|
||||||
|
defaultDateForPeriod,
|
||||||
|
monthKey,
|
||||||
|
periodBounds,
|
||||||
|
} from "@/lib/date/formatDate";
|
||||||
|
import type { Bill, PaymentTitle } from "@/types/finance";
|
||||||
|
|
||||||
|
export function BillsPageClient() {
|
||||||
|
const bills = useBillsResource();
|
||||||
|
const titles = usePaymentTitlesResource();
|
||||||
|
const [editing, setEditing] = useState<Bill | undefined>();
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [month, setMonth] = useState(monthKey);
|
||||||
|
const visibleBills = useMemo(() => {
|
||||||
|
const { start, end } = periodBounds(month);
|
||||||
|
return (bills.data ?? []).filter(
|
||||||
|
(bill) => bill.dueDate >= start && bill.dueDate <= end,
|
||||||
|
);
|
||||||
|
}, [bills.data, month]);
|
||||||
|
const populatedMonths = useMemo(
|
||||||
|
() => new Set((bills.data ?? []).map((bill) => bill.dueDate.slice(0, 7))),
|
||||||
|
[bills.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function createTitle(name: string) {
|
||||||
|
const saved = await apiSave<PaymentTitle>("/api/payment-titles", "POST", {
|
||||||
|
name,
|
||||||
|
paymentType: "bill",
|
||||||
|
});
|
||||||
|
titles.mutate((current) => {
|
||||||
|
const list = current ?? [];
|
||||||
|
return list.some((item) => item.$id === saved.$id)
|
||||||
|
? list
|
||||||
|
: [...list, saved];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(payload: object) {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if ("title" in payload) await createTitle(String(payload.title));
|
||||||
|
const saved = await apiSave<Bill>(
|
||||||
|
editing ? `/api/bills/${editing.$id}` : "/api/bills",
|
||||||
|
editing ? "PATCH" : "POST",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
bills.mutate((current) => {
|
||||||
|
const list = current ?? [];
|
||||||
|
return editing
|
||||||
|
? list.map((item) => (item.$id === saved.$id ? saved : item))
|
||||||
|
: [saved, ...list];
|
||||||
|
});
|
||||||
|
if (saved.status !== "paid") evictCachedLedgerSource("bill", saved.$id);
|
||||||
|
invalidateResource("payments-ledger");
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditing(undefined);
|
||||||
|
notify("Bolletta salvata");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell
|
||||||
|
title="Bollette"
|
||||||
|
description="Gestisci le tue bollette."
|
||||||
|
actions={
|
||||||
|
<SectionAddButton
|
||||||
|
label="Aggiungi bolletta"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(undefined);
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<section className="grid gap-4 rounded-lg border border-white/10 bg-white/[0.03] p-4">
|
||||||
|
<MonthlyPageToolbar
|
||||||
|
allowYearSelection
|
||||||
|
month={month}
|
||||||
|
onChange={setMonth}
|
||||||
|
populatedMonths={populatedMonths}
|
||||||
|
/>
|
||||||
|
<BillList
|
||||||
|
bills={visibleBills}
|
||||||
|
onEdit={(bill) => {
|
||||||
|
setEditing(bill);
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
onDelete={async (bill) => {
|
||||||
|
await apiSave(`/api/bills/${bill.$id}`, "DELETE");
|
||||||
|
bills.mutate((current) =>
|
||||||
|
(current ?? []).filter((item) => item.$id !== bill.$id),
|
||||||
|
);
|
||||||
|
evictCachedLedgerSource("bill", bill.$id);
|
||||||
|
invalidateResource("payments-ledger");
|
||||||
|
notify("Bolletta eliminata");
|
||||||
|
}}
|
||||||
|
onTogglePaid={async (bill) => {
|
||||||
|
const saved = await apiSave<Bill>(
|
||||||
|
`/api/bills/${bill.$id}`,
|
||||||
|
"PATCH",
|
||||||
|
toggledBillPayload(bill),
|
||||||
|
);
|
||||||
|
bills.mutate((current) =>
|
||||||
|
(current ?? []).map((item) =>
|
||||||
|
item.$id === saved.$id ? saved : item,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (saved.status !== "paid") {
|
||||||
|
evictCachedLedgerSource("bill", saved.$id);
|
||||||
|
}
|
||||||
|
invalidateResource("payments-ledger");
|
||||||
|
notify("Stato bolletta aggiornato");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
{modalOpen ? (
|
||||||
|
<EntityFormModal
|
||||||
|
onClose={() => {
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditing(undefined);
|
||||||
|
}}
|
||||||
|
open
|
||||||
|
saving={saving}
|
||||||
|
title={editing ? "Modifica Bolletta" : "Nuova Bolletta"}
|
||||||
|
>
|
||||||
|
<BillForm
|
||||||
|
defaultDate={defaultDateForPeriod(month)}
|
||||||
|
editing={editing}
|
||||||
|
titles={titles.data ?? []}
|
||||||
|
onCancel={() => {
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditing(undefined);
|
||||||
|
}}
|
||||||
|
onCreateTitle={createTitle}
|
||||||
|
onSubmit={save}
|
||||||
|
saving={saving}
|
||||||
|
/>
|
||||||
|
</EntityFormModal>
|
||||||
|
) : null}
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
src/components/dashboard/DashboardCards.tsx
Normal file
35
src/components/dashboard/DashboardCards.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { DashboardStatCard } from "@/components/dashboard/DashboardStatCard";
|
||||||
|
import { euroFormatter } from "@/lib/finance/money";
|
||||||
|
|
||||||
|
export function DashboardCards({
|
||||||
|
summary,
|
||||||
|
scope,
|
||||||
|
}: {
|
||||||
|
summary: {
|
||||||
|
income: number;
|
||||||
|
realSpent: number;
|
||||||
|
projectedSpent: number;
|
||||||
|
projectedRemainingBalance: number;
|
||||||
|
};
|
||||||
|
scope: "anno" | "mese";
|
||||||
|
}) {
|
||||||
|
const cards = [
|
||||||
|
[`Entrate ${scope}`, summary.income],
|
||||||
|
[`Spesa reale ${scope}`, summary.realSpent],
|
||||||
|
[`Previsione ${scope}`, summary.projectedSpent],
|
||||||
|
["Saldo previsto", summary.projectedRemainingBalance],
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-4 gap-2 md:grid-cols-2 md:gap-3 xl:grid-cols-4">
|
||||||
|
{cards.map(([label, value]) => (
|
||||||
|
<DashboardStatCard
|
||||||
|
desktopClassName="md:col-span-1"
|
||||||
|
key={label}
|
||||||
|
label={String(label)}
|
||||||
|
mobileSpan={2}
|
||||||
|
value={euroFormatter.format(Number(value))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/components/dashboard/DashboardKpiRow.tsx
Normal file
48
src/components/dashboard/DashboardKpiRow.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { DashboardStatCard } from "@/components/dashboard/DashboardStatCard";
|
||||||
|
import { euroFormatter } from "@/lib/finance/money";
|
||||||
|
|
||||||
|
export function DashboardKpiRow({
|
||||||
|
savingsRate,
|
||||||
|
recurringCost,
|
||||||
|
billTotal,
|
||||||
|
activeSubscriptions,
|
||||||
|
scope,
|
||||||
|
}: {
|
||||||
|
savingsRate: number;
|
||||||
|
recurringCost: number;
|
||||||
|
billTotal: number;
|
||||||
|
activeSubscriptions: number;
|
||||||
|
scope: "anno" | "mese";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="grid grid-cols-4 gap-2 md:gap-3">
|
||||||
|
<DashboardStatCard
|
||||||
|
desktopClassName="md:col-span-1"
|
||||||
|
label="Risparmio"
|
||||||
|
mobileSpan={2}
|
||||||
|
tone="green"
|
||||||
|
value={`${(savingsRate * 100).toFixed(1)}%`}
|
||||||
|
/>
|
||||||
|
<DashboardStatCard
|
||||||
|
desktopClassName="md:col-span-1"
|
||||||
|
label={`Costo ricorrente ${scope}`}
|
||||||
|
mobileSpan={2}
|
||||||
|
tone="yellow"
|
||||||
|
value={euroFormatter.format(recurringCost)}
|
||||||
|
/>
|
||||||
|
<DashboardStatCard
|
||||||
|
desktopClassName="md:col-span-1"
|
||||||
|
label={`Totale bollette ${scope}`}
|
||||||
|
mobileSpan={2}
|
||||||
|
value={euroFormatter.format(billTotal)}
|
||||||
|
/>
|
||||||
|
<DashboardStatCard
|
||||||
|
desktopClassName="md:col-span-1"
|
||||||
|
label={scope === "anno" ? "Abbonamenti attivi" : "Abbonamenti nel mese"}
|
||||||
|
mobileSpan={2}
|
||||||
|
tone="violet"
|
||||||
|
value={activeSubscriptions}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
src/components/dashboard/DashboardPageClient.tsx
Normal file
104
src/components/dashboard/DashboardPageClient.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { DashboardCards } from "@/components/dashboard/DashboardCards";
|
||||||
|
import { DashboardKpiRow } from "@/components/dashboard/DashboardKpiRow";
|
||||||
|
import { ExpenseDistribution } from "@/components/dashboard/ExpenseDistribution";
|
||||||
|
import { UpcomingPayments } from "@/components/dashboard/UpcomingPayments";
|
||||||
|
import { YearSelector } from "@/components/dashboard/YearSelector";
|
||||||
|
import { PageShell } from "@/components/shared/PageShell";
|
||||||
|
import {
|
||||||
|
useBillsResource,
|
||||||
|
useIncomeResource,
|
||||||
|
useLedgerResource,
|
||||||
|
useSubscriptionPaymentsResource,
|
||||||
|
useSubscriptionsResource,
|
||||||
|
} from "@/components/shared/useFinanceData";
|
||||||
|
import { buildYearlyDashboardSummary } from "@/lib/finance/yearlySummary";
|
||||||
|
|
||||||
|
export function DashboardPageClient() {
|
||||||
|
const subscriptions = useSubscriptionsResource();
|
||||||
|
const subscriptionPayments = useSubscriptionPaymentsResource();
|
||||||
|
const bills = useBillsResource();
|
||||||
|
const ledger = useLedgerResource();
|
||||||
|
const income = useIncomeResource();
|
||||||
|
const [year, setYear] = useState(() => new Date().getFullYear());
|
||||||
|
const loading = [
|
||||||
|
subscriptions,
|
||||||
|
subscriptionPayments,
|
||||||
|
bills,
|
||||||
|
ledger,
|
||||||
|
income,
|
||||||
|
].some((resource) => resource.loading);
|
||||||
|
const error = [
|
||||||
|
subscriptions,
|
||||||
|
subscriptionPayments,
|
||||||
|
bills,
|
||||||
|
ledger,
|
||||||
|
income,
|
||||||
|
].find((resource) => resource.error)?.error;
|
||||||
|
const yearlySummary = useMemo(
|
||||||
|
() =>
|
||||||
|
buildYearlyDashboardSummary({
|
||||||
|
year,
|
||||||
|
ledger: ledger.data ?? [],
|
||||||
|
income: income.data ?? [],
|
||||||
|
subscriptions: subscriptions.data ?? [],
|
||||||
|
subscriptionPayments: subscriptionPayments.data ?? [],
|
||||||
|
bills: bills.data ?? [],
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
bills.data,
|
||||||
|
income.data,
|
||||||
|
ledger.data,
|
||||||
|
year,
|
||||||
|
subscriptionPayments.data,
|
||||||
|
subscriptions.data,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const summary = {
|
||||||
|
income: yearlySummary.yearlyIncome,
|
||||||
|
realSpent: yearlySummary.realSpent,
|
||||||
|
projectedSpent: yearlySummary.projectedSpent,
|
||||||
|
projectedRemainingBalance: yearlySummary.projectedRemainingBalance,
|
||||||
|
savingsRate: yearlySummary.savingsRate,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell
|
||||||
|
title="Dashboard"
|
||||||
|
description="Panoramica finanziaria annuale reale e prevista."
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-slate-400">Caricamento dati...</p>
|
||||||
|
) : null}
|
||||||
|
{error ? <p className="text-sm text-rose-300">{error}</p> : null}
|
||||||
|
<section className="grid gap-4 rounded-lg border border-white/10 bg-white/[0.03] p-4">
|
||||||
|
<YearSelector onChange={setYear} year={year} />
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<DashboardCards scope="anno" summary={summary} />
|
||||||
|
<DashboardKpiRow
|
||||||
|
activeSubscriptions={yearlySummary.activeSubscriptions}
|
||||||
|
billTotal={yearlySummary.annualBillTotal}
|
||||||
|
recurringCost={yearlySummary.annualRecurringCost}
|
||||||
|
savingsRate={summary.savingsRate}
|
||||||
|
scope="anno"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid items-stretch gap-4 xl:grid-cols-[minmax(0,1.12fr)_minmax(22rem,0.88fr)]">
|
||||||
|
<UpcomingPayments
|
||||||
|
bills={bills.data ?? []}
|
||||||
|
subscriptionPayments={subscriptionPayments.data ?? []}
|
||||||
|
subscriptions={subscriptions.data ?? []}
|
||||||
|
/>
|
||||||
|
<ExpenseDistribution
|
||||||
|
items={yearlySummary.expenseDistribution.items}
|
||||||
|
periodLabel={`l'anno ${year}`}
|
||||||
|
total={yearlySummary.expenseDistribution.total}
|
||||||
|
year={year}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/components/dashboard/DashboardStatCard.tsx
Normal file
62
src/components/dashboard/DashboardStatCard.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
type StatTone = "default" | "green" | "yellow" | "red" | "violet";
|
||||||
|
type MobileSpan = 1 | 2 | 4;
|
||||||
|
|
||||||
|
const spanClasses: Record<MobileSpan, string> = {
|
||||||
|
1: "col-span-1",
|
||||||
|
2: "col-span-2",
|
||||||
|
4: "col-span-4",
|
||||||
|
};
|
||||||
|
|
||||||
|
const tones: Record<StatTone, { border: string; value: string }> = {
|
||||||
|
default: {
|
||||||
|
border: "border-white/10",
|
||||||
|
value: "text-white",
|
||||||
|
},
|
||||||
|
green: {
|
||||||
|
border: "border-emerald-400/20",
|
||||||
|
value: "text-emerald-200",
|
||||||
|
},
|
||||||
|
yellow: {
|
||||||
|
border: "border-amber-400/20",
|
||||||
|
value: "text-amber-200",
|
||||||
|
},
|
||||||
|
red: {
|
||||||
|
border: "border-rose-400/20",
|
||||||
|
value: "text-rose-200",
|
||||||
|
},
|
||||||
|
violet: {
|
||||||
|
border: "border-violet-400/20",
|
||||||
|
value: "text-violet-200",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DashboardStatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
mobileSpan = 1,
|
||||||
|
tone = "default",
|
||||||
|
desktopClassName = "",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
mobileSpan?: MobileSpan;
|
||||||
|
tone?: StatTone;
|
||||||
|
desktopClassName?: string;
|
||||||
|
}) {
|
||||||
|
const style = tones[tone];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${spanClasses[mobileSpan]} min-w-0 overflow-hidden rounded-lg border bg-white/[0.04] ${style.border} ${desktopClassName}`}
|
||||||
|
>
|
||||||
|
<p className="px-2 pt-2 text-[10px] leading-tight text-slate-400 md:px-4 md:pt-3 md:text-sm md:leading-normal">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className={`flex min-h-10 items-center justify-center px-1 pb-2 pt-1 text-center text-lg font-semibold leading-none md:min-h-14 md:px-4 md:pb-3 md:pt-2 md:text-2xl ${style.value}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
src/components/dashboard/ExpenseDistribution.tsx
Normal file
126
src/components/dashboard/ExpenseDistribution.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { LuArrowRight } from "react-icons/lu";
|
||||||
|
import { EmptyState } from "@/components/shared/EmptyState";
|
||||||
|
import type { ExpenseDistributionItem } from "@/lib/finance/expenseDistribution";
|
||||||
|
import { euroFormatter } from "@/lib/finance/money";
|
||||||
|
|
||||||
|
export function ExpenseDistribution({
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
year,
|
||||||
|
periodLabel,
|
||||||
|
showReportLink = true,
|
||||||
|
}: {
|
||||||
|
items: ExpenseDistributionItem[];
|
||||||
|
total: number;
|
||||||
|
year: number;
|
||||||
|
periodLabel?: string;
|
||||||
|
showReportLink?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="flex min-h-full flex-col rounded-lg border border-white/10 bg-white/[0.03] p-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-medium text-white">Ripartizione spese</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
|
Spesa reale {periodLabel ?? year}: {euroFormatter.format(total)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{total ? (
|
||||||
|
<div className="my-5 grid flex-1 items-center gap-5 sm:grid-cols-[minmax(8rem,11rem)_1fr]">
|
||||||
|
<div className="relative mx-auto grid aspect-square w-full max-w-44 place-items-center">
|
||||||
|
<DistributionRing items={items} />
|
||||||
|
<div className="absolute grid h-[64%] w-[64%] place-items-center rounded-full bg-[#0b101a] text-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] uppercase text-slate-500">Totale</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold text-white">
|
||||||
|
{euroFormatter.format(total)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{items.map((item) => (
|
||||||
|
<DistributionLegendItem item={item} key={item.sourceType} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="my-4 flex-1">
|
||||||
|
<EmptyState
|
||||||
|
title="Nessuna spesa registrata"
|
||||||
|
text={`Non ci sono pagamenti reali per ${periodLabel ?? `il ${year}`}. Prova a selezionare un altro periodo. I pagamenti futuri non ancora saldati sono visibili in Dashboard.`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showReportLink ? (
|
||||||
|
<Link
|
||||||
|
className="flex items-center justify-center gap-2 rounded-md border border-white/10 px-3 py-2 text-sm text-slate-200 transition hover:bg-white/5 hover:text-white"
|
||||||
|
href={`/reports?year=${year}`}
|
||||||
|
>
|
||||||
|
Apri report
|
||||||
|
<LuArrowRight size={16} />
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DistributionRing({ items }: { items: ExpenseDistributionItem[] }) {
|
||||||
|
let offset = 0;
|
||||||
|
const ring = (
|
||||||
|
<svg aria-hidden="true" className="h-full w-full" viewBox="0 0 42 42">
|
||||||
|
<title>Ripartizione delle spese</title>
|
||||||
|
<circle
|
||||||
|
className="stroke-slate-800"
|
||||||
|
cx="21"
|
||||||
|
cy="21"
|
||||||
|
fill="none"
|
||||||
|
r="15.915"
|
||||||
|
strokeWidth="7"
|
||||||
|
/>
|
||||||
|
{items.map((item) => {
|
||||||
|
const dashOffset = -offset;
|
||||||
|
offset += item.percentage;
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
className="opacity-100"
|
||||||
|
cx="21"
|
||||||
|
cy="21"
|
||||||
|
fill="none"
|
||||||
|
key={item.sourceType}
|
||||||
|
pathLength="100"
|
||||||
|
r="15.915"
|
||||||
|
stroke={item.color}
|
||||||
|
strokeDasharray={`${item.percentage} ${100 - item.percentage}`}
|
||||||
|
strokeDashoffset={dashOffset}
|
||||||
|
strokeWidth="7"
|
||||||
|
transform="rotate(-90 21 21)"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
return ring;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DistributionLegendItem({ item }: { item: ExpenseDistributionItem }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[1fr_auto] items-center gap-3 rounded-md px-2 py-1 text-sm">
|
||||||
|
<span className="flex min-w-0 items-center gap-2 text-slate-300">
|
||||||
|
<span
|
||||||
|
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: item.color }}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{item.label}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-right text-slate-200">
|
||||||
|
{euroFormatter.format(item.amount)}
|
||||||
|
<span className="ml-2 text-xs text-slate-500">
|
||||||
|
{item.percentage.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/components/dashboard/FinancialSummary.tsx
Normal file
46
src/components/dashboard/FinancialSummary.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { DashboardStatCard } from "@/components/dashboard/DashboardStatCard";
|
||||||
|
|
||||||
|
export function FinancialSummary({
|
||||||
|
unpaidSubscriptions,
|
||||||
|
unpaidBills,
|
||||||
|
overdueSubscriptions,
|
||||||
|
overdueBills,
|
||||||
|
}: {
|
||||||
|
unpaidSubscriptions: unknown[];
|
||||||
|
unpaidBills: unknown[];
|
||||||
|
overdueSubscriptions: unknown[];
|
||||||
|
overdueBills: unknown[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-4 gap-2 md:gap-3">
|
||||||
|
<Metric
|
||||||
|
label="Abbonamenti non pagati"
|
||||||
|
value={unpaidSubscriptions.length}
|
||||||
|
tone="violet"
|
||||||
|
/>
|
||||||
|
<Metric
|
||||||
|
label="Bollette non pagate"
|
||||||
|
value={unpaidBills.length}
|
||||||
|
tone="yellow"
|
||||||
|
/>
|
||||||
|
<Metric
|
||||||
|
label="Abbonamenti scaduti"
|
||||||
|
value={overdueSubscriptions.length}
|
||||||
|
tone="red"
|
||||||
|
/>
|
||||||
|
<Metric label="Bollette scadute" value={overdueBills.length} tone="red" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
tone: "violet" | "yellow" | "red";
|
||||||
|
}) {
|
||||||
|
return <DashboardStatCard label={label} tone={tone} value={value} />;
|
||||||
|
}
|
||||||
62
src/components/dashboard/GoalProgress.tsx
Normal file
62
src/components/dashboard/GoalProgress.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { euroFormatter } from "@/lib/finance/money";
|
||||||
|
import type { UserSettings } from "@/types/finance";
|
||||||
|
|
||||||
|
export function GoalProgress({
|
||||||
|
settings,
|
||||||
|
yearlyIncome,
|
||||||
|
yearlySavings,
|
||||||
|
}: {
|
||||||
|
settings?: UserSettings;
|
||||||
|
yearlyIncome: number;
|
||||||
|
yearlySavings: number;
|
||||||
|
}) {
|
||||||
|
const goals = [
|
||||||
|
{
|
||||||
|
label: "Obiettivo entrate",
|
||||||
|
current: yearlyIncome,
|
||||||
|
goal: settings?.monthlyIncomeGoal
|
||||||
|
? settings.monthlyIncomeGoal * 12
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Obiettivo risparmio",
|
||||||
|
current: yearlySavings,
|
||||||
|
goal: settings?.monthlySavingsGoal
|
||||||
|
? settings.monthlySavingsGoal * 12
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
].filter((item) => item.goal);
|
||||||
|
|
||||||
|
if (!goals.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="grid gap-3 md:grid-cols-2">
|
||||||
|
{goals.map((item) => {
|
||||||
|
const percentage = Math.min(
|
||||||
|
100,
|
||||||
|
(item.current / Number(item.goal)) * 100,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-lg border border-white/10 bg-white/[0.03] p-4"
|
||||||
|
key={item.label}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span className="text-slate-300">{item.label}</span>
|
||||||
|
<span className="text-slate-400">
|
||||||
|
{euroFormatter.format(item.current)} /{" "}
|
||||||
|
{euroFormatter.format(Number(item.goal))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 h-2 overflow-hidden rounded-full bg-slate-950">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-emerald-400 transition-[width]"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
src/components/dashboard/UpcomingPayments.tsx
Normal file
127
src/components/dashboard/UpcomingPayments.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { EmptyState } from "@/components/shared/EmptyState";
|
||||||
|
import { PaymentBadge } from "@/components/shared/PaymentBadge";
|
||||||
|
import { addDays, formatItalianDate, todayIso } from "@/lib/date/formatDate";
|
||||||
|
import { paymentStatusLabel } from "@/lib/finance/labels";
|
||||||
|
import { euroFormatter } from "@/lib/finance/money";
|
||||||
|
import { getPaymentVisualStyle } from "@/lib/finance/paymentVisualStyle";
|
||||||
|
import { getUpcomingPayments } from "@/lib/finance/upcomingPayments";
|
||||||
|
import type { Bill, Subscription, SubscriptionPayment } from "@/types/finance";
|
||||||
|
|
||||||
|
const ranges = [7, 15, 30, 60];
|
||||||
|
|
||||||
|
export function UpcomingPayments({
|
||||||
|
bills,
|
||||||
|
subscriptions,
|
||||||
|
subscriptionPayments,
|
||||||
|
}: {
|
||||||
|
bills: Bill[];
|
||||||
|
subscriptions: Subscription[];
|
||||||
|
subscriptionPayments: SubscriptionPayment[];
|
||||||
|
}) {
|
||||||
|
const [days, setDays] = useState(30);
|
||||||
|
const referenceDate = todayIso();
|
||||||
|
const items = useMemo(
|
||||||
|
() =>
|
||||||
|
getUpcomingPayments({
|
||||||
|
bills,
|
||||||
|
subscriptions,
|
||||||
|
subscriptionPayments,
|
||||||
|
days,
|
||||||
|
referenceDate,
|
||||||
|
}),
|
||||||
|
[bills, days, referenceDate, subscriptionPayments, subscriptions],
|
||||||
|
);
|
||||||
|
const total = items.reduce((sum, item) => sum + item.amount, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border border-white/10 bg-white/[0.03] p-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-white">
|
||||||
|
Prossimi pagamenti
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
|
Da oggi: {items.length} pagamenti · {euroFormatter.format(total)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex rounded-md border border-white/10 bg-slate-950/60 p-1">
|
||||||
|
{ranges.map((range) => (
|
||||||
|
<button
|
||||||
|
aria-pressed={days === range}
|
||||||
|
className={`rounded px-2.5 py-1.5 text-xs transition ${
|
||||||
|
days === range
|
||||||
|
? "bg-sky-400/15 text-sky-100"
|
||||||
|
: "text-slate-400 hover:text-white"
|
||||||
|
}`}
|
||||||
|
key={range}
|
||||||
|
onClick={() => setDays(range)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{range} gg
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-2">
|
||||||
|
{items.length ? (
|
||||||
|
items.map((item) => {
|
||||||
|
const style = getPaymentVisualStyle(item.sourceType, item.status);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-between gap-2 rounded-md border px-3 py-2.5 ${style.card}`}
|
||||||
|
key={item.id}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium text-slate-100">
|
||||||
|
{item.title}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-slate-400">
|
||||||
|
{item.subtitle} · {formatItalianDate(item.dueDate)} ·{" "}
|
||||||
|
{dateDistanceLabel(item.dueDate, referenceDate)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<div className="text-right">
|
||||||
|
<p className={`text-sm ${style.accent}`}>
|
||||||
|
{euroFormatter.format(item.amount)}
|
||||||
|
</p>
|
||||||
|
<div className="mt-1">
|
||||||
|
<PaymentBadge
|
||||||
|
sourceType={item.sourceType}
|
||||||
|
status={item.status}
|
||||||
|
>
|
||||||
|
{paymentStatusLabel(item.status)}
|
||||||
|
</PaymentBadge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title="Nessun pagamento previsto"
|
||||||
|
text={`Non ci sono bollette o abbonamenti nei prossimi ${days} giorni.`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateDistanceLabel(dueDate: string, referenceDate: string) {
|
||||||
|
if (dueDate === referenceDate) return "oggi";
|
||||||
|
let distance = 0;
|
||||||
|
let cursor = referenceDate;
|
||||||
|
const direction = dueDate > referenceDate ? 1 : -1;
|
||||||
|
while (cursor !== dueDate && Math.abs(distance) < 3660) {
|
||||||
|
cursor = addDays(cursor, direction);
|
||||||
|
distance += direction;
|
||||||
|
}
|
||||||
|
return distance > 0
|
||||||
|
? `tra ${distance} ${distance === 1 ? "giorno" : "giorni"}`
|
||||||
|
: `${Math.abs(distance)} ${distance === -1 ? "giorno" : "giorni"} fa`;
|
||||||
|
}
|
||||||
45
src/components/dashboard/YearSelector.tsx
Normal file
45
src/components/dashboard/YearSelector.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { LuCalendarRange, LuChevronLeft, LuChevronRight } from "react-icons/lu";
|
||||||
|
|
||||||
|
export function YearSelector({
|
||||||
|
year,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
}: {
|
||||||
|
year: number;
|
||||||
|
onChange: (year: number) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`w-full inline-flex items-center justify-between gap-1 rounded-xl border border-sky-400/15 bg-slate-950/25 p-1 shadow-sm shadow-sky-950/30 transition ${
|
||||||
|
disabled ? "opacity-40" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex min-w-24 items-center gap-2 px-2 text-center text-sm font-semibold text-white">
|
||||||
|
<LuCalendarRange className="text-sky-300" size={16} />
|
||||||
|
{year}
|
||||||
|
</span>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
aria-label="Anno precedente"
|
||||||
|
className="grid size-9 place-items-center rounded-lg text-slate-300 transition hover:bg-sky-400/10 hover:text-sky-100 disabled:cursor-default"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(year - 1)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<LuChevronLeft size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
aria-label="Anno successivo"
|
||||||
|
className="grid size-9 place-items-center rounded-lg text-slate-300 transition hover:bg-sky-400/10 hover:text-sky-100 disabled:cursor-default"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(year + 1)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<LuChevronRight size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
src/components/income/IncomeForm.tsx
Normal file
132
src/components/income/IncomeForm.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { DateField } from "@/components/shared/DateField";
|
||||||
|
import { Field, inputClass } from "@/components/shared/Field";
|
||||||
|
import { Select } from "@/components/shared/Select";
|
||||||
|
import {
|
||||||
|
SimpleFormActions,
|
||||||
|
SimpleFormReview,
|
||||||
|
SimpleFormStepIndicator,
|
||||||
|
} from "@/components/shared/SimpleFormSteps";
|
||||||
|
import { TitleCombobox } from "@/components/shared/TitleCombobox";
|
||||||
|
import { formatItalianDate, todayIso } from "@/lib/date/formatDate";
|
||||||
|
import { incomeTypeLabel } from "@/lib/finance/labels";
|
||||||
|
import {
|
||||||
|
euroFormatter,
|
||||||
|
formatMoneyInput,
|
||||||
|
parseMoneyInput,
|
||||||
|
} from "@/lib/finance/money";
|
||||||
|
import type { Income, PaymentTitle } from "@/types/finance";
|
||||||
|
|
||||||
|
export function IncomeForm({
|
||||||
|
titles,
|
||||||
|
editing,
|
||||||
|
defaultDate = todayIso(),
|
||||||
|
onSubmit,
|
||||||
|
onCreateTitle,
|
||||||
|
onCancel,
|
||||||
|
saving = false,
|
||||||
|
}: {
|
||||||
|
titles: PaymentTitle[];
|
||||||
|
editing?: Income;
|
||||||
|
defaultDate?: string;
|
||||||
|
onSubmit: (data: object) => Promise<void>;
|
||||||
|
onCreateTitle?: (name: string) => Promise<void>;
|
||||||
|
onCancel?: () => void;
|
||||||
|
saving?: boolean;
|
||||||
|
}) {
|
||||||
|
const [review, setReview] = useState(false);
|
||||||
|
const [title, setTitle] = useState(editing?.title ?? "");
|
||||||
|
const [amount, setAmount] = useState(formatMoneyInput(editing?.amount));
|
||||||
|
const [type, setType] = useState(editing?.type ?? "salary");
|
||||||
|
const [date, setDate] = useState(editing?.date ?? defaultDate);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setReview(false);
|
||||||
|
setTitle(editing?.title ?? "");
|
||||||
|
setAmount(formatMoneyInput(editing?.amount));
|
||||||
|
setType(editing?.type ?? "salary");
|
||||||
|
setDate(editing?.date ?? defaultDate);
|
||||||
|
setError("");
|
||||||
|
}, [defaultDate, editing]);
|
||||||
|
|
||||||
|
function payload() {
|
||||||
|
if (!title.trim()) throw new Error("Inserisci il titolo dell'entrata");
|
||||||
|
if (!date) throw new Error("Inserisci la data dell'entrata");
|
||||||
|
return { title, amount: parseMoneyInput(amount), type, date };
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
setError("");
|
||||||
|
const data = payload();
|
||||||
|
if (!review) {
|
||||||
|
setReview(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onSubmit(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Salvataggio non riuscito",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SimpleFormStepIndicator review={review} />
|
||||||
|
{review ? (
|
||||||
|
<SimpleFormReview
|
||||||
|
rows={[
|
||||||
|
{
|
||||||
|
label: "Importo",
|
||||||
|
value: euroFormatter.format(parseMoneyInput(amount)),
|
||||||
|
},
|
||||||
|
{ label: "Tipo", value: incomeTypeLabel(type) },
|
||||||
|
{ label: "Data", value: formatItalianDate(date) },
|
||||||
|
]}
|
||||||
|
title={title}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
|
<TitleCombobox
|
||||||
|
titles={titles}
|
||||||
|
type="income"
|
||||||
|
value={title}
|
||||||
|
onChange={setTitle}
|
||||||
|
onCreate={onCreateTitle}
|
||||||
|
/>
|
||||||
|
<Field label="Importo">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
value={amount}
|
||||||
|
onChange={(event) => setAmount(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Select
|
||||||
|
label="Tipo"
|
||||||
|
value={type}
|
||||||
|
onChange={(value) => setType(value as typeof type)}
|
||||||
|
options={[
|
||||||
|
{ value: "salary", label: "Stipendio" },
|
||||||
|
{ value: "recurring", label: "Ricorrente" },
|
||||||
|
{ value: "one_time", label: "Una tantum" },
|
||||||
|
{ value: "other", label: "Altro" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<DateField label="Data" name="date" value={date} onChange={setDate} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error ? <p className="mt-4 text-sm text-rose-300">{error}</p> : null}
|
||||||
|
<SimpleFormActions
|
||||||
|
onBack={() => setReview(false)}
|
||||||
|
onCancel={onCancel}
|
||||||
|
review={review}
|
||||||
|
saving={saving}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
src/components/income/IncomeList.tsx
Normal file
83
src/components/income/IncomeList.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
LuCalendarDays,
|
||||||
|
LuPencil,
|
||||||
|
LuTrash2,
|
||||||
|
LuTrendingUp,
|
||||||
|
} from "react-icons/lu";
|
||||||
|
import { EmptyState } from "@/components/shared/EmptyState";
|
||||||
|
import { financeListCardClass } from "@/components/shared/financeListCard";
|
||||||
|
import { ListCardAction } from "@/components/shared/ListCardAction";
|
||||||
|
import { PaymentBadge } from "@/components/shared/PaymentBadge";
|
||||||
|
import { formatItalianDate } from "@/lib/date/formatDate";
|
||||||
|
import { incomeTypeLabel } from "@/lib/finance/labels";
|
||||||
|
import { euroFormatter } from "@/lib/finance/money";
|
||||||
|
import type { Income } from "@/types/finance";
|
||||||
|
|
||||||
|
export function IncomeList({
|
||||||
|
income,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
income: Income[];
|
||||||
|
onEdit: (item: Income) => void;
|
||||||
|
onDelete: (item: Income) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [busy, setBusy] = useState("");
|
||||||
|
if (!income.length) return <EmptyState title="Nessuna entrata" />;
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{income.map((item) => (
|
||||||
|
<div className={financeListCardClass} key={item.$id}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="truncate font-medium text-white">{item.title}</p>
|
||||||
|
<PaymentBadge sourceType="income" status="paid">
|
||||||
|
{incomeTypeLabel(item.type)}
|
||||||
|
</PaymentBadge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-lg font-semibold text-slate-100">
|
||||||
|
{euroFormatter.format(item.amount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<ListCardAction
|
||||||
|
icon={LuPencil}
|
||||||
|
label={`Modifica ${item.title}`}
|
||||||
|
onClick={() => onEdit(item)}
|
||||||
|
/>
|
||||||
|
<ListCardAction
|
||||||
|
confirmDescription={`Sei sicuro di voler eliminare l'entrata "${item.title}"?`}
|
||||||
|
danger
|
||||||
|
disabled={busy === item.$id}
|
||||||
|
icon={LuTrash2}
|
||||||
|
label={`Elimina ${item.title}`}
|
||||||
|
onClick={async () => {
|
||||||
|
setBusy(item.$id);
|
||||||
|
try {
|
||||||
|
await onDelete(item);
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid gap-1 text-xs text-slate-400 sm:grid-cols-2">
|
||||||
|
<p className="flex items-center gap-1.5">
|
||||||
|
<LuTrendingUp className="text-emerald-300" size={14} />
|
||||||
|
Entrata {incomeTypeLabel(item.type).toLowerCase()}
|
||||||
|
</p>
|
||||||
|
<p className="flex items-center gap-1.5">
|
||||||
|
<LuCalendarDays className="text-emerald-300" size={14} />
|
||||||
|
Registrata il {formatItalianDate(item.date)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
src/components/income/IncomePageClient.tsx
Normal file
138
src/components/income/IncomePageClient.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { IncomeForm } from "@/components/income/IncomeForm";
|
||||||
|
import { IncomeList } from "@/components/income/IncomeList";
|
||||||
|
import { IncomePeriodToolbar } from "@/components/income/IncomePeriodToolbar";
|
||||||
|
import { EntityFormModal } from "@/components/shared/EntityFormModal";
|
||||||
|
import { PageShell } from "@/components/shared/PageShell";
|
||||||
|
import { SectionAddButton } from "@/components/shared/SectionAddButton";
|
||||||
|
import { notify } from "@/components/shared/Toast";
|
||||||
|
import {
|
||||||
|
apiSave,
|
||||||
|
useIncomeResource,
|
||||||
|
usePaymentTitlesResource,
|
||||||
|
} from "@/components/shared/useFinanceData";
|
||||||
|
import {
|
||||||
|
defaultDateForPeriod,
|
||||||
|
monthKey,
|
||||||
|
periodBounds,
|
||||||
|
} from "@/lib/date/formatDate";
|
||||||
|
import type { Income, PaymentTitle } from "@/types/finance";
|
||||||
|
|
||||||
|
export function IncomePageClient() {
|
||||||
|
const income = useIncomeResource();
|
||||||
|
const titles = usePaymentTitlesResource();
|
||||||
|
const [month, setMonth] = useState(monthKey);
|
||||||
|
const [editing, setEditing] = useState<Income | undefined>();
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const monthIncome = useMemo(() => {
|
||||||
|
const { start, end } = periodBounds(month);
|
||||||
|
return (income.data ?? []).filter(
|
||||||
|
(item) => item.date >= start && item.date <= end,
|
||||||
|
);
|
||||||
|
}, [income.data, month]);
|
||||||
|
const populatedMonths = useMemo(
|
||||||
|
() => new Set((income.data ?? []).map((item) => item.date.slice(0, 7))),
|
||||||
|
[income.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function createTitle(name: string) {
|
||||||
|
const saved = await apiSave<PaymentTitle>("/api/payment-titles", "POST", {
|
||||||
|
name,
|
||||||
|
paymentType: "income",
|
||||||
|
});
|
||||||
|
titles.mutate((current) => {
|
||||||
|
const list = current ?? [];
|
||||||
|
return list.some((item) => item.$id === saved.$id)
|
||||||
|
? list
|
||||||
|
: [...list, saved];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(payload: object) {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if ("title" in payload) await createTitle(String(payload.title));
|
||||||
|
const saved = await apiSave<Income>(
|
||||||
|
editing ? `/api/income/${editing.$id}` : "/api/income",
|
||||||
|
editing ? "PATCH" : "POST",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
income.mutate((current) => {
|
||||||
|
const list = current ?? [];
|
||||||
|
return editing
|
||||||
|
? list.map((item) => (item.$id === saved.$id ? saved : item))
|
||||||
|
: [saved, ...list];
|
||||||
|
});
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditing(undefined);
|
||||||
|
notify("Entrata salvata");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell
|
||||||
|
title="Entrate"
|
||||||
|
description="Inserisci le tue entrate mesili o occasionali."
|
||||||
|
actions={
|
||||||
|
<SectionAddButton
|
||||||
|
label="Aggiungi entrata"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(undefined);
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<section className="grid gap-4 rounded-lg border border-white/10 bg-white/[0.03] p-4">
|
||||||
|
<IncomePeriodToolbar
|
||||||
|
month={month}
|
||||||
|
onMonthChange={setMonth}
|
||||||
|
populatedMonths={populatedMonths}
|
||||||
|
/>
|
||||||
|
<IncomeList
|
||||||
|
income={monthIncome}
|
||||||
|
onEdit={(item) => {
|
||||||
|
setEditing(item);
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
onDelete={async (item) => {
|
||||||
|
await apiSave(`/api/income/${item.$id}`, "DELETE");
|
||||||
|
income.mutate((current) =>
|
||||||
|
(current ?? []).filter((entry) => entry.$id !== item.$id),
|
||||||
|
);
|
||||||
|
notify("Entrata eliminata");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
{modalOpen ? (
|
||||||
|
<EntityFormModal
|
||||||
|
onClose={() => {
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditing(undefined);
|
||||||
|
}}
|
||||||
|
open
|
||||||
|
saving={saving}
|
||||||
|
title={editing ? "Modifica Entrata" : "Nuova Entrata"}
|
||||||
|
>
|
||||||
|
<IncomeForm
|
||||||
|
defaultDate={defaultDateForPeriod(month)}
|
||||||
|
editing={editing}
|
||||||
|
titles={titles.data ?? []}
|
||||||
|
onCancel={() => {
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditing(undefined);
|
||||||
|
}}
|
||||||
|
onCreateTitle={createTitle}
|
||||||
|
onSubmit={save}
|
||||||
|
saving={saving}
|
||||||
|
/>
|
||||||
|
</EntityFormModal>
|
||||||
|
) : null}
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/components/income/IncomePeriodToolbar.tsx
Normal file
26
src/components/income/IncomePeriodToolbar.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { LocalMonthNavigator } from "@/components/shared/LocalMonthNavigator";
|
||||||
|
|
||||||
|
export function IncomePeriodToolbar({
|
||||||
|
month,
|
||||||
|
onMonthChange,
|
||||||
|
populatedMonths,
|
||||||
|
}: {
|
||||||
|
month: string;
|
||||||
|
onMonthChange: (month: string) => void;
|
||||||
|
populatedMonths?: Iterable<string>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||||
|
<LocalMonthNavigator
|
||||||
|
allowYearSelection
|
||||||
|
month={month}
|
||||||
|
onChange={onMonthChange}
|
||||||
|
populatedMonths={populatedMonths}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
src/components/one-time-payments/OneTimePaymentForm.tsx
Normal file
116
src/components/one-time-payments/OneTimePaymentForm.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { DateField } from "@/components/shared/DateField";
|
||||||
|
import { Field, inputClass } from "@/components/shared/Field";
|
||||||
|
import {
|
||||||
|
SimpleFormActions,
|
||||||
|
SimpleFormReview,
|
||||||
|
SimpleFormStepIndicator,
|
||||||
|
} from "@/components/shared/SimpleFormSteps";
|
||||||
|
import { TitleCombobox } from "@/components/shared/TitleCombobox";
|
||||||
|
import { formatItalianDate, todayIso } from "@/lib/date/formatDate";
|
||||||
|
import {
|
||||||
|
euroFormatter,
|
||||||
|
formatMoneyInput,
|
||||||
|
parseMoneyInput,
|
||||||
|
} from "@/lib/finance/money";
|
||||||
|
import type { OneTimePayment, PaymentTitle } from "@/types/finance";
|
||||||
|
|
||||||
|
export function OneTimePaymentForm({
|
||||||
|
titles,
|
||||||
|
editing,
|
||||||
|
onSubmit,
|
||||||
|
onCreateTitle,
|
||||||
|
onCancel,
|
||||||
|
defaultDate = todayIso(),
|
||||||
|
saving = false,
|
||||||
|
}: {
|
||||||
|
titles: PaymentTitle[];
|
||||||
|
editing?: OneTimePayment;
|
||||||
|
onSubmit: (data: object) => Promise<void>;
|
||||||
|
onCreateTitle?: (name: string) => Promise<void>;
|
||||||
|
onCancel?: () => void;
|
||||||
|
defaultDate?: string;
|
||||||
|
saving?: boolean;
|
||||||
|
}) {
|
||||||
|
const [review, setReview] = useState(false);
|
||||||
|
const [title, setTitle] = useState(editing?.title ?? "");
|
||||||
|
const [amount, setAmount] = useState(formatMoneyInput(editing?.amount));
|
||||||
|
const [date, setDate] = useState(editing?.date ?? defaultDate);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setReview(false);
|
||||||
|
setTitle(editing?.title ?? "");
|
||||||
|
setAmount(formatMoneyInput(editing?.amount));
|
||||||
|
setDate(editing?.date ?? defaultDate);
|
||||||
|
setError("");
|
||||||
|
}, [defaultDate, editing]);
|
||||||
|
|
||||||
|
function payload() {
|
||||||
|
if (!title.trim()) throw new Error("Inserisci il titolo del pagamento");
|
||||||
|
if (!date) throw new Error("Inserisci la data del pagamento");
|
||||||
|
return { title, amount: parseMoneyInput(amount), date };
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
setError("");
|
||||||
|
const data = payload();
|
||||||
|
if (!review) {
|
||||||
|
setReview(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onSubmit(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Salvataggio non riuscito",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SimpleFormStepIndicator review={review} />
|
||||||
|
{review ? (
|
||||||
|
<SimpleFormReview
|
||||||
|
rows={[
|
||||||
|
{
|
||||||
|
label: "Importo",
|
||||||
|
value: euroFormatter.format(parseMoneyInput(amount)),
|
||||||
|
},
|
||||||
|
{ label: "Data", value: formatItalianDate(date) },
|
||||||
|
]}
|
||||||
|
title={title}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<TitleCombobox
|
||||||
|
titles={titles}
|
||||||
|
type="one_time"
|
||||||
|
value={title}
|
||||||
|
onChange={setTitle}
|
||||||
|
onCreate={onCreateTitle}
|
||||||
|
/>
|
||||||
|
<Field label="Importo">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
value={amount}
|
||||||
|
onChange={(event) => setAmount(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<DateField label="Data" name="date" value={date} onChange={setDate} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error ? <p className="mt-4 text-sm text-rose-300">{error}</p> : null}
|
||||||
|
<SimpleFormActions
|
||||||
|
onBack={() => setReview(false)}
|
||||||
|
onCancel={onCancel}
|
||||||
|
review={review}
|
||||||
|
saving={saving}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
src/components/one-time-payments/OneTimePaymentList.tsx
Normal file
84
src/components/one-time-payments/OneTimePaymentList.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
LuCalendarDays,
|
||||||
|
LuPencil,
|
||||||
|
LuReceiptText,
|
||||||
|
LuTrash2,
|
||||||
|
} from "react-icons/lu";
|
||||||
|
import { EmptyState } from "@/components/shared/EmptyState";
|
||||||
|
import { financeListCardClass } from "@/components/shared/financeListCard";
|
||||||
|
import { ListCardAction } from "@/components/shared/ListCardAction";
|
||||||
|
import { PaymentBadge } from "@/components/shared/PaymentBadge";
|
||||||
|
import { formatItalianDate } from "@/lib/date/formatDate";
|
||||||
|
import { euroFormatter } from "@/lib/finance/money";
|
||||||
|
import type { OneTimePayment } from "@/types/finance";
|
||||||
|
|
||||||
|
export function OneTimePaymentList({
|
||||||
|
payments,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
payments: OneTimePayment[];
|
||||||
|
onEdit: (payment: OneTimePayment) => void;
|
||||||
|
onDelete: (payment: OneTimePayment) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [busy, setBusy] = useState("");
|
||||||
|
if (!payments.length) return <EmptyState title="Nessun pagamento manuale" />;
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{payments.map((payment) => (
|
||||||
|
<div className={financeListCardClass} key={payment.$id}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="truncate font-medium text-white">
|
||||||
|
{payment.title}
|
||||||
|
</p>
|
||||||
|
<PaymentBadge sourceType="one_time" status="neutral">
|
||||||
|
Manuale
|
||||||
|
</PaymentBadge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-lg font-semibold text-slate-100">
|
||||||
|
{euroFormatter.format(payment.amount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<ListCardAction
|
||||||
|
icon={LuPencil}
|
||||||
|
label={`Modifica ${payment.title}`}
|
||||||
|
onClick={() => onEdit(payment)}
|
||||||
|
/>
|
||||||
|
<ListCardAction
|
||||||
|
confirmDescription={`Sei sicuro di voler eliminare il pagamento "${payment.title}"?`}
|
||||||
|
danger
|
||||||
|
disabled={busy === payment.$id}
|
||||||
|
icon={LuTrash2}
|
||||||
|
label={`Elimina ${payment.title}`}
|
||||||
|
onClick={async () => {
|
||||||
|
setBusy(payment.$id);
|
||||||
|
try {
|
||||||
|
await onDelete(payment);
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid gap-1 text-xs text-slate-400 sm:grid-cols-2">
|
||||||
|
<p className="flex items-center gap-1.5">
|
||||||
|
<LuReceiptText className="text-slate-300" size={14} />
|
||||||
|
Pagamento singolo
|
||||||
|
</p>
|
||||||
|
<p className="flex items-center gap-1.5">
|
||||||
|
<LuCalendarDays className="text-slate-300" size={14} />
|
||||||
|
Registrato il {formatItalianDate(payment.date)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
src/components/one-time-payments/OneTimePaymentsPageClient.tsx
Normal file
147
src/components/one-time-payments/OneTimePaymentsPageClient.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { OneTimePaymentForm } from "@/components/one-time-payments/OneTimePaymentForm";
|
||||||
|
import { OneTimePaymentList } from "@/components/one-time-payments/OneTimePaymentList";
|
||||||
|
import { EntityFormModal } from "@/components/shared/EntityFormModal";
|
||||||
|
import { MonthlyPageToolbar } from "@/components/shared/MonthlyPageToolbar";
|
||||||
|
import { PageShell } from "@/components/shared/PageShell";
|
||||||
|
import { SectionAddButton } from "@/components/shared/SectionAddButton";
|
||||||
|
import { notify } from "@/components/shared/Toast";
|
||||||
|
import {
|
||||||
|
apiSave,
|
||||||
|
evictCachedLedgerSource,
|
||||||
|
invalidateResource,
|
||||||
|
useOneTimePaymentsResource,
|
||||||
|
usePaymentTitlesResource,
|
||||||
|
} from "@/components/shared/useFinanceData";
|
||||||
|
import {
|
||||||
|
defaultDateForPeriod,
|
||||||
|
monthKey,
|
||||||
|
periodBounds,
|
||||||
|
} from "@/lib/date/formatDate";
|
||||||
|
import type { OneTimePayment, PaymentTitle } from "@/types/finance";
|
||||||
|
|
||||||
|
export function OneTimePaymentsPageClient() {
|
||||||
|
const payments = useOneTimePaymentsResource();
|
||||||
|
const titles = usePaymentTitlesResource();
|
||||||
|
const [editing, setEditing] = useState<OneTimePayment | undefined>();
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [month, setMonth] = useState(monthKey);
|
||||||
|
const visiblePayments = useMemo(() => {
|
||||||
|
const { start, end } = periodBounds(month);
|
||||||
|
return (payments.data ?? []).filter(
|
||||||
|
(payment) => payment.date >= start && payment.date <= end,
|
||||||
|
);
|
||||||
|
}, [month, payments.data]);
|
||||||
|
const populatedMonths = useMemo(
|
||||||
|
() =>
|
||||||
|
new Set((payments.data ?? []).map((payment) => payment.date.slice(0, 7))),
|
||||||
|
[payments.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function createTitle(name: string) {
|
||||||
|
const saved = await apiSave<PaymentTitle>("/api/payment-titles", "POST", {
|
||||||
|
name,
|
||||||
|
paymentType: "one_time",
|
||||||
|
});
|
||||||
|
titles.mutate((current) => {
|
||||||
|
const list = current ?? [];
|
||||||
|
return list.some((item) => item.$id === saved.$id)
|
||||||
|
? list
|
||||||
|
: [...list, saved];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(payload: object) {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if ("title" in payload) await createTitle(String(payload.title));
|
||||||
|
const saved = await apiSave<OneTimePayment>(
|
||||||
|
editing
|
||||||
|
? `/api/one-time-payments/${editing.$id}`
|
||||||
|
: "/api/one-time-payments",
|
||||||
|
editing ? "PATCH" : "POST",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
payments.mutate((current) => {
|
||||||
|
const list = current ?? [];
|
||||||
|
return editing
|
||||||
|
? list.map((item) => (item.$id === saved.$id ? saved : item))
|
||||||
|
: [saved, ...list];
|
||||||
|
});
|
||||||
|
invalidateResource("payments-ledger");
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditing(undefined);
|
||||||
|
notify("Pagamento salvato");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell
|
||||||
|
title="Pagamenti manuali"
|
||||||
|
description="Spese immediate, senza categorie o note."
|
||||||
|
actions={
|
||||||
|
<SectionAddButton
|
||||||
|
label="Aggiungi pagamento"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(undefined);
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<section className="grid gap-4 rounded-lg border border-white/10 bg-white/[0.03] p-4">
|
||||||
|
<MonthlyPageToolbar
|
||||||
|
allowYearSelection
|
||||||
|
month={month}
|
||||||
|
onChange={setMonth}
|
||||||
|
populatedMonths={populatedMonths}
|
||||||
|
/>
|
||||||
|
<OneTimePaymentList
|
||||||
|
payments={visiblePayments}
|
||||||
|
onEdit={(payment) => {
|
||||||
|
setEditing(payment);
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
onDelete={async (payment) => {
|
||||||
|
await apiSave(`/api/one-time-payments/${payment.$id}`, "DELETE");
|
||||||
|
payments.mutate((current) =>
|
||||||
|
(current ?? []).filter((item) => item.$id !== payment.$id),
|
||||||
|
);
|
||||||
|
evictCachedLedgerSource("one_time", payment.$id);
|
||||||
|
invalidateResource("payments-ledger");
|
||||||
|
notify("Pagamento eliminato");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
{modalOpen ? (
|
||||||
|
<EntityFormModal
|
||||||
|
onClose={() => {
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditing(undefined);
|
||||||
|
}}
|
||||||
|
open
|
||||||
|
saving={saving}
|
||||||
|
title={editing ? "Modifica Pagamento" : "Nuovo Pagamento"}
|
||||||
|
>
|
||||||
|
<OneTimePaymentForm
|
||||||
|
defaultDate={defaultDateForPeriod(month)}
|
||||||
|
editing={editing}
|
||||||
|
titles={titles.data ?? []}
|
||||||
|
onCancel={() => {
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditing(undefined);
|
||||||
|
}}
|
||||||
|
onCreateTitle={createTitle}
|
||||||
|
onSubmit={save}
|
||||||
|
saving={saving}
|
||||||
|
/>
|
||||||
|
</EntityFormModal>
|
||||||
|
) : null}
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
src/components/profile/AccountCard.tsx
Normal file
148
src/components/profile/AccountCard.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef } from "react";
|
||||||
|
import type { IconType } from "react-icons";
|
||||||
|
import {
|
||||||
|
LuImagePlus,
|
||||||
|
LuKeyRound,
|
||||||
|
LuMail,
|
||||||
|
LuPencil,
|
||||||
|
LuTrash2,
|
||||||
|
LuUserRound,
|
||||||
|
} from "react-icons/lu";
|
||||||
|
import { ProfileCard } from "@/components/profile/ProfileCard";
|
||||||
|
import { ListCardAction } from "@/components/shared/ListCardAction";
|
||||||
|
import { UserAvatar } from "@/components/shared/UserAvatar";
|
||||||
|
import type { UserSession } from "@/types/finance";
|
||||||
|
|
||||||
|
export function AccountCard({
|
||||||
|
onDeleteAvatar,
|
||||||
|
onEditAccount,
|
||||||
|
onEditAvatar,
|
||||||
|
onEditPassword,
|
||||||
|
onSelectAvatar,
|
||||||
|
savingAvatar,
|
||||||
|
user,
|
||||||
|
}: {
|
||||||
|
onDeleteAvatar: () => Promise<void>;
|
||||||
|
onEditAccount: () => void;
|
||||||
|
onEditAvatar: () => void;
|
||||||
|
onEditPassword: () => void;
|
||||||
|
onSelectAvatar: (file: File) => void;
|
||||||
|
savingAvatar: boolean;
|
||||||
|
user: UserSession;
|
||||||
|
}) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProfileCard
|
||||||
|
className="overflow-hidden border-sky-400/20 bg-gradient-to-br from-sky-400/[0.08] via-white/[0.035] to-violet-400/[0.06]"
|
||||||
|
description="Le informazioni del tuo account e le opzioni di accesso."
|
||||||
|
title="Account"
|
||||||
|
>
|
||||||
|
<div className="grid gap-5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div className="flex min-w-0 items-center gap-4">
|
||||||
|
<UserAvatar
|
||||||
|
className="size-20 text-xl ring-4 ring-sky-400/10"
|
||||||
|
user={user}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-lg font-semibold text-white">
|
||||||
|
{user.name}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 truncate text-sm text-slate-400">
|
||||||
|
{user.email}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-slate-500">
|
||||||
|
JPG, PNG o WebP fino a 10 MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<input
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
className="hidden"
|
||||||
|
onChange={async (event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
onSelectAvatar(file);
|
||||||
|
event.target.value = "";
|
||||||
|
}}
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
|
<ListCardAction
|
||||||
|
disabled={savingAvatar}
|
||||||
|
icon={user.avatarUrl ? LuPencil : LuImagePlus}
|
||||||
|
label={user.avatarUrl ? "Modifica avatar" : "Carica avatar"}
|
||||||
|
onClick={() =>
|
||||||
|
user.avatarUrl ? onEditAvatar() : inputRef.current?.click()
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{user.avatarUrl ? (
|
||||||
|
<ListCardAction
|
||||||
|
confirmDescription="Sei sicuro di voler rimuovere l'immagine del profilo?"
|
||||||
|
danger
|
||||||
|
disabled={savingAvatar}
|
||||||
|
icon={LuTrash2}
|
||||||
|
label="Rimuovi avatar"
|
||||||
|
onClick={onDeleteAvatar}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<InfoRow
|
||||||
|
action={
|
||||||
|
<ListCardAction
|
||||||
|
icon={LuPencil}
|
||||||
|
label="Modifica nome ed email"
|
||||||
|
onClick={onEditAccount}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
icon={LuUserRound}
|
||||||
|
label="Nome"
|
||||||
|
value={user.name}
|
||||||
|
/>
|
||||||
|
<InfoRow icon={LuMail} label="Email" value={user.email} />
|
||||||
|
<InfoRow
|
||||||
|
action={
|
||||||
|
<ListCardAction
|
||||||
|
icon={LuPencil}
|
||||||
|
label="Modifica password"
|
||||||
|
onClick={onEditPassword}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
icon={LuKeyRound}
|
||||||
|
label="Password"
|
||||||
|
value="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ProfileCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({
|
||||||
|
action,
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
action?: React.ReactNode;
|
||||||
|
icon: IconType;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 rounded-lg border border-white/10 bg-slate-950/25 px-3 py-2.5">
|
||||||
|
<Icon className="text-sky-300" size={16} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs text-slate-500">{label}</p>
|
||||||
|
<p className="mt-0.5 truncate text-sm text-slate-200">{value}</p>
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
src/components/profile/AccountEditModal.tsx
Normal file
95
src/components/profile/AccountEditModal.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { EntityFormModal } from "@/components/shared/EntityFormModal";
|
||||||
|
import { Field, inputClass } from "@/components/shared/Field";
|
||||||
|
import {
|
||||||
|
SimpleFormActions,
|
||||||
|
SimpleFormReview,
|
||||||
|
SimpleFormStepIndicator,
|
||||||
|
} from "@/components/shared/SimpleFormSteps";
|
||||||
|
import type { UserSession } from "@/types/finance";
|
||||||
|
|
||||||
|
export function AccountEditModal({
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
open,
|
||||||
|
saving,
|
||||||
|
user,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (payload: { name: string; email: string }) => Promise<boolean>;
|
||||||
|
open: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
user: UserSession;
|
||||||
|
}) {
|
||||||
|
const [review, setReview] = useState(false);
|
||||||
|
const [name, setName] = useState(user.name);
|
||||||
|
const [email, setEmail] = useState(user.email);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setReview(false);
|
||||||
|
setName(user.name);
|
||||||
|
setEmail(user.email);
|
||||||
|
}, [open, user.email, user.name]);
|
||||||
|
|
||||||
|
async function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!review) {
|
||||||
|
setReview(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (await onSubmit({ name, email })) onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EntityFormModal
|
||||||
|
onClose={onClose}
|
||||||
|
open={open}
|
||||||
|
saving={saving}
|
||||||
|
title="Modifica dati account"
|
||||||
|
>
|
||||||
|
<SimpleFormStepIndicator review={review} />
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
{review ? (
|
||||||
|
<SimpleFormReview
|
||||||
|
rows={[
|
||||||
|
{ label: "Nome", value: name },
|
||||||
|
{ label: "Email", value: email },
|
||||||
|
]}
|
||||||
|
title="Conferma i nuovi dati account"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<Field label="Nome">
|
||||||
|
<input
|
||||||
|
autoComplete="name"
|
||||||
|
className={inputClass}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Email">
|
||||||
|
<input
|
||||||
|
autoComplete="email"
|
||||||
|
className={inputClass}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
required
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<SimpleFormActions
|
||||||
|
onBack={() => setReview(false)}
|
||||||
|
onCancel={onClose}
|
||||||
|
review={review}
|
||||||
|
saving={saving}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</EntityFormModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
306
src/components/profile/AvatarCropModal.tsx
Normal file
306
src/components/profile/AvatarCropModal.tsx
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
// biome-ignore-all lint/performance/noImgElement: blob previews need stable native dimensions for canvas editing
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { LuCrop, LuImagePlus, LuRotateCcw, LuZoomIn } from "react-icons/lu";
|
||||||
|
import { EntityFormModal } from "@/components/shared/EntityFormModal";
|
||||||
|
|
||||||
|
const OUTPUT_SIZE = 512;
|
||||||
|
type ImageSize = { height: number; width: number };
|
||||||
|
|
||||||
|
export function AvatarCropModal({
|
||||||
|
file,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
onSelectFile,
|
||||||
|
saving,
|
||||||
|
}: {
|
||||||
|
file?: File;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (file: File) => Promise<void>;
|
||||||
|
onSelectFile: (file: File) => void;
|
||||||
|
saving: boolean;
|
||||||
|
}) {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const imageRef = useRef<HTMLImageElement>(null);
|
||||||
|
const previewRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dragRef = useRef<
|
||||||
|
{ pointerId: number; x: number; y: number } | undefined
|
||||||
|
>(undefined);
|
||||||
|
const [source, setSource] = useState("");
|
||||||
|
const [imageSize, setImageSize] = useState<ImageSize>();
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [previewSize, setPreviewSize] = useState(384);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!file) return;
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
setSource(url);
|
||||||
|
setImageSize(undefined);
|
||||||
|
setZoom(1);
|
||||||
|
setPosition({ x: 0, y: 0 });
|
||||||
|
return () => URL.revokeObjectURL(url);
|
||||||
|
}, [file]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const preview = previewRef.current;
|
||||||
|
if (!preview) return;
|
||||||
|
const observer = new ResizeObserver(([entry]) => {
|
||||||
|
if (entry) setPreviewSize(entry.contentRect.width);
|
||||||
|
});
|
||||||
|
observer.observe(preview);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EntityFormModal
|
||||||
|
onClose={onClose}
|
||||||
|
open={Boolean(file)}
|
||||||
|
panelClassName="max-w-md"
|
||||||
|
saving={saving}
|
||||||
|
title="Ridimensiona immagine profilo"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
L'immagine parte interamente visibile. Trascinala o aumenta lo zoom solo
|
||||||
|
se vuoi regolare l'inquadratura.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 grid gap-4">
|
||||||
|
<div
|
||||||
|
className="relative mx-auto aspect-square w-full max-w-56 touch-none overflow-hidden rounded-lg border border-white/10 bg-slate-900 shadow-lg shadow-sky-950/40"
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
dragRef.current = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
onPointerMove={(event) => {
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||||
|
setPosition((current) =>
|
||||||
|
clampPosition(
|
||||||
|
{
|
||||||
|
x: current.x + event.clientX - drag.x,
|
||||||
|
y: current.y + event.clientY - drag.y,
|
||||||
|
},
|
||||||
|
imageSize,
|
||||||
|
previewSize,
|
||||||
|
zoom,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
dragRef.current = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
onPointerUp={(event) => {
|
||||||
|
if (dragRef.current?.pointerId === event.pointerId) {
|
||||||
|
dragRef.current = undefined;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerCancel={() => {
|
||||||
|
dragRef.current = undefined;
|
||||||
|
}}
|
||||||
|
ref={previewRef}
|
||||||
|
>
|
||||||
|
{source ? (
|
||||||
|
<img
|
||||||
|
alt="Anteprima ritaglio avatar"
|
||||||
|
className="pointer-events-none absolute inset-0 h-full w-full origin-center select-none object-contain"
|
||||||
|
draggable={false}
|
||||||
|
onLoad={(event) => {
|
||||||
|
const height = event.currentTarget.naturalHeight;
|
||||||
|
const width = event.currentTarget.naturalWidth;
|
||||||
|
setImageSize((current) => {
|
||||||
|
if (current) return current;
|
||||||
|
setPosition({ x: 0, y: 0 });
|
||||||
|
return { height, width };
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
ref={imageRef}
|
||||||
|
src={source}
|
||||||
|
style={imageStyle(imageSize, previewSize, zoom, position)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mx-auto grid w-full max-w-sm gap-3">
|
||||||
|
<div className="rounded-lg border border-white/10 bg-white/[0.03] p-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-300">
|
||||||
|
<LuZoomIn className="text-sky-300" size={16} />
|
||||||
|
Zoom
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
aria-label="Zoom avatar"
|
||||||
|
className="mt-3 w-full accent-sky-400"
|
||||||
|
max="3"
|
||||||
|
min="1"
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextZoom = Number(event.target.value);
|
||||||
|
setZoom(nextZoom);
|
||||||
|
setPosition((current) =>
|
||||||
|
clampPosition(current, imageSize, previewSize, nextZoom),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
step="0.01"
|
||||||
|
type="range"
|
||||||
|
value={zoom}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextFile = event.target.files?.[0];
|
||||||
|
if (nextFile) onSelectFile(nextFile);
|
||||||
|
event.target.value = "";
|
||||||
|
}}
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-md border border-white/10 px-3 py-2 text-sm text-slate-200 transition hover:bg-white/5 disabled:opacity-50"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<LuImagePlus size={16} />
|
||||||
|
Sostituisci
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-md border border-white/10 px-3 py-2 text-sm text-slate-200 transition hover:bg-white/5"
|
||||||
|
onClick={() => {
|
||||||
|
setZoom(1);
|
||||||
|
setPosition({ x: 0, y: 0 });
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<LuRotateCcw size={16} />
|
||||||
|
Ripristina
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex flex-wrap justify-end gap-2 border-t border-white/10 pt-4">
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-white/10 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/5"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Annulla
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-400 disabled:opacity-50"
|
||||||
|
disabled={saving || !source || !imageSize}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!file || !imageRef.current) return;
|
||||||
|
await onConfirm(
|
||||||
|
await croppedAvatarFile(
|
||||||
|
imageRef.current,
|
||||||
|
file.name,
|
||||||
|
previewSize,
|
||||||
|
zoom,
|
||||||
|
position,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<LuCrop size={16} />
|
||||||
|
{saving ? "Salvataggio..." : "Salva immagine"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</EntityFormModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageStyle(
|
||||||
|
_imageSize: ImageSize | undefined,
|
||||||
|
_previewSize: number,
|
||||||
|
zoom: number,
|
||||||
|
position: { x: number; y: number },
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
transform: `translate(${position.x}px, ${position.y}px) scale(${zoom})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPosition(
|
||||||
|
position: { x: number; y: number },
|
||||||
|
imageSize: ImageSize | undefined,
|
||||||
|
previewSize: number,
|
||||||
|
zoom: number,
|
||||||
|
) {
|
||||||
|
if (!imageSize) return position;
|
||||||
|
const baseScale = initialImageScale(imageSize, previewSize);
|
||||||
|
const maxX = Math.max(
|
||||||
|
0,
|
||||||
|
(imageSize.width * baseScale * zoom - previewSize) / 2,
|
||||||
|
);
|
||||||
|
const maxY = Math.max(
|
||||||
|
0,
|
||||||
|
(imageSize.height * baseScale * zoom - previewSize) / 2,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
x: Math.min(maxX, Math.max(-maxX, position.x)),
|
||||||
|
y: Math.min(maxY, Math.max(-maxY, position.y)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function croppedAvatarFile(
|
||||||
|
image: HTMLImageElement,
|
||||||
|
originalFilename: string,
|
||||||
|
previewSize: number,
|
||||||
|
zoom: number,
|
||||||
|
position: { x: number; y: number },
|
||||||
|
) {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.height = OUTPUT_SIZE;
|
||||||
|
canvas.width = OUTPUT_SIZE;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) throw new Error("Impossibile elaborare l'immagine");
|
||||||
|
|
||||||
|
const outputScale = OUTPUT_SIZE / previewSize;
|
||||||
|
const baseScale = initialImageScale(
|
||||||
|
{ height: image.naturalHeight, width: image.naturalWidth },
|
||||||
|
previewSize,
|
||||||
|
);
|
||||||
|
const scale = baseScale * zoom * outputScale;
|
||||||
|
const width = image.naturalWidth * scale;
|
||||||
|
const height = image.naturalHeight * scale;
|
||||||
|
context.drawImage(
|
||||||
|
image,
|
||||||
|
(OUTPUT_SIZE - width) / 2 + position.x * outputScale,
|
||||||
|
(OUTPUT_SIZE - height) / 2 + position.y * outputScale,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
);
|
||||||
|
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(result) =>
|
||||||
|
result
|
||||||
|
? resolve(result)
|
||||||
|
: reject(new Error("Esportazione non riuscita")),
|
||||||
|
"image/webp",
|
||||||
|
0.9,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return new File([blob], `${stripExtension(originalFilename)}.webp`, {
|
||||||
|
type: "image/webp",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialImageScale(imageSize: ImageSize, previewSize: number) {
|
||||||
|
return Math.min(
|
||||||
|
previewSize / imageSize.width,
|
||||||
|
previewSize / imageSize.height,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripExtension(filename: string) {
|
||||||
|
return filename.replace(/\.[^.]+$/, "") || "avatar";
|
||||||
|
}
|
||||||
45
src/components/profile/ConfirmDangerModal.tsx
Normal file
45
src/components/profile/ConfirmDangerModal.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { EntityFormModal } from "@/components/shared/EntityFormModal";
|
||||||
|
|
||||||
|
export function ConfirmDangerModal({
|
||||||
|
saving,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
saving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => Promise<void>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<EntityFormModal
|
||||||
|
onClose={onClose}
|
||||||
|
open
|
||||||
|
saving={saving}
|
||||||
|
title="Elimina dati finanziari"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-slate-300">
|
||||||
|
Questa azione elimina definitivamente tutti i tuoi dati finanziari. I
|
||||||
|
dati di accesso rimangono salvati.
|
||||||
|
</p>
|
||||||
|
<div className="mt-5 flex gap-2">
|
||||||
|
<button
|
||||||
|
className="rounded-md bg-rose-500 px-4 py-2 text-sm font-medium text-white hover:bg-rose-400 disabled:opacity-50"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={onConfirm}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{saving ? "Eliminazione..." : "Conferma"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-white/10 px-4 py-2 text-sm text-slate-200 hover:bg-white/5 disabled:opacity-50"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Annulla
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</EntityFormModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/components/profile/DangerZoneCard.tsx
Normal file
34
src/components/profile/DangerZoneCard.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { ProfileCard } from "@/components/profile/ProfileCard";
|
||||||
|
|
||||||
|
export function DangerZoneCard({
|
||||||
|
onDeleteAccount,
|
||||||
|
onDeleteAll,
|
||||||
|
}: {
|
||||||
|
onDeleteAccount: () => void;
|
||||||
|
onDeleteAll: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ProfileCard
|
||||||
|
className="border-rose-400/20 bg-rose-950/10"
|
||||||
|
description="Azioni irreversibili protette da una conferma esplicita."
|
||||||
|
title="Gestione dati"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-rose-400/30 px-3 py-2 text-sm text-rose-100 transition hover:bg-rose-400/10"
|
||||||
|
onClick={onDeleteAll}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Elimina tutti i dati finanziari
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-md bg-rose-500 px-3 py-2 text-sm font-medium text-white transition hover:bg-rose-400"
|
||||||
|
onClick={onDeleteAccount}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Elimina account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ProfileCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/components/profile/DeleteAccountModal.tsx
Normal file
77
src/components/profile/DeleteAccountModal.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { LuTrash2 } from "react-icons/lu";
|
||||||
|
import { EntityFormModal } from "@/components/shared/EntityFormModal";
|
||||||
|
import { Field, inputClass } from "@/components/shared/Field";
|
||||||
|
|
||||||
|
export function DeleteAccountModal({
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
open,
|
||||||
|
saving,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (currentPassword: string) => Promise<boolean>;
|
||||||
|
open: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
}) {
|
||||||
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setCurrentPassword("");
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EntityFormModal
|
||||||
|
onClose={onClose}
|
||||||
|
open={open}
|
||||||
|
saving={saving}
|
||||||
|
title="Elimina account"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-slate-300">
|
||||||
|
Verranno eliminati definitivamente account, sessioni, avatar e tutti i
|
||||||
|
dati finanziari.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-rose-300">
|
||||||
|
Questa operazione non può essere annullata.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
className="mt-5"
|
||||||
|
onSubmit={async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (await onConfirm(currentPassword)) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Inserisci la password attuale per confermare">
|
||||||
|
<input
|
||||||
|
autoComplete="current-password"
|
||||||
|
className={inputClass}
|
||||||
|
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="mt-5 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 rounded-md bg-rose-500 px-4 py-2 text-sm font-medium text-white transition hover:bg-rose-400 disabled:opacity-50"
|
||||||
|
disabled={saving}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
<LuTrash2 size={16} />
|
||||||
|
{saving ? "Eliminazione..." : "Elimina definitivamente"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-white/10 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/5"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Annulla
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</EntityFormModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
src/components/profile/PasswordEditModal.tsx
Normal file
118
src/components/profile/PasswordEditModal.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { EntityFormModal } from "@/components/shared/EntityFormModal";
|
||||||
|
import { Field, inputClass } from "@/components/shared/Field";
|
||||||
|
import {
|
||||||
|
SimpleFormActions,
|
||||||
|
SimpleFormReview,
|
||||||
|
SimpleFormStepIndicator,
|
||||||
|
} from "@/components/shared/SimpleFormSteps";
|
||||||
|
|
||||||
|
export function PasswordEditModal({
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
open,
|
||||||
|
saving,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (payload: {
|
||||||
|
currentPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
}) => Promise<boolean>;
|
||||||
|
open: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
}) {
|
||||||
|
const [review, setReview] = useState(false);
|
||||||
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setReview(false);
|
||||||
|
setCurrentPassword("");
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
async function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!review) {
|
||||||
|
setReview(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (await onSubmit({ currentPassword, newPassword, confirmPassword })) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EntityFormModal
|
||||||
|
onClose={onClose}
|
||||||
|
open={open}
|
||||||
|
saving={saving}
|
||||||
|
title="Modifica password"
|
||||||
|
>
|
||||||
|
<SimpleFormStepIndicator review={review} />
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
{review ? (
|
||||||
|
<SimpleFormReview
|
||||||
|
rows={[
|
||||||
|
{
|
||||||
|
label: "Password attuale",
|
||||||
|
value: "Verrà verificata al salvataggio",
|
||||||
|
},
|
||||||
|
{ label: "Nuova password", value: "••••••••" },
|
||||||
|
]}
|
||||||
|
title="Conferma il cambio password"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<Field label="Password attuale">
|
||||||
|
<input
|
||||||
|
autoComplete="current-password"
|
||||||
|
className={inputClass}
|
||||||
|
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="Nuova password">
|
||||||
|
<input
|
||||||
|
autoComplete="new-password"
|
||||||
|
className={inputClass}
|
||||||
|
minLength={8}
|
||||||
|
onChange={(event) => setNewPassword(event.target.value)}
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Conferma nuova password">
|
||||||
|
<input
|
||||||
|
autoComplete="new-password"
|
||||||
|
className={inputClass}
|
||||||
|
minLength={8}
|
||||||
|
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<SimpleFormActions
|
||||||
|
onBack={() => setReview(false)}
|
||||||
|
onCancel={onClose}
|
||||||
|
review={review}
|
||||||
|
saving={saving}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</EntityFormModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/components/profile/ProfileCard.tsx
Normal file
25
src/components/profile/ProfileCard.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export function ProfileCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
contentClassName = "",
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
contentClassName?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`flex flex-col rounded-lg border border-white/10 bg-white/[0.03] p-4 ${className}`}
|
||||||
|
>
|
||||||
|
<h2 className="font-medium text-white">{title}</h2>
|
||||||
|
{description ? (
|
||||||
|
<p className="mt-1 text-sm text-slate-400">{description}</p>
|
||||||
|
) : null}
|
||||||
|
<div className={`mt-4 ${contentClassName}`}>{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
225
src/components/profile/ProfilePageClient.tsx
Normal file
225
src/components/profile/ProfilePageClient.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { AccountCard } from "@/components/profile/AccountCard";
|
||||||
|
import { AccountEditModal } from "@/components/profile/AccountEditModal";
|
||||||
|
import { AvatarCropModal } from "@/components/profile/AvatarCropModal";
|
||||||
|
import { ConfirmDangerModal } from "@/components/profile/ConfirmDangerModal";
|
||||||
|
import { DangerZoneCard } from "@/components/profile/DangerZoneCard";
|
||||||
|
import { DeleteAccountModal } from "@/components/profile/DeleteAccountModal";
|
||||||
|
import { PasswordEditModal } from "@/components/profile/PasswordEditModal";
|
||||||
|
import { PageShell } from "@/components/shared/PageShell";
|
||||||
|
import { notify } from "@/components/shared/Toast";
|
||||||
|
import {
|
||||||
|
apiSave,
|
||||||
|
invalidateFinancialResources,
|
||||||
|
} from "@/components/shared/useFinanceData";
|
||||||
|
import type { UserSession } from "@/types/finance";
|
||||||
|
|
||||||
|
export function ProfilePageClient({ user }: { user: UserSession }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [account, setAccount] = useState(user);
|
||||||
|
const [savingAccount, setSavingAccount] = useState(false);
|
||||||
|
const [savingPassword, setSavingPassword] = useState(false);
|
||||||
|
const [savingAvatar, setSavingAvatar] = useState(false);
|
||||||
|
const [deletingData, setDeletingData] = useState(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const [deletingAccount, setDeletingAccount] = useState(false);
|
||||||
|
const [confirmDeleteAccount, setConfirmDeleteAccount] = useState(false);
|
||||||
|
const [editingAccount, setEditingAccount] = useState(false);
|
||||||
|
const [editingPassword, setEditingPassword] = useState(false);
|
||||||
|
const [selectedAvatar, setSelectedAvatar] = useState<File>();
|
||||||
|
|
||||||
|
async function saveAccount(payload: { name: string; email: string }) {
|
||||||
|
setSavingAccount(true);
|
||||||
|
try {
|
||||||
|
const saved = await apiSave<UserSession>(
|
||||||
|
"/api/profile/account",
|
||||||
|
"PATCH",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
setAccount(saved);
|
||||||
|
router.refresh();
|
||||||
|
notify("Dati account aggiornati");
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
notify(errorMessage(error), "error");
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setSavingAccount(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePassword(payload: {
|
||||||
|
currentPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
}) {
|
||||||
|
setSavingPassword(true);
|
||||||
|
try {
|
||||||
|
await apiSave("/api/profile/account", "POST", payload);
|
||||||
|
notify("Password aggiornata");
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
notify(errorMessage(error), "error");
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setSavingPassword(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadAvatar(file: File) {
|
||||||
|
setSavingAvatar(true);
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set("avatar", file);
|
||||||
|
const saved = await saveAvatarRequest("POST", formData);
|
||||||
|
setAccount(saved);
|
||||||
|
router.refresh();
|
||||||
|
notify("Immagine profilo aggiornata");
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
notify(errorMessage(error), "error");
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setSavingAvatar(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editAvatar() {
|
||||||
|
if (!account.avatarUrl) return;
|
||||||
|
setSavingAvatar(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(account.avatarUrl, { cache: "no-store" });
|
||||||
|
if (!response.ok) throw new Error("Impossibile caricare l'immagine.");
|
||||||
|
const blob = await response.blob();
|
||||||
|
setSelectedAvatar(
|
||||||
|
new File([blob], avatarFilename(blob.type), { type: blob.type }),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
notify(errorMessage(error), "error");
|
||||||
|
} finally {
|
||||||
|
setSavingAvatar(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAvatar() {
|
||||||
|
setSavingAvatar(true);
|
||||||
|
try {
|
||||||
|
const saved = await saveAvatarRequest("DELETE");
|
||||||
|
setAccount(saved);
|
||||||
|
router.refresh();
|
||||||
|
notify("Immagine profilo rimossa");
|
||||||
|
} catch (error) {
|
||||||
|
notify(errorMessage(error), "error");
|
||||||
|
} finally {
|
||||||
|
setSavingAvatar(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFinancialData() {
|
||||||
|
setDeletingData(true);
|
||||||
|
try {
|
||||||
|
await apiSave("/api/profile/data", "DELETE");
|
||||||
|
invalidateFinancialResources();
|
||||||
|
setConfirmDelete(false);
|
||||||
|
notify("Dati finanziari eliminati");
|
||||||
|
} catch (error) {
|
||||||
|
notify(errorMessage(error), "error");
|
||||||
|
} finally {
|
||||||
|
setDeletingData(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAccount(currentPassword: string) {
|
||||||
|
setDeletingAccount(true);
|
||||||
|
try {
|
||||||
|
await apiSave("/api/profile/account", "DELETE", { currentPassword });
|
||||||
|
notify("Account eliminato");
|
||||||
|
router.push("/setup");
|
||||||
|
router.refresh();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
notify(errorMessage(error), "error");
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setDeletingAccount(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell
|
||||||
|
description="Gestisci i tuoi dati di accesso e la sicurezza dell'account."
|
||||||
|
title="Profilo"
|
||||||
|
>
|
||||||
|
<section className="grid gap-4 rounded-xl border border-white/10 bg-white/[0.025] p-4">
|
||||||
|
<AccountCard
|
||||||
|
onDeleteAvatar={deleteAvatar}
|
||||||
|
onEditAccount={() => setEditingAccount(true)}
|
||||||
|
onEditAvatar={editAvatar}
|
||||||
|
onEditPassword={() => setEditingPassword(true)}
|
||||||
|
onSelectAvatar={setSelectedAvatar}
|
||||||
|
savingAvatar={savingAvatar}
|
||||||
|
user={account}
|
||||||
|
/>
|
||||||
|
<DangerZoneCard
|
||||||
|
onDeleteAccount={() => setConfirmDeleteAccount(true)}
|
||||||
|
onDeleteAll={() => setConfirmDelete(true)}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<AccountEditModal
|
||||||
|
onClose={() => setEditingAccount(false)}
|
||||||
|
onSubmit={saveAccount}
|
||||||
|
open={editingAccount}
|
||||||
|
saving={savingAccount}
|
||||||
|
user={account}
|
||||||
|
/>
|
||||||
|
<AvatarCropModal
|
||||||
|
file={selectedAvatar}
|
||||||
|
onClose={() => setSelectedAvatar(undefined)}
|
||||||
|
onConfirm={async (file) => {
|
||||||
|
if (await uploadAvatar(file)) setSelectedAvatar(undefined);
|
||||||
|
}}
|
||||||
|
onSelectFile={setSelectedAvatar}
|
||||||
|
saving={savingAvatar}
|
||||||
|
/>
|
||||||
|
<PasswordEditModal
|
||||||
|
onClose={() => setEditingPassword(false)}
|
||||||
|
onSubmit={savePassword}
|
||||||
|
open={editingPassword}
|
||||||
|
saving={savingPassword}
|
||||||
|
/>
|
||||||
|
{confirmDelete ? (
|
||||||
|
<ConfirmDangerModal
|
||||||
|
onClose={() => setConfirmDelete(false)}
|
||||||
|
onConfirm={deleteFinancialData}
|
||||||
|
saving={deletingData}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<DeleteAccountModal
|
||||||
|
onClose={() => setConfirmDeleteAccount(false)}
|
||||||
|
onConfirm={deleteAccount}
|
||||||
|
open={confirmDeleteAccount}
|
||||||
|
saving={deletingAccount}
|
||||||
|
/>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function avatarFilename(mimeType: string) {
|
||||||
|
if (mimeType === "image/png") return "avatar.png";
|
||||||
|
if (mimeType === "image/jpeg") return "avatar.jpg";
|
||||||
|
return "avatar.webp";
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown) {
|
||||||
|
return error instanceof Error ? error.message : "Operazione non riuscita";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAvatarRequest(method: "POST" | "DELETE", body?: FormData) {
|
||||||
|
const response = await fetch("/api/profile/avatar", { method, body });
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) throw new Error(payload.error ?? "Operazione non riuscita");
|
||||||
|
return payload as UserSession;
|
||||||
|
}
|
||||||
66
src/components/reports/BillsInsights.tsx
Normal file
66
src/components/reports/BillsInsights.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { EmptyState } from "@/components/shared/EmptyState";
|
||||||
|
import { formatItalianDate } from "@/lib/date/formatDate";
|
||||||
|
import { euroFormatter } from "@/lib/finance/money";
|
||||||
|
import type { getBillsInsights } from "@/lib/finance/reportAnalysis";
|
||||||
|
|
||||||
|
type Insights = ReturnType<typeof getBillsInsights>;
|
||||||
|
|
||||||
|
export function BillsInsights({
|
||||||
|
insights,
|
||||||
|
periodLabel,
|
||||||
|
}: {
|
||||||
|
insights: Insights;
|
||||||
|
periodLabel: string;
|
||||||
|
}) {
|
||||||
|
const biggest = insights.topBills[0];
|
||||||
|
return (
|
||||||
|
<section className="flex min-h-full flex-col rounded-lg border border-white/10 bg-white/[0.03] p-4">
|
||||||
|
<h2 className="font-medium text-white">Analisi bollette</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
|
Una lettura rapida delle bollette per {periodLabel}.
|
||||||
|
</p>
|
||||||
|
{biggest ? (
|
||||||
|
<div className="mt-4 grid flex-1 gap-3 sm:grid-cols-3">
|
||||||
|
<Insight
|
||||||
|
label="Totale periodo"
|
||||||
|
value={euroFormatter.format(insights.total)}
|
||||||
|
/>
|
||||||
|
<Insight
|
||||||
|
label="Importo medio"
|
||||||
|
value={euroFormatter.format(insights.average)}
|
||||||
|
/>
|
||||||
|
<Insight
|
||||||
|
label="Bolletta maggiore"
|
||||||
|
text={`${biggest.title} - ${formatItalianDate(biggest.dueDate)}`}
|
||||||
|
value={euroFormatter.format(biggest.amount)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4">
|
||||||
|
<EmptyState
|
||||||
|
text="Prova a selezionare un altro periodo."
|
||||||
|
title="Nessuna bolletta registrata"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Insight({
|
||||||
|
label,
|
||||||
|
text,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
text?: string;
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col justify-center rounded-md border border-white/10 bg-white/[0.025] p-3">
|
||||||
|
<p className="text-xs text-slate-500">{label}</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold text-white">{value}</p>
|
||||||
|
{text ? <p className="mt-1 text-xs text-slate-500">{text}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/components/reports/ExpenseBreakdown.tsx
Normal file
41
src/components/reports/ExpenseBreakdown.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { ExpenseDistributionItem } from "@/lib/finance/expenseDistribution";
|
||||||
|
import { euroFormatter } from "@/lib/finance/money";
|
||||||
|
|
||||||
|
export function ExpenseBreakdown({
|
||||||
|
items,
|
||||||
|
periodLabel,
|
||||||
|
}: {
|
||||||
|
items: ExpenseDistributionItem[];
|
||||||
|
periodLabel: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="flex min-h-full flex-col rounded-lg border border-white/10 bg-white/[0.03] p-4">
|
||||||
|
<h2 className="font-medium text-white">Dettaglio spese</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
|
Quanto incidono le diverse tipologie sulla spesa reale.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 grid flex-1 gap-3 sm:grid-cols-3">
|
||||||
|
{items.map((item) => (
|
||||||
|
<article
|
||||||
|
className="flex flex-col justify-center rounded-lg border border-white/10 bg-white/[0.025] p-4"
|
||||||
|
key={item.sourceType}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="h-2.5 w-2.5 rounded-full"
|
||||||
|
style={{ backgroundColor: item.color }}
|
||||||
|
/>
|
||||||
|
<h3 className="text-sm text-slate-300">{item.label}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xl font-semibold text-white">
|
||||||
|
{euroFormatter.format(item.amount)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
{item.percentage.toFixed(1)}% delle spese per {periodLabel}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/components/reports/MonthSummary.tsx
Normal file
48
src/components/reports/MonthSummary.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { euroFormatter } from "@/lib/finance/money";
|
||||||
|
|
||||||
|
export function MonthSummary({
|
||||||
|
income,
|
||||||
|
label,
|
||||||
|
projected,
|
||||||
|
scope,
|
||||||
|
spent,
|
||||||
|
}: {
|
||||||
|
income: number;
|
||||||
|
label: string;
|
||||||
|
projected: number;
|
||||||
|
scope: "mese" | "anno";
|
||||||
|
spent: number;
|
||||||
|
}) {
|
||||||
|
const balance = income - spent;
|
||||||
|
const savingsRate = income ? (balance / income) * 100 : 0;
|
||||||
|
const cards = [
|
||||||
|
[`Entrate ${scope}`, euroFormatter.format(income)],
|
||||||
|
["Spese reali", euroFormatter.format(spent)],
|
||||||
|
["Spese previste", euroFormatter.format(projected)],
|
||||||
|
[`Saldo ${scope}`, euroFormatter.format(balance)],
|
||||||
|
[`Risparmio ${scope}`, `${savingsRate.toFixed(1)}%`],
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<section className="grid gap-2 lg:grid-cols-[minmax(10rem,0.72fr)_minmax(0,4fr)]">
|
||||||
|
<div className="flex flex-col justify-center rounded-lg border border-sky-400/20 bg-white/[0.04] p-4">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-sky-300">
|
||||||
|
Periodo selezionato
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-1 text-lg font-semibold text-white">{label}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 rounded-lg border border-sky-400/20 bg-white/[0.04] p-4 md:grid-cols-5">
|
||||||
|
{cards.map(([cardLabel, value]) => (
|
||||||
|
<div
|
||||||
|
className="flex flex-col justify-center rounded-md border border-white/10 bg-slate-950/35 px-3 py-2"
|
||||||
|
key={cardLabel}
|
||||||
|
>
|
||||||
|
<p className="text-[11px] text-slate-500">{cardLabel}</p>
|
||||||
|
<p className="mt-1 truncate text-sm font-semibold text-white">
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
186
src/components/reports/ReportsPageClient.tsx
Normal file
186
src/components/reports/ReportsPageClient.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { ExpenseDistribution } from "@/components/dashboard/ExpenseDistribution";
|
||||||
|
import { BillsInsights } from "@/components/reports/BillsInsights";
|
||||||
|
import { ExpenseBreakdown } from "@/components/reports/ExpenseBreakdown";
|
||||||
|
import { MonthSummary } from "@/components/reports/MonthSummary";
|
||||||
|
import { SubscriptionInsights } from "@/components/reports/SubscriptionInsights";
|
||||||
|
import { LocalMonthNavigator } from "@/components/shared/LocalMonthNavigator";
|
||||||
|
import { PageShell } from "@/components/shared/PageShell";
|
||||||
|
import {
|
||||||
|
useBillsResource,
|
||||||
|
useIncomeResource,
|
||||||
|
useLedgerResource,
|
||||||
|
useSubscriptionPaymentsResource,
|
||||||
|
useSubscriptionsResource,
|
||||||
|
} from "@/components/shared/useFinanceData";
|
||||||
|
import { monthKey } from "@/lib/date/formatDate";
|
||||||
|
import {
|
||||||
|
getMonthlyExpenseDistribution,
|
||||||
|
getYearlyExpenseDistribution,
|
||||||
|
} from "@/lib/finance/expenseDistribution";
|
||||||
|
import {
|
||||||
|
getBillsInsights,
|
||||||
|
getMonthlyReportSummary,
|
||||||
|
getSubscriptionInsights,
|
||||||
|
} from "@/lib/finance/reportAnalysis";
|
||||||
|
import { buildYearlyDashboardSummary } from "@/lib/finance/yearlySummary";
|
||||||
|
|
||||||
|
const fullMonthLabels = [
|
||||||
|
"Gennaio",
|
||||||
|
"Febbraio",
|
||||||
|
"Marzo",
|
||||||
|
"Aprile",
|
||||||
|
"Maggio",
|
||||||
|
"Giugno",
|
||||||
|
"Luglio",
|
||||||
|
"Agosto",
|
||||||
|
"Settembre",
|
||||||
|
"Ottobre",
|
||||||
|
"Novembre",
|
||||||
|
"Dicembre",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ReportsPageClient({ initialYear }: { initialYear: number }) {
|
||||||
|
const subscriptions = useSubscriptionsResource();
|
||||||
|
const subscriptionPayments = useSubscriptionPaymentsResource();
|
||||||
|
const bills = useBillsResource();
|
||||||
|
const ledger = useLedgerResource();
|
||||||
|
const income = useIncomeResource();
|
||||||
|
const [period, setPeriod] = useState(() => defaultMonth(initialYear));
|
||||||
|
const selectedYear = Number(period.slice(0, 4));
|
||||||
|
const yearlySummary = buildYearlyDashboardSummary({
|
||||||
|
year: selectedYear,
|
||||||
|
ledger: ledger.data ?? [],
|
||||||
|
income: income.data ?? [],
|
||||||
|
subscriptions: subscriptions.data ?? [],
|
||||||
|
subscriptionPayments: subscriptionPayments.data ?? [],
|
||||||
|
bills: bills.data ?? [],
|
||||||
|
});
|
||||||
|
const resources = [
|
||||||
|
subscriptions,
|
||||||
|
subscriptionPayments,
|
||||||
|
bills,
|
||||||
|
ledger,
|
||||||
|
income,
|
||||||
|
];
|
||||||
|
const loading = resources.some((resource) => resource.loading);
|
||||||
|
const error = resources.find((resource) => resource.error)?.error;
|
||||||
|
const annual = period.length === 4;
|
||||||
|
const selectedMonthSummary = useMemo(
|
||||||
|
() =>
|
||||||
|
annual
|
||||||
|
? undefined
|
||||||
|
: getMonthlyReportSummary({
|
||||||
|
month: period,
|
||||||
|
ledger: ledger.data ?? [],
|
||||||
|
income: income.data ?? [],
|
||||||
|
}),
|
||||||
|
[annual, income.data, ledger.data, period],
|
||||||
|
);
|
||||||
|
const selectedMonthlyPoint = annual
|
||||||
|
? undefined
|
||||||
|
: yearlySummary.monthly.find((point) => point.month === period);
|
||||||
|
const periodSummary = annual
|
||||||
|
? {
|
||||||
|
income: yearlySummary.yearlyIncome,
|
||||||
|
projected: yearlySummary.projectedSpent,
|
||||||
|
spent: yearlySummary.realSpent,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
income: selectedMonthSummary?.income ?? 0,
|
||||||
|
projected: selectedMonthlyPoint?.projected ?? 0,
|
||||||
|
spent: selectedMonthSummary?.spent ?? 0,
|
||||||
|
};
|
||||||
|
const distribution = useMemo(
|
||||||
|
() =>
|
||||||
|
annual
|
||||||
|
? getYearlyExpenseDistribution(ledger.data ?? [], selectedYear)
|
||||||
|
: getMonthlyExpenseDistribution(ledger.data ?? [], period),
|
||||||
|
[annual, ledger.data, period, selectedYear],
|
||||||
|
);
|
||||||
|
const subscriptionInsights = useMemo(
|
||||||
|
() => getSubscriptionInsights(subscriptions.data ?? [], period),
|
||||||
|
[period, subscriptions.data],
|
||||||
|
);
|
||||||
|
const billsInsights = useMemo(
|
||||||
|
() => getBillsInsights(bills.data ?? [], period),
|
||||||
|
[bills.data, period],
|
||||||
|
);
|
||||||
|
const populatedMonths = useMemo(
|
||||||
|
() =>
|
||||||
|
new Set(
|
||||||
|
yearlySummary.monthly
|
||||||
|
.filter(
|
||||||
|
(point) =>
|
||||||
|
point.income > 0 || point.spent > 0 || point.projected > 0,
|
||||||
|
)
|
||||||
|
.map((point) => point.month),
|
||||||
|
),
|
||||||
|
[yearlySummary.monthly],
|
||||||
|
);
|
||||||
|
const selectedPeriodLabel = annual
|
||||||
|
? `l'anno ${selectedYear}`
|
||||||
|
: monthLabel(period);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell
|
||||||
|
description="Una lettura immediata di quanto spendi e dove va il tuo denaro."
|
||||||
|
title="Report"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-slate-400">Caricamento dati...</p>
|
||||||
|
) : null}
|
||||||
|
{error ? <p className="text-sm text-rose-300">{error}</p> : null}
|
||||||
|
<section className="grid gap-4 rounded-lg border border-white/10 bg-white/[0.03] p-4">
|
||||||
|
<LocalMonthNavigator
|
||||||
|
allowYearSelection
|
||||||
|
month={period}
|
||||||
|
onChange={setPeriod}
|
||||||
|
populatedMonths={populatedMonths}
|
||||||
|
/>
|
||||||
|
<MonthSummary
|
||||||
|
income={periodSummary.income}
|
||||||
|
label={annual ? `Anno ${selectedYear}` : monthLabel(period)}
|
||||||
|
projected={periodSummary.projected}
|
||||||
|
scope={annual ? "anno" : "mese"}
|
||||||
|
spent={periodSummary.spent}
|
||||||
|
/>
|
||||||
|
<div className="grid items-stretch gap-4 xl:grid-cols-[minmax(20rem,0.82fr)_minmax(0,1.18fr)]">
|
||||||
|
<ExpenseDistribution
|
||||||
|
items={distribution.items}
|
||||||
|
periodLabel={selectedPeriodLabel}
|
||||||
|
showReportLink={false}
|
||||||
|
total={distribution.total}
|
||||||
|
year={selectedYear}
|
||||||
|
/>
|
||||||
|
<ExpenseBreakdown
|
||||||
|
items={distribution.items}
|
||||||
|
periodLabel={selectedPeriodLabel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid items-stretch gap-4 xl:grid-cols-2">
|
||||||
|
<SubscriptionInsights
|
||||||
|
insights={subscriptionInsights}
|
||||||
|
periodLabel={selectedPeriodLabel}
|
||||||
|
/>
|
||||||
|
<BillsInsights
|
||||||
|
insights={billsInsights}
|
||||||
|
periodLabel={selectedPeriodLabel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultMonth(year: number) {
|
||||||
|
const current = monthKey();
|
||||||
|
return current.startsWith(`${year}-`) ? current : `${year}-01`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthLabel(month: string) {
|
||||||
|
const [year, monthNumber] = month.split("-");
|
||||||
|
return `${fullMonthLabels[Number(monthNumber) - 1]} ${year}`;
|
||||||
|
}
|
||||||
63
src/components/reports/SubscriptionInsights.tsx
Normal file
63
src/components/reports/SubscriptionInsights.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { EmptyState } from "@/components/shared/EmptyState";
|
||||||
|
import { euroFormatter } from "@/lib/finance/money";
|
||||||
|
import type { getSubscriptionInsights } from "@/lib/finance/reportAnalysis";
|
||||||
|
|
||||||
|
type Insight = ReturnType<typeof getSubscriptionInsights>[number];
|
||||||
|
|
||||||
|
export function SubscriptionInsights({
|
||||||
|
insights,
|
||||||
|
periodLabel,
|
||||||
|
}: {
|
||||||
|
insights: Insight[];
|
||||||
|
periodLabel: string;
|
||||||
|
}) {
|
||||||
|
const maximum = Math.max(1, ...insights.map((item) => item.periodCost));
|
||||||
|
return (
|
||||||
|
<section className="flex min-h-full flex-col rounded-lg border border-white/10 bg-white/[0.03] p-4">
|
||||||
|
<h2 className="font-medium text-white">Impatto abbonamenti</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
|
Abbonamenti attivi ordinati per costo stimato nel periodo selezionato.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 grid flex-1 gap-2.5">
|
||||||
|
{insights.length ? (
|
||||||
|
insights.map(({ subscription, periodCost }) => (
|
||||||
|
<div key={subscription.$id}>
|
||||||
|
<div className="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span className="truncate text-slate-200">
|
||||||
|
{subscription.title}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-slate-300">
|
||||||
|
{euroFormatter.format(periodCost)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-xs text-slate-500">
|
||||||
|
{euroFormatter.format(subscription.amount)} -{" "}
|
||||||
|
{cycleLabel(subscription.billingCycle)}
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 h-1 overflow-hidden rounded-full bg-slate-950">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-violet-400"
|
||||||
|
style={{ width: `${(periodCost / maximum) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<EmptyState title="Nessun abbonamento attivo" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xs text-slate-500">{periodLabel}</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cycleLabel(cycle: Insight["subscription"]["billingCycle"]) {
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
monthly: "al mese",
|
||||||
|
weekly: "a settimana",
|
||||||
|
yearly: "all'anno",
|
||||||
|
custom: "personalizzato",
|
||||||
|
}[cycle] ?? cycle
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/components/shared/AnnualPageToolbar.tsx
Normal file
20
src/components/shared/AnnualPageToolbar.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { YearSelector } from "@/components/dashboard/YearSelector";
|
||||||
|
|
||||||
|
export function AnnualPageToolbar({
|
||||||
|
year,
|
||||||
|
onChange,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
year: number;
|
||||||
|
onChange: (year: number) => void;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-nowrap items-center justify-center gap-2">
|
||||||
|
<YearSelector year={year} onChange={onChange} />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/components/shared/Badge.tsx
Normal file
26
src/components/shared/Badge.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
type BadgeTone = "green" | "yellow" | "red" | "blue" | "violet" | "neutral";
|
||||||
|
|
||||||
|
const tones: Record<BadgeTone, string> = {
|
||||||
|
green: "border-emerald-400/30 bg-emerald-400/10 text-emerald-200",
|
||||||
|
yellow: "border-amber-400/30 bg-amber-400/10 text-amber-200",
|
||||||
|
red: "border-rose-400/30 bg-rose-400/10 text-rose-200",
|
||||||
|
blue: "border-sky-400/30 bg-sky-400/10 text-sky-200",
|
||||||
|
violet: "border-violet-400/30 bg-violet-400/10 text-violet-200",
|
||||||
|
neutral: "border-white/10 bg-white/5 text-slate-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Badge({
|
||||||
|
children,
|
||||||
|
tone = "neutral",
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
tone?: BadgeTone;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex rounded-full border px-2 py-1 text-xs ${tones[tone]}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
220
src/components/shared/DateField.tsx
Normal file
220
src/components/shared/DateField.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { FaCalendarAlt } from "react-icons/fa";
|
||||||
|
import { Field, inputClass } from "@/components/shared/Field";
|
||||||
|
import {
|
||||||
|
addMonths,
|
||||||
|
formatItalianDate,
|
||||||
|
fromIso,
|
||||||
|
todayIso,
|
||||||
|
toIsoDate,
|
||||||
|
} from "@/lib/date/formatDate";
|
||||||
|
import { parseItalianDate } from "@/lib/date/parseItalianDate";
|
||||||
|
|
||||||
|
export function DateField({
|
||||||
|
label,
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
optional = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
optional?: boolean;
|
||||||
|
}) {
|
||||||
|
const [display, setDisplay] = useState(formatItalianDate(value));
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const calendarRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [viewMonth, setViewMonth] = useState(value.slice(0, 7));
|
||||||
|
const [calendarPosition, setCalendarPosition] = useState({
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDisplay(formatItalianDate(value));
|
||||||
|
setViewMonth(value.slice(0, 7));
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function close(event: MouseEvent) {
|
||||||
|
const target = event.target as Node;
|
||||||
|
if (
|
||||||
|
!wrapperRef.current?.contains(target) &&
|
||||||
|
!calendarRef.current?.contains(target)
|
||||||
|
) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", close);
|
||||||
|
return () => document.removeEventListener("mousedown", close);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function positionCalendar() {
|
||||||
|
const rect = buttonRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
const width = 288;
|
||||||
|
const gap = 8;
|
||||||
|
const left = Math.min(
|
||||||
|
Math.max(8, rect.right - width),
|
||||||
|
window.innerWidth - width - 8,
|
||||||
|
);
|
||||||
|
const fitsBelow = rect.bottom + gap + 336 <= window.innerHeight;
|
||||||
|
setCalendarPosition({
|
||||||
|
left,
|
||||||
|
top: fitsBelow ? rect.bottom + gap : Math.max(8, rect.top - 336 - gap),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
positionCalendar();
|
||||||
|
window.addEventListener("resize", positionCalendar);
|
||||||
|
window.addEventListener("scroll", positionCalendar, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", positionCalendar);
|
||||||
|
window.removeEventListener("scroll", positionCalendar, true);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const fallbackMonth = value.slice(0, 7) || todayIso().slice(0, 7);
|
||||||
|
const activeMonth = viewMonth || fallbackMonth;
|
||||||
|
const days = useMemo(() => calendarDays(activeMonth), [activeMonth]);
|
||||||
|
|
||||||
|
function commit(raw: string) {
|
||||||
|
if (optional && !raw.trim()) {
|
||||||
|
setError("");
|
||||||
|
onChange("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = parseItalianDate(raw);
|
||||||
|
if (!parsed) {
|
||||||
|
setError("Data non valida");
|
||||||
|
onChange("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError("");
|
||||||
|
onChange(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={wrapperRef}>
|
||||||
|
<Field label={`${label}${optional ? " (opzionale)" : ""}`}>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
inputMode="numeric"
|
||||||
|
name={name}
|
||||||
|
placeholder="DD/MM/YYYY"
|
||||||
|
value={display}
|
||||||
|
onBlur={() => commit(display)}
|
||||||
|
onChange={(event) => {
|
||||||
|
setDisplay(event.target.value);
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-white/10 px-3 text-sm text-slate-200 hover:bg-white/5"
|
||||||
|
onClick={() => setOpen((current) => !current)}
|
||||||
|
ref={buttonRef}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<FaCalendarAlt />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error ? <span className="text-xs text-rose-300">{error}</span> : null}
|
||||||
|
</Field>
|
||||||
|
{open
|
||||||
|
? createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed z-[70] w-72 rounded-lg border border-white/10 bg-slate-950 p-3 shadow-2xl"
|
||||||
|
ref={calendarRef}
|
||||||
|
style={calendarPosition}
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
className="rounded border border-white/10 px-2 py-1 text-xs"
|
||||||
|
onClick={() =>
|
||||||
|
setViewMonth(addMonths(`${activeMonth}-01`, -1).slice(0, 7))
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Indietro
|
||||||
|
</button>
|
||||||
|
<span className="text-sm font-medium text-white">
|
||||||
|
{activeMonth}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="rounded border border-white/10 px-2 py-1 text-xs"
|
||||||
|
onClick={() =>
|
||||||
|
setViewMonth(addMonths(`${activeMonth}-01`, 1).slice(0, 7))
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Avanti
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-1 text-center text-xs text-slate-500">
|
||||||
|
{[
|
||||||
|
["mon", "L"],
|
||||||
|
["tue", "M"],
|
||||||
|
["wed", "M"],
|
||||||
|
["thu", "G"],
|
||||||
|
["fri", "V"],
|
||||||
|
["sat", "S"],
|
||||||
|
["sun", "D"],
|
||||||
|
].map(([key, day]) => (
|
||||||
|
<span key={key}>{day}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 grid grid-cols-7 gap-1">
|
||||||
|
{days.map((day) => {
|
||||||
|
const active = day === value;
|
||||||
|
const inMonth = day.startsWith(activeMonth);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`rounded px-2 py-1.5 text-xs ${
|
||||||
|
active
|
||||||
|
? "bg-sky-500 text-white"
|
||||||
|
: inMonth
|
||||||
|
? "text-slate-200 hover:bg-white/10"
|
||||||
|
: "text-slate-600 hover:bg-white/5"
|
||||||
|
}`}
|
||||||
|
key={day}
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
setError("");
|
||||||
|
onChange(day);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{day.slice(8)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function calendarDays(month: string) {
|
||||||
|
const start = fromIso(`${month}-01`);
|
||||||
|
const mondayOffset = (start.getDay() + 6) % 7;
|
||||||
|
const gridStart = new Date(start);
|
||||||
|
gridStart.setDate(start.getDate() - mondayOffset);
|
||||||
|
return Array.from({ length: 42 }, (_, index) => {
|
||||||
|
const date = new Date(gridStart);
|
||||||
|
date.setDate(gridStart.getDate() + index);
|
||||||
|
return toIsoDate(date);
|
||||||
|
});
|
||||||
|
}
|
||||||
8
src/components/shared/EmptyState.tsx
Normal file
8
src/components/shared/EmptyState.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export function EmptyState({ title, text }: { title: string; text?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-dashed border-white/10 bg-white/[0.03] p-6 text-center">
|
||||||
|
<p className="font-medium text-slate-100">{title}</p>
|
||||||
|
{text ? <p className="mt-1 text-sm text-slate-400">{text}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
src/components/shared/EntityFormModal.tsx
Normal file
126
src/components/shared/EntityFormModal.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const focusableSelector = [
|
||||||
|
"button:not([disabled])",
|
||||||
|
"input:not([disabled])",
|
||||||
|
"select:not([disabled])",
|
||||||
|
"textarea:not([disabled])",
|
||||||
|
"[tabindex]:not([tabindex='-1'])",
|
||||||
|
].join(",");
|
||||||
|
|
||||||
|
export function EntityFormModal({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
saving,
|
||||||
|
onClose,
|
||||||
|
panelClassName = "max-w-2xl",
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
saving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
panelClassName?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const triggerRef = useRef<HTMLElement | null>(null);
|
||||||
|
const [mounted, setMounted] = useState(open);
|
||||||
|
const savingRef = useRef(saving);
|
||||||
|
const onCloseRef = useRef(onClose);
|
||||||
|
savingRef.current = saving;
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setMounted(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timeout = window.setTimeout(() => setMounted(false), 140);
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !mounted) return;
|
||||||
|
triggerRef.current = document.activeElement as HTMLElement | null;
|
||||||
|
const panel = panelRef.current;
|
||||||
|
const focusable = panel?.querySelectorAll<HTMLElement>(focusableSelector);
|
||||||
|
focusable?.[0]?.focus();
|
||||||
|
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape" && !savingRef.current) {
|
||||||
|
event.preventDefault();
|
||||||
|
onCloseRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key !== "Tab" || !panel) return;
|
||||||
|
const elements = Array.from(
|
||||||
|
panel.querySelectorAll<HTMLElement>(focusableSelector),
|
||||||
|
);
|
||||||
|
if (!elements.length) return;
|
||||||
|
const first = elements[0];
|
||||||
|
const last = elements[elements.length - 1];
|
||||||
|
if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
};
|
||||||
|
}, [open, mounted]);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-labelledby="entity-modal-title"
|
||||||
|
aria-modal="true"
|
||||||
|
className={`fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-slate-950/25 p-4 backdrop-blur-sm ${
|
||||||
|
open
|
||||||
|
? "animate-[modal-backdrop-in_160ms_ease-out]"
|
||||||
|
: "animate-[modal-backdrop-out_140ms_ease-in]"
|
||||||
|
}`}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
if (event.target === event.currentTarget && !saving) onClose();
|
||||||
|
}}
|
||||||
|
role="dialog"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`max-h-[calc(100vh-2rem)] w-full ${panelClassName} overflow-y-auto rounded-lg border border-white/10 bg-slate-950 p-5 shadow-2xl ${
|
||||||
|
open
|
||||||
|
? "animate-[modal-in_160ms_ease-out]"
|
||||||
|
: "animate-[modal-out_140ms_ease-in]"
|
||||||
|
}`}
|
||||||
|
ref={panelRef}
|
||||||
|
>
|
||||||
|
<div className="mb-5 flex items-start justify-between gap-3">
|
||||||
|
<h2
|
||||||
|
className="text-lg font-semibold text-white"
|
||||||
|
id="entity-modal-title"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
aria-label="Chiudi"
|
||||||
|
className="rounded-md border border-white/10 px-3 py-1.5 text-sm text-slate-300 hover:bg-white/5 disabled:opacity-50"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Chiudi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/components/shared/Field.tsx
Normal file
17
src/components/shared/Field.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export function Field({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-1.5 text-sm text-slate-300">
|
||||||
|
<span>{label}</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const inputClass =
|
||||||
|
"w-full rounded-md border border-white/10 bg-slate-950 px-3 py-2 text-sm text-slate-100 outline-none transition focus:border-sky-400";
|
||||||
54
src/components/shared/Header.tsx
Normal file
54
src/components/shared/Header.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { MobileSidebar } from "@/components/shared/navigation/MobileSidebar";
|
||||||
|
import { Sidebar } from "@/components/shared/navigation/Sidebar";
|
||||||
|
import { SidebarTrigger } from "@/components/shared/navigation/SidebarTrigger";
|
||||||
|
import { notify, Toast } from "@/components/shared/Toast";
|
||||||
|
import type { UserSession } from "@/types/finance";
|
||||||
|
|
||||||
|
export function Header({
|
||||||
|
user,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
user: UserSession;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
|
const sidebarTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await fetch("/api/auth/logout", { method: "POST" });
|
||||||
|
notify("Disconnessione effettuata");
|
||||||
|
router.push("/login");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#182033] text-slate-100">
|
||||||
|
<Sidebar onLogout={logout} pathname={pathname} user={user} />
|
||||||
|
<MobileSidebar
|
||||||
|
onClose={() => setMobileSidebarOpen(false)}
|
||||||
|
onLogout={logout}
|
||||||
|
open={mobileSidebarOpen}
|
||||||
|
pathname={pathname}
|
||||||
|
triggerRef={sidebarTriggerRef}
|
||||||
|
user={user}
|
||||||
|
/>
|
||||||
|
<div className="lg:pl-64">
|
||||||
|
<header className="sticky top-0 z-20 bg-[#182033]/90 px-4 py-3 lg:hidden">
|
||||||
|
<SidebarTrigger
|
||||||
|
onClick={() => setMobileSidebarOpen(true)}
|
||||||
|
open={mobileSidebarOpen}
|
||||||
|
ref={sidebarTriggerRef}
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
<main className="px-4 py-6 md:px-8">{children}</main>
|
||||||
|
</div>
|
||||||
|
<Toast />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
src/components/shared/ListCardAction.tsx
Normal file
90
src/components/shared/ListCardAction.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { IconType } from "react-icons";
|
||||||
|
import { EntityFormModal } from "@/components/shared/EntityFormModal";
|
||||||
|
|
||||||
|
export function ListCardAction({
|
||||||
|
danger = false,
|
||||||
|
disabled = false,
|
||||||
|
confirmDescription,
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
danger?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
confirmDescription?: string;
|
||||||
|
icon: IconType;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void | Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
|
||||||
|
async function runAction() {
|
||||||
|
if (danger && confirmDescription) {
|
||||||
|
setConfirmOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onClick();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
aria-label={label}
|
||||||
|
className={`grid size-9 place-items-center rounded-md border transition ${
|
||||||
|
danger
|
||||||
|
? "border-rose-400/20 text-rose-300 hover:bg-rose-400/10"
|
||||||
|
: "border-white/10 text-slate-300 hover:bg-white/5 hover:text-white"
|
||||||
|
}`}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={runAction}
|
||||||
|
title={label}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon size={15} />
|
||||||
|
</button>
|
||||||
|
{confirmOpen ? (
|
||||||
|
<EntityFormModal
|
||||||
|
onClose={() => setConfirmOpen(false)}
|
||||||
|
open
|
||||||
|
saving={confirming}
|
||||||
|
title="Conferma eliminazione"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-slate-300">{confirmDescription}</p>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">
|
||||||
|
Questa operazione non può essere annullata.
|
||||||
|
</p>
|
||||||
|
<div className="mt-5 flex gap-2">
|
||||||
|
<button
|
||||||
|
className="rounded-md bg-rose-500 px-4 py-2 text-sm font-medium text-white hover:bg-rose-400 disabled:opacity-50"
|
||||||
|
disabled={confirming}
|
||||||
|
onClick={async () => {
|
||||||
|
setConfirming(true);
|
||||||
|
try {
|
||||||
|
await onClick();
|
||||||
|
setConfirmOpen(false);
|
||||||
|
} finally {
|
||||||
|
setConfirming(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{confirming ? "Eliminazione..." : "Elimina"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-white/10 px-4 py-2 text-sm text-slate-200 hover:bg-white/5"
|
||||||
|
disabled={confirming}
|
||||||
|
onClick={() => setConfirmOpen(false)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Annulla
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</EntityFormModal>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
src/components/shared/LocalMonthNavigator.tsx
Normal file
148
src/components/shared/LocalMonthNavigator.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
LuCalendarRange,
|
||||||
|
LuChevronLeft,
|
||||||
|
LuChevronRight,
|
||||||
|
LuRotateCcw,
|
||||||
|
} from "react-icons/lu";
|
||||||
|
import { monthKey } from "@/lib/date/formatDate";
|
||||||
|
|
||||||
|
const monthLabels = [
|
||||||
|
"Gen",
|
||||||
|
"Feb",
|
||||||
|
"Mar",
|
||||||
|
"Apr",
|
||||||
|
"Mag",
|
||||||
|
"Giu",
|
||||||
|
"Lug",
|
||||||
|
"Ago",
|
||||||
|
"Set",
|
||||||
|
"Ott",
|
||||||
|
"Nov",
|
||||||
|
"Dic",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function LocalMonthNavigator({
|
||||||
|
month,
|
||||||
|
onChange,
|
||||||
|
populatedMonths = [],
|
||||||
|
allowYearSelection = false,
|
||||||
|
disabled = false,
|
||||||
|
}: {
|
||||||
|
month: string;
|
||||||
|
onChange: (month: string) => void;
|
||||||
|
populatedMonths?: Iterable<string>;
|
||||||
|
allowYearSelection?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const [year, selectedMonthNumber = monthKey().slice(5)] = month.split("-");
|
||||||
|
const currentMonth = monthKey();
|
||||||
|
const currentYear = currentMonth.slice(0, 4);
|
||||||
|
const monthsWithContent = new Set(populatedMonths);
|
||||||
|
|
||||||
|
function changeYear(offset: number) {
|
||||||
|
const nextYear = String(Number(year) + offset);
|
||||||
|
onChange(month === year ? nextYear : `${nextYear}-${selectedMonthNumber}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`w-full rounded-xl border border-sky-400/15 bg-slate-950/25 p-2 shadow-sm shadow-sky-950/30 transition ${
|
||||||
|
disabled ? "opacity-40" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
|
{allowYearSelection ? (
|
||||||
|
<button
|
||||||
|
aria-label={`Mostra tutto l'anno ${year}`}
|
||||||
|
aria-pressed={month === year}
|
||||||
|
className={`flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm font-semibold transition ${
|
||||||
|
month === year
|
||||||
|
? "bg-sky-400/20 text-sky-100 ring-1 ring-sky-300/40"
|
||||||
|
: "text-white hover:bg-white/5"
|
||||||
|
}`}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(year)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<LuCalendarRange className="text-sky-300" size={16} />
|
||||||
|
<span>{year}</span>
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wide text-sky-300">
|
||||||
|
Anno intero
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-2 px-1 text-sm font-semibold text-white">
|
||||||
|
<LuCalendarRange className="text-sky-300" size={16} />
|
||||||
|
{year}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
aria-label="Anno precedente"
|
||||||
|
className="grid size-8 place-items-center rounded-lg text-slate-300 transition hover:bg-sky-400/10 hover:text-sky-100 disabled:cursor-default"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => changeYear(-1)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<LuChevronLeft size={17} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="Torna al mese corrente"
|
||||||
|
className="grid size-8 place-items-center rounded-lg text-sky-200 transition hover:bg-sky-400/10 disabled:cursor-default disabled:opacity-35"
|
||||||
|
disabled={disabled || month === currentMonth}
|
||||||
|
onClick={() => onChange(currentMonth)}
|
||||||
|
title="Torna al mese corrente"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<LuRotateCcw size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="Anno successivo"
|
||||||
|
className="grid size-8 place-items-center rounded-lg text-slate-300 transition hover:bg-sky-400/10 hover:text-sky-100 disabled:cursor-default"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => changeYear(1)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<LuChevronRight size={17} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-6 gap-1 sm:grid-cols-12">
|
||||||
|
{monthLabels.map((label, index) => {
|
||||||
|
const value = `${year}-${String(index + 1).padStart(2, "0")}`;
|
||||||
|
const selected = month === value;
|
||||||
|
const populated = monthsWithContent.has(value);
|
||||||
|
const current = currentMonth === value && currentYear === year;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-label={`${label} ${year}${populated ? ", contiene dati" : ""}`}
|
||||||
|
aria-pressed={selected}
|
||||||
|
className={`relative flex min-h-10 flex-col items-center justify-center rounded-lg px-1 py-1 text-xs font-medium transition ${
|
||||||
|
selected
|
||||||
|
? "bg-sky-400/20 text-sky-100 ring-1 ring-sky-300/40"
|
||||||
|
: "text-slate-400 hover:bg-white/5 hover:text-slate-200"
|
||||||
|
}`}
|
||||||
|
disabled={disabled}
|
||||||
|
key={value}
|
||||||
|
onClick={() => onChange(value)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
<span
|
||||||
|
className={`mt-1 size-1.5 rounded-full ${
|
||||||
|
populated
|
||||||
|
? "bg-sky-300 shadow-[0_0_7px_rgba(125,211,252,0.75)]"
|
||||||
|
: current
|
||||||
|
? "bg-slate-500"
|
||||||
|
: "bg-transparent"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/components/shared/MonthlyPageToolbar.tsx
Normal file
29
src/components/shared/MonthlyPageToolbar.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { LocalMonthNavigator } from "@/components/shared/LocalMonthNavigator";
|
||||||
|
|
||||||
|
export function MonthlyPageToolbar({
|
||||||
|
month,
|
||||||
|
onChange,
|
||||||
|
populatedMonths,
|
||||||
|
allowYearSelection = false,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
month: string;
|
||||||
|
onChange: (month: string) => void;
|
||||||
|
populatedMonths?: Iterable<string>;
|
||||||
|
allowYearSelection?: boolean;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-nowrap items-center justify-start gap-2">
|
||||||
|
<LocalMonthNavigator
|
||||||
|
allowYearSelection={allowYearSelection}
|
||||||
|
month={month}
|
||||||
|
onChange={onChange}
|
||||||
|
populatedMonths={populatedMonths}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/components/shared/PageShell.tsx
Normal file
26
src/components/shared/PageShell.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export function PageShell({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<h1 className="text-2xl font-semibold text-white">{title}</h1>
|
||||||
|
{description ? (
|
||||||
|
<p className="mt-1 text-sm text-slate-400">{description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/components/shared/PaymentBadge.tsx
Normal file
20
src/components/shared/PaymentBadge.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { getPaymentVisualStyle } from "@/lib/finance/paymentVisualStyle";
|
||||||
|
import type { CalendarEventType, PaymentSourceType } from "@/types/finance";
|
||||||
|
|
||||||
|
export function PaymentBadge({
|
||||||
|
sourceType,
|
||||||
|
status,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
sourceType: PaymentSourceType | CalendarEventType | "income";
|
||||||
|
status?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex rounded-full border px-2 py-1 text-xs ${getPaymentVisualStyle(sourceType, status).badge}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/components/shared/SectionAddButton.tsx
Normal file
19
src/components/shared/SectionAddButton.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export function SectionAddButton({
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-label={label}
|
||||||
|
className="grid size-10 shrink-0 place-items-center rounded-md bg-sky-500 text-lg font-medium text-white hover:bg-sky-400 sm:flex sm:w-auto sm:px-4 sm:text-sm"
|
||||||
|
onClick={onClick}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span>+</span>
|
||||||
|
<span className="hidden sm:inline"> Aggiungi</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/components/shared/SectionPeriodTabs.tsx
Normal file
57
src/components/shared/SectionPeriodTabs.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { LuCalendarDays, LuCalendarRange } from "react-icons/lu";
|
||||||
|
|
||||||
|
export type SectionPeriodView = "month" | "year";
|
||||||
|
|
||||||
|
export function SectionPeriodTabs({
|
||||||
|
view,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
view: SectionPeriodView;
|
||||||
|
onChange: (view: SectionPeriodView) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 rounded-xl border border-white/10 bg-slate-950/60 p-1">
|
||||||
|
<Tab
|
||||||
|
active={view === "month"}
|
||||||
|
icon={<LuCalendarDays size={16} />}
|
||||||
|
label="Mese"
|
||||||
|
onClick={() => onChange("month")}
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
active={view === "year"}
|
||||||
|
icon={<LuCalendarRange size={16} />}
|
||||||
|
label="Anno"
|
||||||
|
onClick={() => onChange("year")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tab({
|
||||||
|
active,
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition ${
|
||||||
|
active
|
||||||
|
? "bg-sky-400/15 text-sky-100"
|
||||||
|
: "text-slate-400 hover:bg-white/5 hover:text-slate-200"
|
||||||
|
}`}
|
||||||
|
onClick={onClick}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user