From c4a692b5d2f2ce6e45fbf1f86a2d25b428455e4c Mon Sep 17 00:00:00 2001 From: Kacper Date: Fri, 10 Jul 2026 22:57:16 +0200 Subject: [PATCH] v1.0.0 --- .env.example | 23 + .gitignore | 4 + README.md | 149 +++++ backend/.dockerignore | 3 + backend/Dockerfile | 18 + backend/package.json | 20 + backend/src/db/db.js | 25 + backend/src/db/schema.sql | 82 +++ backend/src/index.js | 31 + backend/src/middleware/auth.js | 36 ++ backend/src/routes/auth.js | 206 ++++++ backend/src/routes/categories.js | 66 ++ backend/src/routes/expenses.js | 214 +++++++ backend/src/routes/households.js | 154 +++++ backend/src/routes/settlements.js | 38 ++ backend/src/routes/stats.js | 92 +++ backend/src/utils/balance.js | 80 +++ backend/src/utils/households.js | 51 ++ backend/src/utils/mailer.js | 26 + docker-compose.yaml | 43 ++ frontend/.dockerignore | 3 + frontend/Dockerfile | 11 + frontend/index.html | 28 + frontend/nginx.conf | 31 + frontend/package.json | 24 + frontend/public/icons/icon-192.png | Bin 0 -> 8818 bytes frontend/public/icons/icon-512.png | Bin 0 -> 10324 bytes frontend/src/App.jsx | 68 ++ frontend/src/api/client.js | 73 +++ frontend/src/api/queries.js | 229 +++++++ frontend/src/auth/AuthContext.jsx | 74 +++ frontend/src/components/BottomNav.jsx | 34 + frontend/src/components/CategoryPieChart.jsx | 25 + .../src/components/ConfirmDialogProvider.jsx | 57 ++ frontend/src/components/ErrorBoundary.jsx | 28 + frontend/src/components/ExpenseEditModal.jsx | 142 +++++ frontend/src/components/ExpenseListItem.jsx | 26 + frontend/src/components/Icon.jsx | 7 + frontend/src/components/InstallBanner.jsx | 41 ++ frontend/src/components/MonthlyBarChart.jsx | 19 + frontend/src/components/OfflineBanner.jsx | 14 + frontend/src/components/PasswordField.jsx | 26 + .../src/components/PayerComparisonChart.jsx | 30 + frontend/src/components/PayerToggle.jsx | 18 + frontend/src/components/SplitSelector.jsx | 69 +++ frontend/src/components/Switch.jsx | 8 + frontend/src/household/HouseholdContext.jsx | 68 ++ frontend/src/main.jsx | 37 ++ frontend/src/offline/db.js | 31 + frontend/src/offline/syncQueue.js | 24 + frontend/src/offline/useOnlineSync.js | 43 ++ frontend/src/pages/AddExpense.jsx | 154 +++++ frontend/src/pages/Dashboard.jsx | 76 +++ frontend/src/pages/ForgotPassword.jsx | 50 ++ frontend/src/pages/History.jsx | 92 +++ frontend/src/pages/JoinInvite.jsx | 48 ++ frontend/src/pages/Login.jsx | 65 ++ frontend/src/pages/Onboarding.jsx | 90 +++ frontend/src/pages/Register.jsx | 68 ++ frontend/src/pages/ResetPassword.jsx | 60 ++ frontend/src/pages/Settings.jsx | 541 ++++++++++++++++ frontend/src/pages/Stats.jsx | 50 ++ frontend/src/pwa/useInstallPrompt.js | 47 ++ frontend/src/styles.css | 585 ++++++++++++++++++ frontend/src/theme/ThemeContext.jsx | 34 + frontend/vite.config.js | 57 ++ 66 files changed, 4666 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 backend/package.json create mode 100644 backend/src/db/db.js create mode 100644 backend/src/db/schema.sql create mode 100644 backend/src/index.js create mode 100644 backend/src/middleware/auth.js create mode 100644 backend/src/routes/auth.js create mode 100644 backend/src/routes/categories.js create mode 100644 backend/src/routes/expenses.js create mode 100644 backend/src/routes/households.js create mode 100644 backend/src/routes/settlements.js create mode 100644 backend/src/routes/stats.js create mode 100644 backend/src/utils/balance.js create mode 100644 backend/src/utils/households.js create mode 100644 backend/src/utils/mailer.js create mode 100644 docker-compose.yaml create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/public/icons/icon-192.png create mode 100644 frontend/public/icons/icon-512.png create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/api/client.js create mode 100644 frontend/src/api/queries.js create mode 100644 frontend/src/auth/AuthContext.jsx create mode 100644 frontend/src/components/BottomNav.jsx create mode 100644 frontend/src/components/CategoryPieChart.jsx create mode 100644 frontend/src/components/ConfirmDialogProvider.jsx create mode 100644 frontend/src/components/ErrorBoundary.jsx create mode 100644 frontend/src/components/ExpenseEditModal.jsx create mode 100644 frontend/src/components/ExpenseListItem.jsx create mode 100644 frontend/src/components/Icon.jsx create mode 100644 frontend/src/components/InstallBanner.jsx create mode 100644 frontend/src/components/MonthlyBarChart.jsx create mode 100644 frontend/src/components/OfflineBanner.jsx create mode 100644 frontend/src/components/PasswordField.jsx create mode 100644 frontend/src/components/PayerComparisonChart.jsx create mode 100644 frontend/src/components/PayerToggle.jsx create mode 100644 frontend/src/components/SplitSelector.jsx create mode 100644 frontend/src/components/Switch.jsx create mode 100644 frontend/src/household/HouseholdContext.jsx create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/offline/db.js create mode 100644 frontend/src/offline/syncQueue.js create mode 100644 frontend/src/offline/useOnlineSync.js create mode 100644 frontend/src/pages/AddExpense.jsx create mode 100644 frontend/src/pages/Dashboard.jsx create mode 100644 frontend/src/pages/ForgotPassword.jsx create mode 100644 frontend/src/pages/History.jsx create mode 100644 frontend/src/pages/JoinInvite.jsx create mode 100644 frontend/src/pages/Login.jsx create mode 100644 frontend/src/pages/Onboarding.jsx create mode 100644 frontend/src/pages/Register.jsx create mode 100644 frontend/src/pages/ResetPassword.jsx create mode 100644 frontend/src/pages/Settings.jsx create mode 100644 frontend/src/pages/Stats.jsx create mode 100644 frontend/src/pwa/useInstallPrompt.js create mode 100644 frontend/src/styles.css create mode 100644 frontend/src/theme/ThemeContext.jsx create mode 100644 frontend/vite.config.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..87a2a64 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Skopiuj ten plik do .env i uzupełnij wartości: cp .env.example .env + +# --- Bezpieczeństwo --- +# Wygeneruj losowy sekret np. poleceniem: openssl rand -hex 32 +JWT_SECRET=change-me-to-a-random-secret + +# --- Adres aplikacji --- +FRONTEND_URL=https://twoja-domena.pl + +# --- Domena (routing Traefik) --- +DOMAIN=twoja-domena.pl + +# --- Sieć zewnętrzna Traefik --- +TRAEFIK_NETWORK=traefik_public + +# --- SMTP (powiadomienia mailowe, reset hasła) --- +# Pozostaw SMTP_HOST puste, aby wyłączyć wysyłkę e-maili. +SMTP_HOST= +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER= +SMTP_PASS= +SMTP_FROM= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ae62c9c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +sqlite/ +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..0c08be6 --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ +# KtoCo — wspólne wydatki dla grup, par i współlokatorów + +Aplikacja webowa (PWA) do dzielenia się wydatkami w gospodarstwie domowym — kto ile wydał, kto komu jest winien, z historią, statystykami i rozliczeniami. Obsługuje dowolną liczbę osób w gospodarstwie i dowolną liczbę gospodarstw na użytkownika. Mobile-first, instalowalna na telefonie, działa częściowo offline. + +## Funkcje + +- **Dashboard** — kafelki „kto komu ile jest winien" (automatycznie uproszczone do minimalnej liczby przelewów dla całej grupy) z przyciskiem „Rozlicz się", wykres kołowy wydatków wg kategorii, podsumowanie miesiąca. +- **Dodawanie wydatku** — duże pole kwoty, kategorie z ikonami, wybór płacącego, trzy tryby podziału: po równo (między wszystkich członków), dokładny podział, całość na jedną osobę. +- **Historia** — lista wydatków z filtrami (miesiąc/kategoria/płacący), edycja i usuwanie. +- **Statystyki** — wydatki miesiąc do miesiąca, porównanie konsumpcji członków gospodarstwa, ranking kategorii. +- **Wiele gospodarstw** — użytkownik może należeć do wielu gospodarstw jednocześnie i przełączać się między nimi (Ustawienia → „Twoje gospodarstwa"); każde gospodarstwo może mieć dowolną liczbę członków, dołączanych tym samym kodem zaproszenia. +- **Ustawienia** — przełącznik i zarządzanie gospodarstwami (zmiana nazwy/waluty, usuwanie członka, usunięcie całego gospodarstwa, zaproszenia), własne kategorie (z edycją nazwy/ikony), motyw jasny/ciemny/systemowy, powiadomienia mailowe, eksport CSV. +- **Konto** — rejestracja e-mail/hasło, logowanie, zmiana nazwy, zmiana hasła, przypomnienie/reset hasła mailem, usunięcie konta. +- **PWA / offline** — instalowalna na ekranie głównym telefonu, cache widoków, wydatki dodane offline trafiają do kolejki i synchronizują się automatycznie po powrocie sieci. + +## Stack technologiczny + +| Warstwa | Technologia | +|---|---| +| Frontend | React (Vite), react-router-dom, TanStack Query, Recharts, vite-plugin-pwa, Material Symbols (Google Fonts) | +| Backend | Node.js + Express, better-sqlite3, JWT (jsonwebtoken), bcryptjs, nodemailer | +| Baza danych | SQLite (plik na bind mouncie `./sqlite`) | +| Infrastruktura | Docker Compose, nginx (serwuje frontend + proxy `/api`), opcjonalnie Traefik (TLS + routing domenowy) | + +## Struktura projektu + +``` +ktoco/ +├── docker-compose.yaml # jedyny plik potrzebny do uruchomienia całości +├── .env # konfiguracja (sekrety, domena, SMTP) — NIE commitować +├── .env.example # szablon konfiguracji do skopiowania +├── sqlite/ # bind mount — tu leży plik app.db (trwałość danych) +├── backend/ +│ ├── Dockerfile +│ └── src/ +│ ├── index.js # Express app, montowanie routerów +│ ├── db/ # schema.sql + połączenie better-sqlite3 +│ ├── middleware/auth.js # weryfikacja JWT +│ ├── routes/ # auth, households, categories, expenses, settlements, stats +│ └── utils/ # obliczanie salda, mailer (nodemailer), pomocnicze household +└── frontend/ + ├── Dockerfile # multi-stage: build (node) -> serve (nginx) + ├── nginx.conf # proxy /api -> backend:3000 + ├── vite.config.js # konfiguracja PWA (manifest, service worker) + └── src/ + ├── pages/ # Dashboard, AddExpense, History, Stats, Settings, Login, Register, ... + ├── components/ # BottomNav, wykresy, formularze, Icon, Switch, ... + ├── api/ # klient fetch + hooki React Query + ├── auth/ # kontekst autoryzacji (JWT w localStorage) + ├── household/ # kontekst aktywnego gospodarstwa (lista + przełączanie) + ├── theme/ # kontekst motywu jasny/ciemny/systemowy + └── offline/ # kolejka IndexedDB + synchronizacja po powrocie sieci +``` + +## Uruchomienie + +Wymagany jest tylko Docker (z pluginem Compose). + +```bash +cp .env.example .env +# uzupełnij .env (patrz sekcja niżej) — nie trzeba edytować docker-compose.yaml +sudo docker compose up -d --build +``` + +Aplikacja będzie dostępna pod `http://localhost:8856` (oraz pod domeną z Traefika, jeśli skonfigurowana — patrz niżej). + +## Konfiguracja (`.env`) + +Cała konfiguracja wdrożeniowa znajduje się w `.env` — `docker-compose.yaml` nie wymaga edycji. + +| Zmienna | Opis | Domyślnie | +|---|---|---| +| `JWT_SECRET` | Sekret do podpisywania tokenów logowania. Wygeneruj: `openssl rand -hex 32` | — (wymagany) | +| `FRONTEND_URL` | Publiczny adres aplikacji, używany w linkach w mailach (np. reset hasła) | `https://ktoco.kzbikowski.pl` | +| `DOMAIN` | Domena, pod którą Traefik wystawia aplikację | `ktoco.kzbikowski.pl` | +| `TRAEFIK_NETWORK` | Nazwa istniejącej zewnętrznej sieci Docker, do której podłączony jest Traefik | `traefik_public` | +| `SMTP_HOST` | Adres serwera SMTP. Puste = wysyłka maili wyłączona (tylko log w konsoli) | — (opcjonalne) | +| `SMTP_PORT` | Port SMTP | `587` | +| `SMTP_SECURE` | `true` dla połączenia SSL/TLS od razu (port 465), inaczej `false` (STARTTLS) | `false` | +| `SMTP_USER` / `SMTP_PASS` | Dane logowania do SMTP | — | +| `SMTP_FROM` | Adres nadawcy w wysyłanych mailach | `SMTP_USER` | + +Bez skonfigurowanego SMTP aplikacja działa normalnie — funkcje „reset hasła" i „powiadomienia mailowe" po prostu nie wysyłają realnych maili (backend loguje w konsoli, że wysyłkę pominięto). + +## Dane / trwałość + +Baza SQLite leży w `./sqlite/app.db` na hoście (bind mount, nie nazwany wolumen Dockera) — łatwo ją skopiować, zbackupować albo podejrzeć narzędziem `sqlite3` bez wchodzenia do kontenera. + +## Wdrożenie za Traefikiem + +Serwis `frontend` jest podłączony do zewnętrznej sieci `traefik_public` (nazwa konfigurowalna przez `TRAEFIK_NETWORK`) i ma etykiety Traefika (routing po domenie z `.env`, TLS przez `tls-resolver`). Warunek: sieć `traefik_public` musi już istnieć na hoście (tworzy ją zwykle stack samego Traefika): + +```bash +docker network create traefik_public # tylko jeśli jeszcze nie istnieje +``` + +Port `8856` frontend jest dodatkowo opublikowany bezpośrednio na hosta — przydatne przy testach lokalnych równolegle z dostępem przez Traefik. + +## Model danych (SQLite) + +- `users` — konta (e-mail, hash hasła, preferencja powiadomień mailowych) +- `households` — gospodarstwa domowe (nazwa, waluta) +- `household_members` — członkowie gospodarstwa (dowolna liczba osób; użytkownik może być w wielu gospodarstwach naraz) +- `invites` — kody zaproszeń do gospodarstwa (ważne 7 dni, wielokrotnego użytku — nie wygasają po jednym dołączeniu) +- `password_resets` — jednorazowe tokeny resetu hasła (ważne 1h) +- `categories` — kategorie wydatków (nazwa, ikona Material Symbols, kolor) +- `expenses` — wydatki (kwota, płacący, kategoria, data, typ podziału) +- `expense_shares` — finalny podział wydatku między członków gospodarstwa (niezależnie od typu podziału zawsze sumuje się do kwoty wydatku) +- `settlements` — historia rozliczeń („Rozlicz się") + +Saldo per osoba liczone jest jako: `(suma zapłacona przez osobę) − (suma jej udziałów w wydatkach) − (netto rozliczeń)`. Do prezentacji „kto komu ile jest winien" salda są upraszczane zachłannym algorytmem (`backend/src/utils/balance.js: simplifyDebts`), który dla N osób generuje minimalną liczbę przelewów rozliczających wszystkich (zamiast osobnego długu między każdą parą). + +## Wiele gospodarstw — jak to działa + +Użytkownik może należeć do wielu gospodarstw. Ponieważ każdy request do zasobów powiązanych z gospodarstwem (wydatki, kategorie, saldo, statystyki, rozliczenia) musi wiedzieć, którego gospodarstwa dotyczy, frontend wysyła nagłówek `X-Household-Id: ` przy każdym takim żądaniu (ustawiany automatycznie przez `frontend/src/household/HouseholdContext.jsx` po przełączeniu gospodarstwa w Ustawieniach). Backend weryfikuje w `requireHousehold` middleware, że zalogowany użytkownik faktycznie jest członkiem podanego gospodarstwa. + +Usunięcie ostatniego członka z gospodarstwa automatycznie kasuje samo gospodarstwo (wraz z całą historią wydatków — kasowanie kaskadowe przez klucze obce). Usunięcie konta użytkownika, który ma współdzieloną historię finansową z innymi (wydatki/udziały/rozliczenia), nie usuwa go fizycznie z bazy (zepsułoby to historię widoczną dla reszty gospodarstwa) — konto jest wtedy anonimizowane (nazwa → „Usunięte konto", e-mail zastąpiony unikalnym nieistniejącym adresem, hasło unieważnione). Świeże konto bez żadnej historii jest usuwane w całości. + +## API (skrót) + +Wszystkie endpointy poza `/auth/register`, `/auth/login`, `/auth/forgot-password`, `/auth/reset-password` i `/health` wymagają nagłówka `Authorization: Bearer `. Endpointy gospodarstwa/kategorii/wydatków/rozliczeń/statystyk dodatkowo wymagają `X-Household-Id: `. + +| Grupa | Endpointy | +|---|---| +| Auth | `POST /auth/register`, `/login`, `/change-password`, `/forgot-password`, `/reset-password`, `GET /auth/me`, `PUT /auth/me`, `PUT /auth/me/notifications`, `DELETE /auth/me` | +| Gospodarstwa | `GET /households` (lista Twoich), `GET/PUT/DELETE /households/:id`, `POST /households`, `POST /households/:id/invite`, `POST /households/join`, `DELETE /households/:id/members/:userId` | +| Kategorie | `GET/POST/PUT/DELETE /categories[/:id]` | +| Wydatki | `GET/POST/PUT/DELETE /expenses[/:id]` (filtry: `month`, `categoryId`, `payerId`) | +| Rozliczenia | `GET/POST /settlements` (POST rozlicza od razu wszystkie uproszczone przelewy) | +| Statystyki | `GET /stats/balance`, `/summary`, `/monthly`, `/export.csv` | + +## Tryb offline (PWA) + +Service worker (Workbox, przez `vite-plugin-pwa`) cache'uje powłokę aplikacji i ostatnio pobrane dane GET z API (strategia `NetworkFirst`), więc appka otwiera się i pokazuje dane nawet bez sieci. Nowy wydatek dodany offline trafia do kolejki w IndexedDB (`frontend/src/offline/`) i zostaje automatycznie wysłany po wykryciu powrotu połączenia (`online` event) — widoczny jest wtedy baner z liczbą oczekujących wpisów. + +## Znane ograniczenia + +- Kopiowanie kodu zaproszenia przez `navigator.clipboard` wymaga bezpiecznego kontekstu (HTTPS lub `localhost`) — na zwykłym HTTP w sieci lokalnej przeglądarka może to zablokować; dlatego kod jest zawsze dostępny też jako zaznaczalne pole tekstowe (ręczne kopiowanie zawsze działa). +- `schema.sql` używa `CREATE TABLE IF NOT EXISTS` — dodanie nowej kolumny do istniejącej tabeli w już działającej bazie wymaga ręcznej migracji (`ALTER TABLE`); przy starcie na czystej bazie schemat tworzy się poprawnie od razu. +- Kod zaproszenia do gospodarstwa nie wygasa po pierwszym użyciu (celowo — pozwala zaprosić dowolną liczbę osób tym samym kodem), tylko po czasie (7 dni) lub ręcznym wygenerowaniu nowego w Ustawieniach. + +## Rozwój lokalny (bez Dockera) + +Wymaga Node.js 20+. + +```bash +cd backend && npm install && JWT_SECRET=dev DATABASE_PATH=./data/app.db npm start +cd frontend && npm install && npm run dev # serwer dev na :5173, proxy /api -> :3000 +``` diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..a3d7f70 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,3 @@ +node_modules +data +.git diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..637c693 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,18 @@ +FROM node:20-bookworm-slim + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 make g++ \ + && rm -rf /var/lib/apt/lists/* + +COPY package.json ./ +RUN npm install --omit=dev + +COPY src ./src + +ENV NODE_ENV=production +ENV DATABASE_PATH=/app/data/app.db +EXPOSE 3000 + +CMD ["node", "src/index.js"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..cdf12e7 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,20 @@ +{ + "name": "ktoco-backend", + "version": "1.0.0", + "private": true, + "type": "commonjs", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js" + }, + "dependencies": { + "better-sqlite3": "^11.3.0", + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "jsonwebtoken": "^9.0.2", + "nodemailer": "^6.9.14", + "uuid": "^9.0.1" + } +} diff --git a/backend/src/db/db.js b/backend/src/db/db.js new file mode 100644 index 0000000..59a706c --- /dev/null +++ b/backend/src/db/db.js @@ -0,0 +1,25 @@ +const fs = require('fs'); +const path = require('path'); +const Database = require('better-sqlite3'); + +const DATABASE_PATH = process.env.DATABASE_PATH || path.join(__dirname, '../../data/app.db'); +fs.mkdirSync(path.dirname(DATABASE_PATH), { recursive: true }); + +const db = new Database(DATABASE_PATH); +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8'); +db.exec(schema); + +const DEFAULT_CATEGORIES = [ + { name: 'Jedzenie', icon: 'shopping_cart', color: '#22c55e' }, + { name: 'Mieszkanie', icon: 'home', color: '#3b82f6' }, + { name: 'Rachunki', icon: 'bolt', color: '#f59e0b' }, + { name: 'Transport', icon: 'directions_car', color: '#8b5cf6' }, + { name: 'Restauracje', icon: 'restaurant', color: '#ef4444' }, + { name: 'Rozrywka', icon: 'celebration', color: '#ec4899' }, + { name: 'Inne', icon: 'inventory_2', color: '#6b7280' }, +]; + +module.exports = { db, DEFAULT_CATEGORIES }; diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql new file mode 100644 index 0000000..69e96aa --- /dev/null +++ b/backend/src/db/schema.sql @@ -0,0 +1,82 @@ +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + name TEXT NOT NULL, + avatar_emoji TEXT NOT NULL DEFAULT '🙂', + email_notifications INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS password_resets ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT UNIQUE NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS households ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + currency TEXT NOT NULL DEFAULT 'PLN', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS household_members ( + household_id TEXT NOT NULL REFERENCES households(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + joined_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (household_id, user_id) +); + +CREATE TABLE IF NOT EXISTS invites ( + id TEXT PRIMARY KEY, + household_id TEXT NOT NULL REFERENCES households(id) ON DELETE CASCADE, + code TEXT UNIQUE NOT NULL, + created_by TEXT NOT NULL REFERENCES users(id), + expires_at TEXT NOT NULL, + used_at TEXT +); + +CREATE TABLE IF NOT EXISTS categories ( + id TEXT PRIMARY KEY, + household_id TEXT NOT NULL REFERENCES households(id) ON DELETE CASCADE, + name TEXT NOT NULL, + icon TEXT NOT NULL DEFAULT '🛒', + color TEXT NOT NULL DEFAULT '#6b7280' +); + +CREATE TABLE IF NOT EXISTS expenses ( + id TEXT PRIMARY KEY, + household_id TEXT NOT NULL REFERENCES households(id) ON DELETE CASCADE, + payer_id TEXT NOT NULL REFERENCES users(id), + amount REAL NOT NULL, + title TEXT NOT NULL, + category_id TEXT REFERENCES categories(id) ON DELETE SET NULL, + expense_date TEXT NOT NULL, + split_type TEXT NOT NULL CHECK (split_type IN ('equal', 'exact', 'full')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS expense_shares ( + expense_id TEXT NOT NULL REFERENCES expenses(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id), + share_amount REAL NOT NULL, + PRIMARY KEY (expense_id, user_id) +); + +CREATE TABLE IF NOT EXISTS settlements ( + id TEXT PRIMARY KEY, + household_id TEXT NOT NULL REFERENCES households(id) ON DELETE CASCADE, + from_user_id TEXT NOT NULL REFERENCES users(id), + to_user_id TEXT NOT NULL REFERENCES users(id), + amount REAL NOT NULL, + settled_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_expenses_household ON expenses(household_id); +CREATE INDEX IF NOT EXISTS idx_expense_shares_expense ON expense_shares(expense_id); +CREATE INDEX IF NOT EXISTS idx_settlements_household ON settlements(household_id); diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..9738af3 --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,31 @@ +require('dotenv').config(); +const express = require('express'); +const cors = require('cors'); + +const { router: authRouter } = require('./routes/auth'); +const { router: householdsRouter } = require('./routes/households'); +const { router: categoriesRouter } = require('./routes/categories'); +const { router: expensesRouter } = require('./routes/expenses'); +const { router: settlementsRouter } = require('./routes/settlements'); +const { router: statsRouter } = require('./routes/stats'); + +const app = express(); +app.use(cors()); +app.use(express.json()); + +app.get('/health', (req, res) => res.json({ status: 'ok' })); + +app.use('/auth', authRouter); +app.use('/households', householdsRouter); +app.use('/categories', categoriesRouter); +app.use('/expenses', expensesRouter); +app.use('/settlements', settlementsRouter); +app.use('/stats', statsRouter); + +app.use((err, req, res, next) => { + console.error(err); + res.status(500).json({ error: 'Wewnętrzny błąd serwera' }); +}); + +const PORT = process.env.PORT || 3000; +app.listen(PORT, () => console.log(`Backend listening on port ${PORT}`)); diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000..08b48e5 --- /dev/null +++ b/backend/src/middleware/auth.js @@ -0,0 +1,36 @@ +const jwt = require('jsonwebtoken'); +const { db } = require('../db/db'); + +const JWT_SECRET = process.env.JWT_SECRET; +if (!JWT_SECRET) { + throw new Error('JWT_SECRET env var is required'); +} + +function signToken(user) { + return jwt.sign({ sub: user.id, email: user.email }, JWT_SECRET, { expiresIn: '30d' }); +} + +function requireAuth(req, res, next) { + const header = req.headers.authorization || ''; + const [scheme, token] = header.split(' '); + if (scheme !== 'Bearer' || !token) { + return res.status(401).json({ error: 'Missing bearer token' }); + } + let payload; + try { + payload = jwt.verify(token, JWT_SECRET); + } catch (err) { + return res.status(401).json({ error: 'Invalid or expired token' }); + } + // The JWT signature alone doesn't prove the account still exists (deleted + // account, or — in dev — a wiped database): reject it the same way so the + // client logs out instead of misreading "no accounts" as "no household". + const user = db.prepare('SELECT id FROM users WHERE id = ?').get(payload.sub); + if (!user) { + return res.status(401).json({ error: 'Invalid or expired token' }); + } + req.userId = payload.sub; + next(); +} + +module.exports = { signToken, requireAuth, JWT_SECRET }; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js new file mode 100644 index 0000000..9a5ee87 --- /dev/null +++ b/backend/src/routes/auth.js @@ -0,0 +1,206 @@ +const express = require('express'); +const crypto = require('crypto'); +const bcrypt = require('bcryptjs'); +const { v4: uuid } = require('uuid'); +const { db } = require('../db/db'); +const { signToken, requireAuth } = require('../middleware/auth'); +const { sendMail } = require('../utils/mailer'); + +const router = express.Router(); + +const RESET_TOKEN_TTL_MS = 60 * 60 * 1000; +const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:8856'; + +function toPublicUser(user) { + return { + id: user.id, + email: user.email, + name: user.name, + avatarEmoji: user.avatar_emoji, + emailNotifications: !!user.email_notifications, + }; +} + +router.post('/register', (req, res) => { + const { email, password, name } = req.body || {}; + if (!email || !password || !name) { + return res.status(400).json({ error: 'email, password i name są wymagane' }); + } + if (String(password).length < 6) { + return res.status(400).json({ error: 'Hasło musi mieć co najmniej 6 znaków' }); + } + + const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email.toLowerCase()); + if (existing) { + return res.status(409).json({ error: 'Konto z tym adresem e-mail już istnieje' }); + } + + const user = { + id: uuid(), + email: email.toLowerCase(), + password_hash: bcrypt.hashSync(password, 10), + name, + }; + db.prepare('INSERT INTO users (id, email, password_hash, name) VALUES (?, ?, ?, ?)').run( + user.id, + user.email, + user.password_hash, + user.name + ); + + const created = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id); + const token = signToken(created); + res.status(201).json({ token, user: toPublicUser(created) }); +}); + +router.post('/login', (req, res) => { + const { email, password } = req.body || {}; + if (!email || !password) { + return res.status(400).json({ error: 'email i password są wymagane' }); + } + + const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase()); + if (!user || !bcrypt.compareSync(password, user.password_hash)) { + return res.status(401).json({ error: 'Nieprawidłowy e-mail lub hasło' }); + } + + const token = signToken(user); + res.json({ token, user: toPublicUser(user) }); +}); + +router.get('/me', requireAuth, (req, res) => { + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.userId); + if (!user) return res.status(404).json({ error: 'Użytkownik nie znaleziony' }); + res.json({ user: toPublicUser(user) }); +}); + +router.put('/me', requireAuth, (req, res) => { + const { name } = req.body || {}; + if (!name || !name.trim()) { + return res.status(400).json({ error: 'name jest wymagane' }); + } + db.prepare('UPDATE users SET name = ? WHERE id = ?').run(name.trim(), req.userId); + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.userId); + res.json({ user: toPublicUser(user) }); +}); + +router.delete('/me', requireAuth, (req, res) => { + const userId = req.userId; + const householdIds = db + .prepare('SELECT household_id FROM household_members WHERE user_id = ?') + .all(userId) + .map((r) => r.household_id); + + try { + db.prepare('DELETE FROM users WHERE id = ?').run(userId); + return res.json({ ok: true, anonymized: false }); + } catch (err) { + // Foreign key constraint: user has expense/settlement history shared with others. + // Anonymize instead of a hard delete so their household's financial history stays intact. + } + + const anonymize = db.transaction(() => { + db.prepare('DELETE FROM household_members WHERE user_id = ?').run(userId); + for (const householdId of householdIds) { + const remaining = db + .prepare('SELECT COUNT(*) AS c FROM household_members WHERE household_id = ?') + .get(householdId).c; + if (remaining === 0) { + db.prepare('DELETE FROM households WHERE id = ?').run(householdId); + } + } + db.prepare( + `UPDATE users SET name = 'Usunięte konto', email = ?, password_hash = '', email_notifications = 0 WHERE id = ?` + ).run(`deleted-${userId}@ktoco.invalid`, userId); + }); + anonymize(); + + res.json({ ok: true, anonymized: true }); +}); + +router.put('/me/notifications', requireAuth, (req, res) => { + const { enabled } = req.body || {}; + db.prepare('UPDATE users SET email_notifications = ? WHERE id = ?').run(enabled ? 1 : 0, req.userId); + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.userId); + res.json({ user: toPublicUser(user) }); +}); + +router.post('/change-password', requireAuth, (req, res) => { + const { currentPassword, newPassword } = req.body || {}; + if (!currentPassword || !newPassword) { + return res.status(400).json({ error: 'currentPassword i newPassword są wymagane' }); + } + if (String(newPassword).length < 6) { + return res.status(400).json({ error: 'Nowe hasło musi mieć co najmniej 6 znaków' }); + } + + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.userId); + if (!bcrypt.compareSync(currentPassword, user.password_hash)) { + // 400, not 401: the JWT is valid (requireAuth already passed) — this is a form + // validation failure, not an auth failure, and must not trigger a global session logout. + return res.status(400).json({ error: 'Bieżące hasło jest nieprawidłowe' }); + } + + const passwordHash = bcrypt.hashSync(newPassword, 10); + db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(passwordHash, user.id); + res.json({ ok: true }); +}); + +router.post('/forgot-password', async (req, res) => { + const { email } = req.body || {}; + const genericResponse = { message: 'Jeśli konto istnieje, wysłaliśmy e-mail z linkiem do resetu hasła' }; + if (!email) { + return res.status(400).json({ error: 'email jest wymagany' }); + } + + const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase()); + if (!user) { + return res.json(genericResponse); + } + + const token = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MS).toISOString(); + db.prepare('INSERT INTO password_resets (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)').run( + uuid(), + user.id, + token, + expiresAt + ); + + const resetLink = `${FRONTEND_URL}/reset-password?token=${token}`; + await sendMail({ + to: user.email, + subject: 'KtoCo — reset hasła', + text: `Cześć ${user.name},\n\nAby zresetować hasło, kliknij poniższy link (ważny 1 godzinę):\n${resetLink}\n\nJeśli to nie Ty, zignoruj tę wiadomość.`, + }); + + res.json(genericResponse); +}); + +router.post('/reset-password', (req, res) => { + const { token, password } = req.body || {}; + if (!token || !password) { + return res.status(400).json({ error: 'token i password są wymagane' }); + } + if (String(password).length < 6) { + return res.status(400).json({ error: 'Hasło musi mieć co najmniej 6 znaków' }); + } + + const reset = db + .prepare(`SELECT * FROM password_resets WHERE token = ? AND used_at IS NULL AND expires_at > datetime('now')`) + .get(token); + if (!reset) { + return res.status(400).json({ error: 'Link do resetu hasła jest nieprawidłowy lub wygasł' }); + } + + const passwordHash = bcrypt.hashSync(password, 10); + const apply = db.transaction(() => { + db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(passwordHash, reset.user_id); + db.prepare(`UPDATE password_resets SET used_at = datetime('now') WHERE id = ?`).run(reset.id); + }); + apply(); + + res.json({ ok: true }); +}); + +module.exports = { router, toPublicUser }; diff --git a/backend/src/routes/categories.js b/backend/src/routes/categories.js new file mode 100644 index 0000000..a15e515 --- /dev/null +++ b/backend/src/routes/categories.js @@ -0,0 +1,66 @@ +const express = require('express'); +const { v4: uuid } = require('uuid'); +const { db } = require('../db/db'); +const { requireAuth } = require('../middleware/auth'); +const { requireHousehold } = require('../utils/households'); + +const router = express.Router(); +router.use(requireAuth, requireHousehold); + +router.get('/', (req, res) => { + const categories = db + .prepare('SELECT * FROM categories WHERE household_id = ? ORDER BY name') + .all(req.household.id); + res.json({ categories }); +}); + +router.post('/', (req, res) => { + const { name, icon, color } = req.body || {}; + if (!name) { + return res.status(400).json({ error: 'Nazwa kategorii jest wymagana' }); + } + const category = { + id: uuid(), + household_id: req.household.id, + name, + icon: icon || '📦', + color: color || '#6b7280', + }; + db.prepare('INSERT INTO categories (id, household_id, name, icon, color) VALUES (?, ?, ?, ?, ?)').run( + category.id, + category.household_id, + category.name, + category.icon, + category.color + ); + res.status(201).json({ category }); +}); + +router.put('/:id', (req, res) => { + const existing = db + .prepare('SELECT * FROM categories WHERE id = ? AND household_id = ?') + .get(req.params.id, req.household.id); + if (!existing) { + return res.status(404).json({ error: 'Kategoria nie znaleziona' }); + } + const { name, icon, color } = req.body || {}; + db.prepare('UPDATE categories SET name = ?, icon = ?, color = ? WHERE id = ?').run( + name ?? existing.name, + icon ?? existing.icon, + color ?? existing.color, + existing.id + ); + res.json({ category: db.prepare('SELECT * FROM categories WHERE id = ?').get(existing.id) }); +}); + +router.delete('/:id', (req, res) => { + const result = db + .prepare('DELETE FROM categories WHERE id = ? AND household_id = ?') + .run(req.params.id, req.household.id); + if (result.changes === 0) { + return res.status(404).json({ error: 'Kategoria nie znaleziona' }); + } + res.status(204).end(); +}); + +module.exports = { router }; diff --git a/backend/src/routes/expenses.js b/backend/src/routes/expenses.js new file mode 100644 index 0000000..312cd13 --- /dev/null +++ b/backend/src/routes/expenses.js @@ -0,0 +1,214 @@ +const express = require('express'); +const { v4: uuid } = require('uuid'); +const { db } = require('../db/db'); +const { requireAuth } = require('../middleware/auth'); +const { requireHousehold, getMembers } = require('../utils/households'); +const { sendMail } = require('../utils/mailer'); +const { round2 } = require('../utils/balance'); + +const router = express.Router(); +router.use(requireAuth, requireHousehold); + +const EPSILON = 0.01; + +function computeShares(splitType, amount, payerId, members, shares) { + const memberIds = members.map((m) => m.id); + if (splitType === 'equal') { + if (memberIds.length === 0) { + throw new Error('Gospodarstwo domowe nie ma członków do podziału wydatku'); + } + const base = Math.floor((amount / memberIds.length) * 100) / 100; + const shares = Object.fromEntries(memberIds.map((id) => [id, base])); + const distributed = round2(base * memberIds.length); + const remainder = round2(amount - distributed); + // any leftover cents from rounding go to the last member so the split always sums exactly to `amount` + shares[memberIds[memberIds.length - 1]] = round2(base + remainder); + return shares; + } + + if (splitType === 'exact' || splitType === 'full') { + if (!shares || typeof shares !== 'object') { + throw new Error('Pole "shares" jest wymagane dla wybranego typu podziału'); + } + const sum = Object.values(shares).reduce((acc, v) => acc + Number(v), 0); + if (Math.abs(sum - amount) > EPSILON) { + throw new Error('Suma udziałów musi być równa kwocie wydatku'); + } + for (const userId of Object.keys(shares)) { + if (!memberIds.includes(userId)) { + throw new Error('Udział przypisany do osoby spoza gospodarstwa domowego'); + } + } + return shares; + } + + throw new Error('Nieznany typ podziału'); +} + +function attachShares(expense) { + const shares = db + .prepare('SELECT user_id, share_amount FROM expense_shares WHERE expense_id = ?') + .all(expense.id); + return { ...expense, shares }; +} + +router.get('/', (req, res) => { + const { month, categoryId, payerId } = req.query; + let query = 'SELECT * FROM expenses WHERE household_id = ?'; + const params = [req.household.id]; + + if (month) { + query += " AND strftime('%Y-%m', expense_date) = ?"; + params.push(month); + } + if (categoryId) { + query += ' AND category_id = ?'; + params.push(categoryId); + } + if (payerId) { + query += ' AND payer_id = ?'; + params.push(payerId); + } + query += ' ORDER BY expense_date DESC, created_at DESC'; + + const expenses = db.prepare(query).all(...params).map(attachShares); + res.json({ expenses }); +}); + +router.post('/', (req, res) => { + const { amount, title, categoryId, expenseDate, payerId, splitType, shares } = req.body || {}; + if (!amount || !title || !expenseDate || !payerId || !splitType) { + return res.status(400).json({ error: 'amount, title, expenseDate, payerId i splitType są wymagane' }); + } + + const members = getMembers(req.household.id); + if (!members.some((m) => m.id === payerId)) { + return res.status(400).json({ error: 'payerId musi być członkiem gospodarstwa domowego' }); + } + + let computedShares; + try { + computedShares = computeShares(splitType, Number(amount), payerId, members, shares); + } catch (err) { + return res.status(400).json({ error: err.message }); + } + + const expense = { + id: uuid(), + household_id: req.household.id, + payer_id: payerId, + amount: Number(amount), + title, + category_id: categoryId || null, + expense_date: expenseDate, + split_type: splitType, + }; + + const insert = db.transaction(() => { + db.prepare( + `INSERT INTO expenses (id, household_id, payer_id, amount, title, category_id, expense_date, split_type) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + expense.id, + expense.household_id, + expense.payer_id, + expense.amount, + expense.title, + expense.category_id, + expense.expense_date, + expense.split_type + ); + for (const [userId, shareAmount] of Object.entries(computedShares)) { + db.prepare('INSERT INTO expense_shares (expense_id, user_id, share_amount) VALUES (?, ?, ?)').run( + expense.id, + userId, + shareAmount + ); + } + }); + insert(); + + const actor = db.prepare('SELECT name FROM users WHERE id = ?').get(req.userId); + const notifyTargets = db + .prepare( + `SELECT u.email, u.name FROM users u + JOIN household_members hm ON hm.user_id = u.id + WHERE hm.household_id = ? AND u.id != ? AND u.email_notifications = 1` + ) + .all(req.household.id, req.userId); + for (const target of notifyTargets) { + sendMail({ + to: target.email, + subject: 'KtoCo — nowy wydatek', + text: `${actor?.name || 'Ktoś'} dodał(a) nowy wydatek "${expense.title}" na kwotę ${expense.amount.toFixed(2)}.`, + }); + } + + const created = db.prepare('SELECT * FROM expenses WHERE id = ?').get(expense.id); + res.status(201).json({ expense: attachShares(created) }); +}); + +router.put('/:id', (req, res) => { + const existing = db + .prepare('SELECT * FROM expenses WHERE id = ? AND household_id = ?') + .get(req.params.id, req.household.id); + if (!existing) { + return res.status(404).json({ error: 'Wydatek nie znaleziony' }); + } + + const { amount, title, categoryId, expenseDate, payerId, splitType, shares } = req.body || {}; + const members = getMembers(req.household.id); + const finalAmount = amount !== undefined ? Number(amount) : existing.amount; + const finalPayerId = payerId || existing.payer_id; + const finalSplitType = splitType || existing.split_type; + + if (!members.some((m) => m.id === finalPayerId)) { + return res.status(400).json({ error: 'payerId musi być członkiem gospodarstwa domowego' }); + } + + let computedShares; + try { + computedShares = computeShares(finalSplitType, finalAmount, finalPayerId, members, shares); + } catch (err) { + return res.status(400).json({ error: err.message }); + } + + const update = db.transaction(() => { + db.prepare( + `UPDATE expenses SET amount = ?, title = ?, category_id = ?, expense_date = ?, payer_id = ?, + split_type = ?, updated_at = datetime('now') WHERE id = ?` + ).run( + finalAmount, + title ?? existing.title, + categoryId !== undefined ? categoryId : existing.category_id, + expenseDate ?? existing.expense_date, + finalPayerId, + finalSplitType, + existing.id + ); + db.prepare('DELETE FROM expense_shares WHERE expense_id = ?').run(existing.id); + for (const [userId, shareAmount] of Object.entries(computedShares)) { + db.prepare('INSERT INTO expense_shares (expense_id, user_id, share_amount) VALUES (?, ?, ?)').run( + existing.id, + userId, + shareAmount + ); + } + }); + update(); + + const updated = db.prepare('SELECT * FROM expenses WHERE id = ?').get(existing.id); + res.json({ expense: attachShares(updated) }); +}); + +router.delete('/:id', (req, res) => { + const result = db + .prepare('DELETE FROM expenses WHERE id = ? AND household_id = ?') + .run(req.params.id, req.household.id); + if (result.changes === 0) { + return res.status(404).json({ error: 'Wydatek nie znaleziony' }); + } + res.status(204).end(); +}); + +module.exports = { router }; diff --git a/backend/src/routes/households.js b/backend/src/routes/households.js new file mode 100644 index 0000000..07e8f86 --- /dev/null +++ b/backend/src/routes/households.js @@ -0,0 +1,154 @@ +const express = require('express'); +const crypto = require('crypto'); +const { v4: uuid } = require('uuid'); +const { db, DEFAULT_CATEGORIES } = require('../db/db'); +const { requireAuth } = require('../middleware/auth'); +const { getHouseholdsForUser, getHouseholdById, isMember, getMembers } = require('../utils/households'); + +const router = express.Router(); +router.use(requireAuth); + +const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +function generateCode() { + return crypto.randomBytes(4).toString('hex').toUpperCase(); +} + +function serializeHousehold(household) { + const members = getMembers(household.id); + const invite = db + .prepare( + `SELECT * FROM invites WHERE household_id = ? AND used_at IS NULL AND expires_at > datetime('now') + ORDER BY expires_at DESC LIMIT 1` + ) + .get(household.id); + return { ...household, members, inviteCode: invite?.code || null }; +} + +router.get('/', (req, res) => { + const households = getHouseholdsForUser(req.userId).map(serializeHousehold); + res.json({ households }); +}); + +router.get('/:id', (req, res) => { + if (!isMember(req.params.id, req.userId)) { + return res.status(403).json({ error: 'Nie jesteś członkiem tego gospodarstwa' }); + } + const household = getHouseholdById(req.params.id); + if (!household) return res.status(404).json({ error: 'Gospodarstwo nie znalezione' }); + res.json({ household: serializeHousehold(household) }); +}); + +router.post('/', (req, res) => { + const { name, currency } = req.body || {}; + const household = { id: uuid(), name: name || 'Nasze gospodarstwo', currency: currency || 'PLN' }; + + const createHousehold = db.transaction(() => { + db.prepare('INSERT INTO households (id, name, currency) VALUES (?, ?, ?)').run( + household.id, + household.name, + household.currency + ); + db.prepare('INSERT INTO household_members (household_id, user_id) VALUES (?, ?)').run(household.id, req.userId); + for (const cat of DEFAULT_CATEGORIES) { + db.prepare('INSERT INTO categories (id, household_id, name, icon, color) VALUES (?, ?, ?, ?, ?)').run( + uuid(), + household.id, + cat.name, + cat.icon, + cat.color + ); + } + }); + createHousehold(); + + const code = generateCode(); + const expiresAt = new Date(Date.now() + INVITE_TTL_MS).toISOString(); + db.prepare( + 'INSERT INTO invites (id, household_id, code, created_by, expires_at) VALUES (?, ?, ?, ?, ?)' + ).run(uuid(), household.id, code, req.userId, expiresAt); + + res.status(201).json({ household: serializeHousehold(getHouseholdById(household.id)) }); +}); + +router.put('/:id', (req, res) => { + if (!isMember(req.params.id, req.userId)) { + return res.status(403).json({ error: 'Nie jesteś członkiem tego gospodarstwa' }); + } + const household = getHouseholdById(req.params.id); + if (!household) return res.status(404).json({ error: 'Gospodarstwo nie znalezione' }); + + const { name, currency } = req.body || {}; + db.prepare('UPDATE households SET name = ?, currency = ? WHERE id = ?').run( + name ?? household.name, + currency ?? household.currency, + household.id + ); + res.json({ household: serializeHousehold(getHouseholdById(household.id)) }); +}); + +router.post('/:id/invite', (req, res) => { + if (!isMember(req.params.id, req.userId)) { + return res.status(403).json({ error: 'Nie jesteś członkiem tego gospodarstwa' }); + } + db.prepare(`UPDATE invites SET used_at = datetime('now') WHERE household_id = ? AND used_at IS NULL`).run( + req.params.id + ); + const code = generateCode(); + const expiresAt = new Date(Date.now() + INVITE_TTL_MS).toISOString(); + db.prepare( + 'INSERT INTO invites (id, household_id, code, created_by, expires_at) VALUES (?, ?, ?, ?, ?)' + ).run(uuid(), req.params.id, code, req.userId, expiresAt); + + res.status(201).json({ inviteCode: code }); +}); + +router.post('/join', (req, res) => { + const { code } = req.body || {}; + if (!code) { + return res.status(400).json({ error: 'Kod zaproszenia jest wymagany' }); + } + + const invite = db + .prepare(`SELECT * FROM invites WHERE code = ? AND used_at IS NULL AND expires_at > datetime('now')`) + .get(code.toUpperCase()); + if (!invite) { + return res.status(404).json({ error: 'Kod zaproszenia jest nieprawidłowy lub wygasł' }); + } + if (isMember(invite.household_id, req.userId)) { + return res.status(409).json({ error: 'Jesteś już członkiem tego gospodarstwa' }); + } + + db.prepare('INSERT INTO household_members (household_id, user_id) VALUES (?, ?)').run( + invite.household_id, + req.userId + ); + + res.json({ household: serializeHousehold(getHouseholdById(invite.household_id)) }); +}); + +router.delete('/:id/members/:userId', (req, res) => { + if (!isMember(req.params.id, req.userId)) { + return res.status(403).json({ error: 'Nie jesteś członkiem tego gospodarstwa' }); + } + db.prepare('DELETE FROM household_members WHERE household_id = ? AND user_id = ?').run( + req.params.id, + req.params.userId + ); + const remaining = getMembers(req.params.id); + if (remaining.length === 0) { + db.prepare('DELETE FROM households WHERE id = ?').run(req.params.id); + return res.json({ deleted: true }); + } + res.json({ household: serializeHousehold(getHouseholdById(req.params.id)) }); +}); + +router.delete('/:id', (req, res) => { + if (!isMember(req.params.id, req.userId)) { + return res.status(403).json({ error: 'Nie jesteś członkiem tego gospodarstwa' }); + } + db.prepare('DELETE FROM households WHERE id = ?').run(req.params.id); + res.json({ deleted: true }); +}); + +module.exports = { router }; diff --git a/backend/src/routes/settlements.js b/backend/src/routes/settlements.js new file mode 100644 index 0000000..d07a3c3 --- /dev/null +++ b/backend/src/routes/settlements.js @@ -0,0 +1,38 @@ +const express = require('express'); +const { v4: uuid } = require('uuid'); +const { db } = require('../db/db'); +const { requireAuth } = require('../middleware/auth'); +const { requireHousehold, getMembers } = require('../utils/households'); +const { computeSettlement } = require('../utils/balance'); + +const router = express.Router(); +router.use(requireAuth, requireHousehold); + +router.get('/', (req, res) => { + const settlements = db + .prepare('SELECT * FROM settlements WHERE household_id = ? ORDER BY settled_at DESC') + .all(req.household.id); + res.json({ settlements }); +}); + +router.post('/', (req, res) => { + const members = getMembers(req.household.id); + const current = computeSettlement(req.household.id, members); + + if (current.settled) { + return res.status(409).json({ error: 'Jesteście już rozliczeni' }); + } + + const insert = db.transaction(() => { + for (const tx of current.transactions) { + db.prepare( + 'INSERT INTO settlements (id, household_id, from_user_id, to_user_id, amount) VALUES (?, ?, ?, ?, ?)' + ).run(uuid(), req.household.id, tx.from, tx.to, tx.amount); + } + }); + insert(); + + res.status(201).json({ transactions: current.transactions }); +}); + +module.exports = { router }; diff --git a/backend/src/routes/stats.js b/backend/src/routes/stats.js new file mode 100644 index 0000000..3aecde7 --- /dev/null +++ b/backend/src/routes/stats.js @@ -0,0 +1,92 @@ +const express = require('express'); +const { db } = require('../db/db'); +const { requireAuth } = require('../middleware/auth'); +const { requireHousehold, getMembers } = require('../utils/households'); +const { computeSettlement } = require('../utils/balance'); + +const router = express.Router(); +router.use(requireAuth, requireHousehold); + +function currentMonth() { + return new Date().toISOString().slice(0, 7); +} + +router.get('/balance', (req, res) => { + const members = getMembers(req.household.id); + const result = computeSettlement(req.household.id, members); + res.json(result); +}); + +router.get('/summary', (req, res) => { + const month = req.query.month || currentMonth(); + const total = db + .prepare( + `SELECT COALESCE(SUM(amount), 0) AS total FROM expenses + WHERE household_id = ? AND strftime('%Y-%m', expense_date) = ?` + ) + .get(req.household.id, month).total; + + const byPayer = db + .prepare( + `SELECT payer_id AS userId, SUM(amount) AS total FROM expenses + WHERE household_id = ? AND strftime('%Y-%m', expense_date) = ? GROUP BY payer_id` + ) + .all(req.household.id, month); + + const byCategory = db + .prepare( + `SELECT c.id AS categoryId, c.name, c.icon, c.color, SUM(e.amount) AS total + FROM expenses e LEFT JOIN categories c ON c.id = e.category_id + WHERE e.household_id = ? AND strftime('%Y-%m', e.expense_date) = ? + GROUP BY c.id ORDER BY total DESC` + ) + .all(req.household.id, month); + + const byShare = db + .prepare( + `SELECT es.user_id AS userId, SUM(es.share_amount) AS total + FROM expense_shares es JOIN expenses e ON e.id = es.expense_id + WHERE e.household_id = ? AND strftime('%Y-%m', e.expense_date) = ? + GROUP BY es.user_id` + ) + .all(req.household.id, month); + + res.json({ month, total, byPayer, byShare, byCategory }); +}); + +router.get('/monthly', (req, res) => { + const months = db + .prepare( + `SELECT strftime('%Y-%m', expense_date) AS month, SUM(amount) AS total + FROM expenses WHERE household_id = ? GROUP BY month ORDER BY month` + ) + .all(req.household.id); + res.json({ months }); +}); + +router.get('/export.csv', (req, res) => { + const rows = db + .prepare( + `SELECT e.expense_date, e.title, e.amount, c.name AS category, u.name AS payer, e.split_type + FROM expenses e + LEFT JOIN categories c ON c.id = e.category_id + JOIN users u ON u.id = e.payer_id + WHERE e.household_id = ? ORDER BY e.expense_date DESC` + ) + .all(req.household.id); + + const escape = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`; + const header = ['Data', 'Tytuł', 'Kwota', 'Kategoria', 'Płacił', 'Podział']; + const lines = [header.join(',')]; + for (const r of rows) { + lines.push( + [escape(r.expense_date), escape(r.title), r.amount, escape(r.category), escape(r.payer), escape(r.split_type)].join(',') + ); + } + + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', 'attachment; filename="wydatki.csv"'); + res.send(lines.join('\n')); +}); + +module.exports = { router }; diff --git a/backend/src/utils/balance.js b/backend/src/utils/balance.js new file mode 100644 index 0000000..0c95787 --- /dev/null +++ b/backend/src/utils/balance.js @@ -0,0 +1,80 @@ +const { db } = require('../db/db'); + +function round2(n) { + return Math.round(n * 100) / 100; +} + +// Positive net means others owe this user money; negative means this user owes others. +function computeNetBalances(householdId, memberIds) { + const net = Object.fromEntries(memberIds.map((id) => [id, 0])); + + const paid = db + .prepare( + `SELECT payer_id AS user_id, SUM(amount) AS total FROM expenses + WHERE household_id = ? GROUP BY payer_id` + ) + .all(householdId); + for (const row of paid) { + net[row.user_id] = (net[row.user_id] || 0) + row.total; + } + + const owed = db + .prepare( + `SELECT es.user_id AS user_id, SUM(es.share_amount) AS total FROM expense_shares es + JOIN expenses e ON e.id = es.expense_id + WHERE e.household_id = ? GROUP BY es.user_id` + ) + .all(householdId); + for (const row of owed) { + net[row.user_id] = (net[row.user_id] || 0) - row.total; + } + + const settlements = db + .prepare('SELECT from_user_id, to_user_id, amount FROM settlements WHERE household_id = ?') + .all(householdId); + for (const s of settlements) { + net[s.from_user_id] = (net[s.from_user_id] || 0) + s.amount; + net[s.to_user_id] = (net[s.to_user_id] || 0) - s.amount; + } + + for (const id of Object.keys(net)) { + net[id] = round2(net[id]); + } + return net; +} + +// Greedy debt simplification: matches debtors to creditors to produce a minimal +// set of "who pays whom how much" transactions that settle every balance to zero. +function simplifyDebts(net) { + const creditors = []; + const debtors = []; + for (const [id, amount] of Object.entries(net)) { + if (amount > 0.01) creditors.push({ id, amount }); + else if (amount < -0.01) debtors.push({ id, amount: -amount }); + } + creditors.sort((a, b) => b.amount - a.amount); + debtors.sort((a, b) => b.amount - a.amount); + + const transactions = []; + let i = 0; + let j = 0; + while (i < debtors.length && j < creditors.length) { + const pay = Math.min(debtors[i].amount, creditors[j].amount); + transactions.push({ from: debtors[i].id, to: creditors[j].id, amount: round2(pay) }); + debtors[i].amount = round2(debtors[i].amount - pay); + creditors[j].amount = round2(creditors[j].amount - pay); + if (debtors[i].amount < 0.01) i++; + if (creditors[j].amount < 0.01) j++; + } + return transactions; +} + +// Returns { settled: bool, transactions: [{from, to, amount}], net } +function computeSettlement(householdId, members) { + const memberIds = members.map((m) => m.id); + const net = computeNetBalances(householdId, memberIds); + const transactions = simplifyDebts(net); + return { settled: transactions.length === 0, transactions, net }; +} + +module.exports = { computeNetBalances, computeSettlement, simplifyDebts, round2 }; diff --git a/backend/src/utils/households.js b/backend/src/utils/households.js new file mode 100644 index 0000000..d53265e --- /dev/null +++ b/backend/src/utils/households.js @@ -0,0 +1,51 @@ +const { db } = require('../db/db'); + +function getHouseholdsForUser(userId) { + return db + .prepare( + `SELECT h.* FROM households h + JOIN household_members hm ON hm.household_id = h.id + WHERE hm.user_id = ? + ORDER BY h.created_at` + ) + .all(userId); +} + +function getHouseholdById(householdId) { + return db.prepare('SELECT * FROM households WHERE id = ?').get(householdId); +} + +function isMember(householdId, userId) { + return !!db + .prepare('SELECT 1 FROM household_members WHERE household_id = ? AND user_id = ?') + .get(householdId, userId); +} + +function getMembers(householdId) { + return db + .prepare( + `SELECT u.id, u.name, u.email, u.avatar_emoji FROM users u + JOIN household_members hm ON hm.user_id = u.id + WHERE hm.household_id = ? + ORDER BY hm.joined_at` + ) + .all(householdId); +} + +function requireHousehold(req, res, next) { + const householdId = req.headers['x-household-id']; + if (!householdId) { + return res.status(400).json({ error: 'Nie wybrano gospodarstwa (brak nagłówka X-Household-Id)' }); + } + if (!isMember(householdId, req.userId)) { + return res.status(403).json({ error: 'Nie jesteś członkiem tego gospodarstwa' }); + } + const household = getHouseholdById(householdId); + if (!household) { + return res.status(404).json({ error: 'Gospodarstwo nie znalezione' }); + } + req.household = household; + next(); +} + +module.exports = { getHouseholdsForUser, getHouseholdById, isMember, getMembers, requireHousehold }; diff --git a/backend/src/utils/mailer.js b/backend/src/utils/mailer.js new file mode 100644 index 0000000..f784624 --- /dev/null +++ b/backend/src/utils/mailer.js @@ -0,0 +1,26 @@ +const nodemailer = require('nodemailer'); + +const { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM, SMTP_SECURE } = process.env; + +const transporter = SMTP_HOST + ? nodemailer.createTransport({ + host: SMTP_HOST, + port: Number(SMTP_PORT) || 587, + secure: SMTP_SECURE === 'true', + auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined, + }) + : null; + +async function sendMail({ to, subject, text }) { + if (!transporter) { + console.log(`[mailer] SMTP nieskonfigurowane — pomijam wysyłkę do ${to}: "${subject}"`); + return; + } + try { + await transporter.sendMail({ from: SMTP_FROM || SMTP_USER, to, subject, text }); + } catch (err) { + console.error('[mailer] Błąd wysyłki e-mail:', err.message); + } +} + +module.exports = { sendMail, isConfigured: !!transporter }; diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..f53ce57 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,43 @@ +services: + backend: + build: ./backend + environment: + JWT_SECRET: ${JWT_SECRET:-please-change-this-secret-in-.env} + DATABASE_PATH: /app/data/app.db + PORT: 3000 + FRONTEND_URL: ${FRONTEND_URL:-https://ktoco.kzbikowski.pl} + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_SECURE: ${SMTP_SECURE:-false} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASS: ${SMTP_PASS:-} + SMTP_FROM: ${SMTP_FROM:-} + volumes: + - ./sqlite:/app/data + networks: + - default + restart: unless-stopped + + frontend: + build: ./frontend + ports: + - "8856:80" + depends_on: + - backend + networks: + - default + - traefik_public + labels: + - traefik.enable=true + - traefik.docker.network=traefik_public + - traefik.http.routers.ktoco.rule=Host(`${DOMAIN:-ktoco.kzbikowski.pl}`) + - traefik.http.routers.ktoco.entrypoints=websecure + - traefik.http.routers.ktoco.tls.certresolver=tls-resolver + - traefik.http.routers.ktoco.tls=true + restart: unless-stopped + +networks: + default: + traefik_public: + external: true + name: ${TRAEFIK_NETWORK:-traefik_public} diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..a21f178 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,3 @@ +node_modules +dist +.git diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..74af95b --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-bookworm-slim AS build +WORKDIR /app +COPY package.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:1.27-alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..52a1fdc --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,28 @@ + + + + + + + KtoCo - Wydatki wspólne + + + + + + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..e3fc9a2 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,31 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://backend:3000/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location / { + try_files $uri $uri/ /index.html; + } + + location ~* \.(?:js|css|png|jpg|jpeg|svg|woff2?)$ { + expires 7d; + add_header Cache-Control "public"; + } + + # Never cache the service worker or manifest so updates roll out immediately + location = /sw.js { + add_header Cache-Control "no-cache"; + } + location = /manifest.webmanifest { + default_type application/manifest+json; + add_header Cache-Control "no-cache"; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..0d23230 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "ktoco-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.51.1", + "idb": "^8.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.25.1", + "recharts": "^2.12.7" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.3.4", + "vite-plugin-pwa": "^0.20.1" + } +} diff --git a/frontend/public/icons/icon-192.png b/frontend/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..44a51d5ec28de6404fdea0131b118d472d8620e2 GIT binary patch literal 8818 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 S*#ukwATOgNT_b4}{C@x}i+peZ literal 0 HcmV?d00001 diff --git a/frontend/public/icons/icon-512.png b/frontend/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..2b3cce4b8024c5bfee6855ce398ca94918cfd130 GIT binary patch literal 10324 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 PHb6%DCKs~L-+cH#P_n`u literal 0 HcmV?d00001 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..871cb31 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,68 @@ +import { Routes, Route, Navigate, Outlet } from 'react-router-dom'; +import { useAuth } from './auth/AuthContext.jsx'; +import { useHouseholdContext } from './household/HouseholdContext.jsx'; +import Login from './pages/Login.jsx'; +import Register from './pages/Register.jsx'; +import ForgotPassword from './pages/ForgotPassword.jsx'; +import ResetPassword from './pages/ResetPassword.jsx'; +import JoinInvite from './pages/JoinInvite.jsx'; +import Onboarding from './pages/Onboarding.jsx'; +import Dashboard from './pages/Dashboard.jsx'; +import AddExpense from './pages/AddExpense.jsx'; +import History from './pages/History.jsx'; +import Stats from './pages/Stats.jsx'; +import Settings from './pages/Settings.jsx'; +import BottomNav from './components/BottomNav.jsx'; +import OfflineBanner from './components/OfflineBanner.jsx'; +import InstallBanner from './components/InstallBanner.jsx'; + +function RequireAuth() { + const { isAuthenticated } = useAuth(); + if (!isAuthenticated) return ; + return ; +} + +function RequireHousehold() { + const { households, householdsLoading } = useHouseholdContext(); + if (householdsLoading) return
Ładowanie…
; + if (households.length === 0) return ; + return ; +} + +function Layout() { + return ( +
+ + +
+ +
+ +
+ ); +} + +export default function App() { + return ( + + } /> + } /> + } /> + } /> + } /> + }> + } /> + }> + }> + } /> + } /> + } /> + } /> + } /> + + + + } /> + + ); +} diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..a2e0243 --- /dev/null +++ b/frontend/src/api/client.js @@ -0,0 +1,73 @@ +const TOKEN_KEY = 'ktoco_token'; +const ACTIVE_HOUSEHOLD_KEY = 'ktoco_active_household'; + +export function getToken() { + return localStorage.getItem(TOKEN_KEY); +} + +export function setToken(token) { + if (token) localStorage.setItem(TOKEN_KEY, token); + else localStorage.removeItem(TOKEN_KEY); +} + +// Called when the server rejects our token (expired/invalid). Clears it and tells +// AuthContext to drop its cached user, so the app redirects to /login instead of +// misreading "no household" and bouncing to /onboarding. +export function clearInvalidSession() { + setToken(null); + window.dispatchEvent(new Event('ktoco:auth-invalid')); +} + +export function getActiveHouseholdId() { + return localStorage.getItem(ACTIVE_HOUSEHOLD_KEY); +} + +export function setActiveHouseholdId(id) { + if (id) localStorage.setItem(ACTIVE_HOUSEHOLD_KEY, id); + else localStorage.removeItem(ACTIVE_HOUSEHOLD_KEY); +} + +export class ApiError extends Error { + constructor(message, status) { + super(message); + this.status = status; + } +} + +export async function apiFetch(path, options = {}) { + const token = getToken(); + const headers = { ...(options.headers || {}) }; + if (options.body && !(options.body instanceof FormData)) { + headers['Content-Type'] = 'application/json'; + } + if (token) { + headers.Authorization = `Bearer ${token}`; + } + const activeHouseholdId = getActiveHouseholdId(); + if (activeHouseholdId) { + headers['X-Household-Id'] = activeHouseholdId; + } + + const res = await fetch(`/api${path}`, { ...options, headers }); + + if (res.status === 401 && token) { + clearInvalidSession(); + } + + if (res.status === 204) return null; + + const isJson = res.headers.get('content-type')?.includes('application/json'); + const data = isJson ? await res.json() : await res.text(); + + if (!res.ok) { + throw new ApiError(isJson ? data.error || 'Wystąpił błąd' : data, res.status); + } + return data; +} + +export const api = { + get: (path) => apiFetch(path, { method: 'GET' }), + post: (path, body) => apiFetch(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }), + put: (path, body) => apiFetch(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }), + delete: (path) => apiFetch(path, { method: 'DELETE' }), +}; diff --git a/frontend/src/api/queries.js b/frontend/src/api/queries.js new file mode 100644 index 0000000..9b55869 --- /dev/null +++ b/frontend/src/api/queries.js @@ -0,0 +1,229 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { api } from './client.js'; +import { useHouseholdContext } from '../household/HouseholdContext.jsx'; + +export function useHouseholds() { + return useQuery({ + queryKey: ['households'], + queryFn: () => api.get('/households'), + select: (data) => data.households, + }); +} + +export function useCreateHousehold() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload) => api.post('/households', payload), + onSuccess: () => qc.invalidateQueries({ queryKey: ['households'] }), + }); +} + +export function useJoinHousehold() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (code) => api.post('/households/join', { code }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['households'] }), + }); +} + +export function useUpdateHousehold() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...payload }) => api.put(`/households/${id}`, payload), + onSuccess: () => qc.invalidateQueries({ queryKey: ['households'] }), + }); +} + +export function useRegenerateInvite() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (householdId) => api.post(`/households/${householdId}/invite`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['households'] }), + }); +} + +export function useRemoveMember() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ householdId, userId }) => api.delete(`/households/${householdId}/members/${userId}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['households'] }), + }); +} + +export function useDeleteHousehold() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (householdId) => api.delete(`/households/${householdId}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['households'] }), + }); +} + +export function useCategories() { + const { activeHouseholdId } = useHouseholdContext(); + const query = useQuery({ + queryKey: ['categories'], + queryFn: () => api.get('/categories'), + select: (data) => data.categories, + enabled: !!activeHouseholdId, + }); + return { ...query, isLoading: !activeHouseholdId || query.isLoading }; +} + +export function useCreateCategory() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload) => api.post('/categories', payload), + onSuccess: () => qc.invalidateQueries({ queryKey: ['categories'] }), + }); +} + +export function useUpdateCategory() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...payload }) => api.put(`/categories/${id}`, payload), + onSuccess: () => qc.invalidateQueries({ queryKey: ['categories'] }), + }); +} + +export function useDeleteCategory() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id) => api.delete(`/categories/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['categories'] }), + }); +} + +export function useExpenses(filters = {}) { + const { activeHouseholdId } = useHouseholdContext(); + const params = new URLSearchParams(filters); + const qs = params.toString(); + const query = useQuery({ + queryKey: ['expenses', filters], + queryFn: () => api.get(`/expenses${qs ? `?${qs}` : ''}`), + select: (data) => data.expenses, + enabled: !!activeHouseholdId, + }); + return { ...query, isLoading: !activeHouseholdId || query.isLoading }; +} + +export function useCreateExpense() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload) => api.post('/expenses', payload), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['expenses'] }); + qc.invalidateQueries({ queryKey: ['balance'] }); + qc.invalidateQueries({ queryKey: ['summary'] }); + qc.invalidateQueries({ queryKey: ['monthly'] }); + }, + }); +} + +export function useUpdateExpense() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...payload }) => api.put(`/expenses/${id}`, payload), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['expenses'] }); + qc.invalidateQueries({ queryKey: ['balance'] }); + qc.invalidateQueries({ queryKey: ['summary'] }); + qc.invalidateQueries({ queryKey: ['monthly'] }); + }, + }); +} + +export function useDeleteExpense() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id) => api.delete(`/expenses/${id}`), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['expenses'] }); + qc.invalidateQueries({ queryKey: ['balance'] }); + qc.invalidateQueries({ queryKey: ['summary'] }); + qc.invalidateQueries({ queryKey: ['monthly'] }); + }, + }); +} + +export function useBalance() { + const { activeHouseholdId } = useHouseholdContext(); + const query = useQuery({ + queryKey: ['balance'], + queryFn: () => api.get('/stats/balance'), + enabled: !!activeHouseholdId, + }); + return { ...query, isLoading: !activeHouseholdId || query.isLoading }; +} + +export function useSummary(month) { + const { activeHouseholdId } = useHouseholdContext(); + const query = useQuery({ + queryKey: ['summary', month], + queryFn: () => api.get(`/stats/summary${month ? `?month=${month}` : ''}`), + enabled: !!activeHouseholdId, + }); + return { ...query, isLoading: !activeHouseholdId || query.isLoading }; +} + +export function useMonthly() { + const { activeHouseholdId } = useHouseholdContext(); + const query = useQuery({ + queryKey: ['monthly'], + queryFn: () => api.get('/stats/monthly'), + select: (d) => d.months, + enabled: !!activeHouseholdId, + }); + return { ...query, isLoading: !activeHouseholdId || query.isLoading }; +} + +export function useMe() { + return useQuery({ queryKey: ['me'], queryFn: () => api.get('/auth/me'), select: (d) => d.user }); +} + +export function useUpdateProfile() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (name) => api.put('/auth/me', { name }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['me'] }), + }); +} + +export function useDeleteAccount() { + return useMutation({ mutationFn: () => api.delete('/auth/me') }); +} + +export function useUpdateNotifications() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (enabled) => api.put('/auth/me/notifications', { enabled }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['me'] }), + }); +} + +export function useChangePassword() { + return useMutation({ + mutationFn: ({ currentPassword, newPassword }) => + api.post('/auth/change-password', { currentPassword, newPassword }), + }); +} + +export function useForgotPassword() { + return useMutation({ mutationFn: (email) => api.post('/auth/forgot-password', { email }) }); +} + +export function useResetPassword() { + return useMutation({ + mutationFn: ({ token, password }) => api.post('/auth/reset-password', { token, password }), + }); +} + +export function useSettleUp() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => api.post('/settlements'), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['balance'] }); + qc.invalidateQueries({ queryKey: ['settlements'] }); + }, + }); +} diff --git a/frontend/src/auth/AuthContext.jsx b/frontend/src/auth/AuthContext.jsx new file mode 100644 index 0000000..65d396a --- /dev/null +++ b/frontend/src/auth/AuthContext.jsx @@ -0,0 +1,74 @@ +import { createContext, useContext, useEffect, useState, useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { api, getToken, setToken } from '../api/client.js'; + +const USER_KEY = 'ktoco_user'; +const AuthContext = createContext(null); + +export function AuthProvider({ children }) { + const qc = useQueryClient(); + const [user, setUser] = useState(() => { + const raw = localStorage.getItem(USER_KEY); + return raw ? JSON.parse(raw) : null; + }); + const [ready, setReady] = useState(true); + + const persistUser = useCallback((u) => { + setUser(u); + if (u) localStorage.setItem(USER_KEY, JSON.stringify(u)); + else localStorage.removeItem(USER_KEY); + }, []); + + const login = useCallback( + async (email, password) => { + const data = await api.post('/auth/login', { email, password }); + setToken(data.token); + persistUser(data.user); + return data.user; + }, + [persistUser] + ); + + const register = useCallback( + async (email, password, name) => { + const data = await api.post('/auth/register', { email, password, name }); + setToken(data.token); + persistUser(data.user); + return data.user; + }, + [persistUser] + ); + + const logout = useCallback(() => { + setToken(null); + persistUser(null); + qc.clear(); + }, [persistUser, qc]); + + useEffect(() => { + if (user && !getToken()) { + persistUser(null); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + function handleAuthInvalid() { + persistUser(null); + qc.clear(); + } + window.addEventListener('ktoco:auth-invalid', handleAuthInvalid); + return () => window.removeEventListener('ktoco:auth-invalid', handleAuthInvalid); + }, [persistUser, qc]); + + return ( + + {children} + + ); +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used within AuthProvider'); + return ctx; +} diff --git a/frontend/src/components/BottomNav.jsx b/frontend/src/components/BottomNav.jsx new file mode 100644 index 0000000..b52d048 --- /dev/null +++ b/frontend/src/components/BottomNav.jsx @@ -0,0 +1,34 @@ +import { NavLink } from 'react-router-dom'; +import Icon from './Icon.jsx'; + +const items = [ + { to: '/', label: 'Start', icon: 'home', end: true }, + { to: '/history', label: 'Historia', icon: 'receipt_long' }, +]; + +const rightItems = [ + { to: '/stats', label: 'Statystyki', icon: 'bar_chart' }, + { to: '/settings', label: 'Ustawienia', icon: 'settings' }, +]; + +export default function BottomNav() { + return ( + + ); +} diff --git a/frontend/src/components/CategoryPieChart.jsx b/frontend/src/components/CategoryPieChart.jsx new file mode 100644 index 0000000..b3136ef --- /dev/null +++ b/frontend/src/components/CategoryPieChart.jsx @@ -0,0 +1,25 @@ +import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from 'recharts'; + +export default function CategoryPieChart({ data, currency }) { + const chartData = data + .filter((d) => d.total > 0) + .map((d) => ({ name: d.name || 'Bez kategorii', value: d.total, color: d.color || '#6b7280' })); + + if (chartData.length === 0) { + return

Brak wydatków w tym miesiącu

; + } + + return ( + + + + {chartData.map((entry, i) => ( + + ))} + + `${value.toFixed(2)} ${currency}`} /> + + + + ); +} diff --git a/frontend/src/components/ConfirmDialogProvider.jsx b/frontend/src/components/ConfirmDialogProvider.jsx new file mode 100644 index 0000000..d8f9b66 --- /dev/null +++ b/frontend/src/components/ConfirmDialogProvider.jsx @@ -0,0 +1,57 @@ +import { createContext, useCallback, useContext, useState } from 'react'; +import Icon from './Icon.jsx'; + +const ConfirmContext = createContext(null); + +export function ConfirmProvider({ children }) { + const [state, setState] = useState(null); + + const confirmAction = useCallback((options) => { + return new Promise((resolve) => { + setState({ + title: options.title || 'Czy na pewno?', + message: options.message || '', + confirmLabel: options.confirmLabel || 'Usuń', + cancelLabel: options.cancelLabel || 'Anuluj', + danger: options.danger !== false, + resolve, + }); + }); + }, []); + + function handle(result) { + state?.resolve(result); + setState(null); + } + + return ( + + {children} + {state && ( +
handle(false)}> +
e.stopPropagation()}> +
+ +
+

{state.title}

+ {state.message &&

{state.message}

} +
+ + +
+
+
+ )} +
+ ); +} + +export function useConfirm() { + const ctx = useContext(ConfirmContext); + if (!ctx) throw new Error('useConfirm must be used within ConfirmProvider'); + return ctx; +} diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx new file mode 100644 index 0000000..2453a61 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.jsx @@ -0,0 +1,28 @@ +import { Component } from 'react'; + +export default class ErrorBoundary extends Component { + state = { error: null }; + + static getDerivedStateFromError(error) { + return { error }; + } + + componentDidCatch(error, info) { + console.error('Nieobsłużony błąd renderowania:', error, info); + } + + render() { + if (this.state.error) { + return ( +
+

Coś poszło nie tak

+

Wystąpił nieoczekiwany błąd. Spróbuj odświeżyć stronę.

+ +
+ ); + } + return this.props.children; + } +} diff --git a/frontend/src/components/ExpenseEditModal.jsx b/frontend/src/components/ExpenseEditModal.jsx new file mode 100644 index 0000000..de6dd93 --- /dev/null +++ b/frontend/src/components/ExpenseEditModal.jsx @@ -0,0 +1,142 @@ +import { useState } from 'react'; +import { useUpdateExpense, useDeleteExpense } from '../api/queries.js'; +import { useConfirm } from './ConfirmDialogProvider.jsx'; +import PayerToggle from './PayerToggle.jsx'; +import SplitSelector from './SplitSelector.jsx'; +import Icon from './Icon.jsx'; + +export default function ExpenseEditModal({ expense, members, categories, currency, onClose }) { + const updateExpense = useUpdateExpense(); + const deleteExpense = useDeleteExpense(); + const confirmDialog = useConfirm(); + + const [amount, setAmount] = useState(String(expense.amount)); + const [title, setTitle] = useState(expense.title); + const [categoryId, setCategoryId] = useState(expense.category_id); + const [payerId, setPayerId] = useState(expense.payer_id); + const [expenseDate, setExpenseDate] = useState(expense.expense_date); + const [splitType, setSplitType] = useState(expense.split_type); + const [exactShares, setExactShares] = useState( + Object.fromEntries(expense.shares.map((s) => [s.user_id, String(s.share_amount)])) + ); + const [fullOwedBy, setFullOwedBy] = useState( + expense.shares.find((s) => s.share_amount === expense.amount)?.user_id || null + ); + const [error, setError] = useState(''); + + function buildShares() { + if (splitType === 'exact') { + return Object.fromEntries(members.map((m) => [m.id, Number(exactShares[m.id]) || 0])); + } + if (splitType === 'full') { + const owedBy = fullOwedBy || members[0]?.id; + return Object.fromEntries(members.map((m) => [m.id, m.id === owedBy ? Number(amount) : 0])); + } + return undefined; + } + + async function handleSave() { + setError(''); + try { + await updateExpense.mutateAsync({ + id: expense.id, + amount: Number(amount), + title, + categoryId, + expenseDate, + payerId, + splitType, + shares: buildShares(), + }); + onClose(); + } catch (err) { + setError(err.message); + } + } + + async function handleDelete() { + const ok = await confirmDialog({ + title: 'Usunąć ten wydatek?', + message: 'Tej operacji nie da się cofnąć.', + confirmLabel: 'Usuń', + }); + if (!ok) return; + await deleteExpense.mutateAsync(expense.id); + onClose(); + } + + return ( +
+
e.stopPropagation()}> +
+

Edytuj wydatek

+ +
+ +
+ setAmount(e.target.value)} + /> + +
+ + setTitle(e.target.value)} /> +
+ +
+ +
+ {(categories || []).map((c) => ( +
setCategoryId(c.id)} + > + + {c.name} +
+ ))} +
+
+ +
+ + +
+ + + +
+ + setExpenseDate(e.target.value)} /> +
+ + {error &&

{error}

} + +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/ExpenseListItem.jsx b/frontend/src/components/ExpenseListItem.jsx new file mode 100644 index 0000000..dfb41f3 --- /dev/null +++ b/frontend/src/components/ExpenseListItem.jsx @@ -0,0 +1,26 @@ +import Icon from './Icon.jsx'; + +export default function ExpenseListItem({ expense, category, payer, currentUserId, currency, onClick }) { + const myShare = expense.shares.find((s) => s.user_id === currentUserId)?.share_amount ?? 0; + + return ( +
+
+ +
+
+
{expense.title}
+
+ {expense.expense_date} + · + + {payer?.name || '—'} +
+
+
+
{expense.amount.toFixed(2)} {currency}
+
Twoja część: {myShare.toFixed(2)} {currency}
+
+
+ ); +} diff --git a/frontend/src/components/Icon.jsx b/frontend/src/components/Icon.jsx new file mode 100644 index 0000000..133c6a7 --- /dev/null +++ b/frontend/src/components/Icon.jsx @@ -0,0 +1,7 @@ +export default function Icon({ name, className = '', style }) { + return ( + + ); +} diff --git a/frontend/src/components/InstallBanner.jsx b/frontend/src/components/InstallBanner.jsx new file mode 100644 index 0000000..870ddaf --- /dev/null +++ b/frontend/src/components/InstallBanner.jsx @@ -0,0 +1,41 @@ +import { useState } from 'react'; +import { useInstallPrompt } from '../pwa/useInstallPrompt.js'; +import Icon from './Icon.jsx'; + +const DISMISS_KEY = 'ktoco_install_dismissed'; + +export default function InstallBanner() { + const { canInstall, isIOS, promptInstall } = useInstallPrompt(); + const [dismissed, setDismissed] = useState(() => localStorage.getItem(DISMISS_KEY) === '1'); + + if (dismissed || (!canInstall && !isIOS)) return null; + + function handleDismiss() { + localStorage.setItem(DISMISS_KEY, '1'); + setDismissed(true); + } + + return ( +
+ +
+ {canInstall ? ( + Zainstaluj KtoCo jako aplikację na telefonie + ) : ( + + Dodaj KtoCo do ekranu głównego: dotknij Udostępnij, a + potem „Dodaj do ekranu początkowego” + + )} +
+ {canInstall && ( + + )} + +
+ ); +} diff --git a/frontend/src/components/MonthlyBarChart.jsx b/frontend/src/components/MonthlyBarChart.jsx new file mode 100644 index 0000000..b675b4b --- /dev/null +++ b/frontend/src/components/MonthlyBarChart.jsx @@ -0,0 +1,19 @@ +import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'; + +export default function MonthlyBarChart({ data, currency }) { + if (!data || data.length === 0) { + return

Brak danych historycznych

; + } + + return ( + + + + + + `${Number(value).toFixed(2)} ${currency}`} /> + + + + ); +} diff --git a/frontend/src/components/OfflineBanner.jsx b/frontend/src/components/OfflineBanner.jsx new file mode 100644 index 0000000..8359684 --- /dev/null +++ b/frontend/src/components/OfflineBanner.jsx @@ -0,0 +1,14 @@ +import { useOnlineSync } from '../offline/useOnlineSync.js'; + +export default function OfflineBanner() { + const { isOnline, pendingCount } = useOnlineSync(); + + if (isOnline && pendingCount === 0) return null; + + return ( +
+ {!isOnline && Brak połączenia — wydatki zapisują się lokalnie} + {isOnline && pendingCount > 0 && Synchronizowanie {pendingCount} wydatków…} +
+ ); +} diff --git a/frontend/src/components/PasswordField.jsx b/frontend/src/components/PasswordField.jsx new file mode 100644 index 0000000..4d80622 --- /dev/null +++ b/frontend/src/components/PasswordField.jsx @@ -0,0 +1,26 @@ +import { useState } from 'react'; +import Icon from './Icon.jsx'; + +export default function PasswordField({ value, onChange, placeholder }) { + const [visible, setVisible] = useState(false); + + return ( +
+ + +
+ ); +} diff --git a/frontend/src/components/PayerComparisonChart.jsx b/frontend/src/components/PayerComparisonChart.jsx new file mode 100644 index 0000000..ebd94e9 --- /dev/null +++ b/frontend/src/components/PayerComparisonChart.jsx @@ -0,0 +1,30 @@ +import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Cell } from 'recharts'; + +const COLORS = ['#4f46e5', '#f59e0b', '#22c55e', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16']; + +export default function PayerComparisonChart({ members, byShare, currency }) { + const data = members.map((m) => ({ + name: m.name, + value: byShare.find((s) => s.userId === m.id)?.total || 0, + })); + + if (data.every((d) => d.value === 0)) { + return

Brak wydatków w tym miesiącu

; + } + + return ( + + + + + + `${Number(value).toFixed(2)} ${currency}`} /> + + {data.map((_, i) => ( + + ))} + + + + ); +} diff --git a/frontend/src/components/PayerToggle.jsx b/frontend/src/components/PayerToggle.jsx new file mode 100644 index 0000000..28959f8 --- /dev/null +++ b/frontend/src/components/PayerToggle.jsx @@ -0,0 +1,18 @@ +import Icon from './Icon.jsx'; + +export default function PayerToggle({ members, payerId, onChange }) { + return ( +
+ {members.map((m) => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/SplitSelector.jsx b/frontend/src/components/SplitSelector.jsx new file mode 100644 index 0000000..1669ae0 --- /dev/null +++ b/frontend/src/components/SplitSelector.jsx @@ -0,0 +1,69 @@ +const OPTIONS = [ + { value: 'equal', label: 'Po równo (50/50)' }, + { value: 'exact', label: 'Dokładny podział' }, + { value: 'full', label: 'Całość na jedną osobę' }, +]; + +export default function SplitSelector({ + members, + amount, + splitType, + onSplitTypeChange, + exactShares, + onExactSharesChange, + fullOwedBy, + onFullOwedByChange, +}) { + const exactSum = members.reduce((acc, m) => acc + (Number(exactShares[m.id]) || 0), 0); + const exactValid = Math.abs(exactSum - Number(amount || 0)) < 0.01; + + return ( +
+ +
+ {OPTIONS.map((opt) => ( +
onSplitTypeChange(opt.value)} + > + {opt.label} + + {opt.value === 'exact' && splitType === 'exact' && ( +
e.stopPropagation()}> + {members.map((m) => ( +
+ + onExactSharesChange({ ...exactShares, [m.id]: e.target.value })} + /> +
+ ))} + {!exactValid && amount &&

Suma udziałów musi wynosić {amount}

} +
+ )} + + {opt.value === 'full' && splitType === 'full' && ( +
e.stopPropagation()}> + {members.map((m) => ( + + ))} +
+ )} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/Switch.jsx b/frontend/src/components/Switch.jsx new file mode 100644 index 0000000..8549e9e --- /dev/null +++ b/frontend/src/components/Switch.jsx @@ -0,0 +1,8 @@ +export default function Switch({ checked, onChange, disabled }) { + return ( + + ); +} diff --git a/frontend/src/household/HouseholdContext.jsx b/frontend/src/household/HouseholdContext.jsx new file mode 100644 index 0000000..006385d --- /dev/null +++ b/frontend/src/household/HouseholdContext.jsx @@ -0,0 +1,68 @@ +import { createContext, useContext, useEffect, useState, useCallback } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { api, getActiveHouseholdId, setActiveHouseholdId as persistActiveHouseholdId } from '../api/client.js'; +import { useAuth } from '../auth/AuthContext.jsx'; + +const HouseholdContext = createContext(null); + +const HOUSEHOLD_SCOPED_KEYS = ['expenses', 'categories', 'balance', 'summary', 'monthly', 'settlements']; + +export function HouseholdProvider({ children }) { + const { isAuthenticated } = useAuth(); + const qc = useQueryClient(); + const [activeHouseholdId, setActiveHouseholdIdState] = useState(() => getActiveHouseholdId()); + + const householdsQuery = useQuery({ + queryKey: ['households'], + queryFn: () => api.get('/households'), + select: (d) => d.households, + enabled: isAuthenticated, + }); + + const households = householdsQuery.data || []; + + const switchHousehold = useCallback( + (id) => { + persistActiveHouseholdId(id); + setActiveHouseholdIdState(id); + for (const key of HOUSEHOLD_SCOPED_KEYS) { + qc.invalidateQueries({ queryKey: [key] }); + } + }, + [qc] + ); + + useEffect(() => { + if (!isAuthenticated || householdsQuery.isLoading) return; + if (households.length === 0) { + if (activeHouseholdId) switchHousehold(null); + return; + } + if (!activeHouseholdId || !households.some((h) => h.id === activeHouseholdId)) { + switchHousehold(households[0].id); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [households, householdsQuery.isLoading, isAuthenticated, activeHouseholdId]); + + const activeHousehold = households.find((h) => h.id === activeHouseholdId) || null; + + return ( + + {children} + + ); +} + +export function useHouseholdContext() { + const ctx = useContext(HouseholdContext); + if (!ctx) throw new Error('useHouseholdContext must be used within HouseholdProvider'); + return ctx; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..d14cfa4 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,37 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import App from './App.jsx'; +import { AuthProvider } from './auth/AuthContext.jsx'; +import { ThemeProvider } from './theme/ThemeContext.jsx'; +import { HouseholdProvider } from './household/HouseholdContext.jsx'; +import { ConfirmProvider } from './components/ConfirmDialogProvider.jsx'; +import ErrorBoundary from './components/ErrorBoundary.jsx'; +import './styles.css'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: 1, staleTime: 30_000 }, + }, +}); + +ReactDOM.createRoot(document.getElementById('root')).render( + + + + + + + + + + + + + + + + + +); diff --git a/frontend/src/offline/db.js b/frontend/src/offline/db.js new file mode 100644 index 0000000..cca58a1 --- /dev/null +++ b/frontend/src/offline/db.js @@ -0,0 +1,31 @@ +import { openDB } from 'idb'; + +const DB_NAME = 'ktoco-offline'; +const STORE = 'pending-expenses'; + +async function getDb() { + return openDB(DB_NAME, 1, { + upgrade(db) { + if (!db.objectStoreNames.contains(STORE)) { + db.createObjectStore(STORE, { keyPath: 'localId' }); + } + }, + }); +} + +export async function queueExpense(payload) { + const db = await getDb(); + const localId = `local-${Date.now()}-${Math.random().toString(36).slice(2)}`; + await db.put(STORE, { localId, payload, createdAt: Date.now() }); + return localId; +} + +export async function getPendingExpenses() { + const db = await getDb(); + return db.getAll(STORE); +} + +export async function removePendingExpense(localId) { + const db = await getDb(); + await db.delete(STORE, localId); +} diff --git a/frontend/src/offline/syncQueue.js b/frontend/src/offline/syncQueue.js new file mode 100644 index 0000000..ad882c4 --- /dev/null +++ b/frontend/src/offline/syncQueue.js @@ -0,0 +1,24 @@ +import { api } from '../api/client.js'; +import { getPendingExpenses, removePendingExpense } from './db.js'; + +let syncing = false; + +export async function syncPendingExpenses(onSynced) { + if (syncing || !navigator.onLine) return; + syncing = true; + try { + const pending = await getPendingExpenses(); + for (const item of pending) { + try { + await api.post('/expenses', item.payload); + await removePendingExpense(item.localId); + onSynced?.(item.localId); + } catch (err) { + // stop on first failure (e.g. still offline or auth issue); retry on next trigger + break; + } + } + } finally { + syncing = false; + } +} diff --git a/frontend/src/offline/useOnlineSync.js b/frontend/src/offline/useOnlineSync.js new file mode 100644 index 0000000..4d6d9ff --- /dev/null +++ b/frontend/src/offline/useOnlineSync.js @@ -0,0 +1,43 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { getPendingExpenses } from './db.js'; +import { syncPendingExpenses } from './syncQueue.js'; + +export function useOnlineSync() { + const [isOnline, setIsOnline] = useState(navigator.onLine); + const [pendingCount, setPendingCount] = useState(0); + const qc = useQueryClient(); + + const refreshPendingCount = useCallback(async () => { + const pending = await getPendingExpenses(); + setPendingCount(pending.length); + }, []); + + const runSync = useCallback(async () => { + await syncPendingExpenses(); + await refreshPendingCount(); + qc.invalidateQueries({ queryKey: ['expenses'] }); + qc.invalidateQueries({ queryKey: ['balance'] }); + qc.invalidateQueries({ queryKey: ['summary'] }); + qc.invalidateQueries({ queryKey: ['monthly'] }); + }, [qc, refreshPendingCount]); + + useEffect(() => { + refreshPendingCount(); + function handleOnline() { + setIsOnline(true); + runSync(); + } + function handleOffline() { + setIsOnline(false); + } + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, [runSync, refreshPendingCount]); + + return { isOnline, pendingCount, refreshPendingCount, runSync }; +} diff --git a/frontend/src/pages/AddExpense.jsx b/frontend/src/pages/AddExpense.jsx new file mode 100644 index 0000000..8ef0edc --- /dev/null +++ b/frontend/src/pages/AddExpense.jsx @@ -0,0 +1,154 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useCategories, useCreateExpense } from '../api/queries.js'; +import { useHouseholdContext } from '../household/HouseholdContext.jsx'; +import { useAuth } from '../auth/AuthContext.jsx'; +import { queueExpense } from '../offline/db.js'; +import PayerToggle from '../components/PayerToggle.jsx'; +import SplitSelector from '../components/SplitSelector.jsx'; +import Icon from '../components/Icon.jsx'; + +function todayStr() { + return new Date().toISOString().slice(0, 10); +} + +export default function AddExpense() { + const navigate = useNavigate(); + const { user } = useAuth(); + const { activeHousehold: household } = useHouseholdContext(); + const { data: categories } = useCategories(); + const createExpense = useCreateExpense(); + + const members = household?.members || []; + + const [amount, setAmount] = useState(''); + const [title, setTitle] = useState(''); + const [categoryId, setCategoryId] = useState(null); + const [payerId, setPayerId] = useState(user?.id); + const [expenseDate, setExpenseDate] = useState(todayStr()); + const [splitType, setSplitType] = useState('equal'); + const [exactShares, setExactShares] = useState({}); + const [fullOwedBy, setFullOwedBy] = useState(null); + const [error, setError] = useState(''); + const [notice, setNotice] = useState(''); + + function buildShares() { + if (splitType === 'exact') { + return Object.fromEntries(members.map((m) => [m.id, Number(exactShares[m.id]) || 0])); + } + if (splitType === 'full') { + const owedBy = fullOwedBy || members[0]?.id; + return Object.fromEntries(members.map((m) => [m.id, m.id === owedBy ? Number(amount) : 0])); + } + return undefined; + } + + async function handleSubmit(e) { + e.preventDefault(); + setError(''); + setNotice(''); + + if (!amount || Number(amount) <= 0) { + setError('Podaj poprawną kwotę'); + return; + } + if (!payerId) { + setError('Wybierz kto płacił'); + return; + } + if (splitType === 'full' && !fullOwedBy) { + setError('Wybierz kto jest winien całość'); + return; + } + + const payload = { + amount: Number(amount), + title: title || 'Wydatek', + categoryId, + expenseDate, + payerId, + splitType, + shares: buildShares(), + }; + + try { + await createExpense.mutateAsync(payload); + navigate('/', { replace: true }); + } catch (err) { + if (!navigator.onLine) { + await queueExpense(payload); + setNotice('Brak sieci — wydatek zapisano lokalnie i zsynchronizuje się automatycznie.'); + setTimeout(() => navigate('/', { replace: true }), 1200); + } else { + setError(err.message); + } + } + } + + return ( +
+

Dodaj wydatek

+
+ setAmount(e.target.value)} + autoFocus + /> + +
+ + setTitle(e.target.value)} /> +
+ +
+ +
+ {(categories || []).map((c) => ( +
setCategoryId(c.id)} + > + + {c.name} +
+ ))} +
+
+ +
+ + +
+ + + +
+ + setExpenseDate(e.target.value)} /> +
+ + {error &&

{error}

} + {notice &&

{notice}

} + + + +
+ ); +} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx new file mode 100644 index 0000000..6102982 --- /dev/null +++ b/frontend/src/pages/Dashboard.jsx @@ -0,0 +1,76 @@ +import { useBalance, useSummary, useSettleUp } from '../api/queries.js'; +import { useHouseholdContext } from '../household/HouseholdContext.jsx'; +import CategoryPieChart from '../components/CategoryPieChart.jsx'; +import Icon from '../components/Icon.jsx'; + +function memberName(members, id) { + return members.find((m) => m.id === id)?.name || '—'; +} + +export default function Dashboard() { + const { activeHousehold: household } = useHouseholdContext(); + const { data: balance } = useBalance(); + const { data: summary } = useSummary(); + const settleUp = useSettleUp(); + + const members = household?.members || []; + const currency = household?.currency || 'PLN'; + + return ( +
+

Dashboard

+ +
+ {!balance ? ( +

Ładowanie…

+ ) : balance.settled ? ( + <> +

Rozliczenia

+

+ Jesteście na czysto! +

+ + ) : ( + <> +

Rozliczenia

+
+ {balance.transactions.map((tx, i) => ( +

+ {memberName(members, tx.from)} → {memberName(members, tx.to)}: {tx.amount.toFixed(2)} {currency} +

+ ))} +
+ + + )} +
+ +
+

Wydatki wg kategorii (ten miesiąc)

+ {!summary ?

Ładowanie…

: } +
+ +
+

Podsumowanie miesiąca

+ {summary && ( +
+
+
Suma
+
{summary.total.toFixed(2)} {currency}
+
+ {members.map((m) => ( +
+
{m.name}
+
+ {(summary.byPayer.find((p) => p.userId === m.id)?.total || 0).toFixed(2)} {currency} +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/ForgotPassword.jsx b/frontend/src/pages/ForgotPassword.jsx new file mode 100644 index 0000000..699cffe --- /dev/null +++ b/frontend/src/pages/ForgotPassword.jsx @@ -0,0 +1,50 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useForgotPassword } from '../api/queries.js'; + +export default function ForgotPassword() { + const forgotPassword = useForgotPassword(); + const [email, setEmail] = useState(''); + const [sent, setSent] = useState(false); + const [error, setError] = useState(''); + + async function handleSubmit(e) { + e.preventDefault(); + setError(''); + try { + await forgotPassword.mutateAsync(email); + setSent(true); + } catch (err) { + setError(err.message); + } + } + + return ( +
+

Przypomnij hasło

+ {sent ? ( +

Jeśli konto z tym adresem e-mail istnieje, wysłaliśmy wiadomość z linkiem do resetu hasła.

+ ) : ( + <> +

Podaj e-mail, na który wyślemy link do zresetowania hasła

+
+ setEmail(e.target.value)} + required + /> + {error &&

{error}

} + +
+ + )} +

+ Powrót do logowania +

+
+ ); +} diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx new file mode 100644 index 0000000..0cf75fb --- /dev/null +++ b/frontend/src/pages/History.jsx @@ -0,0 +1,92 @@ +import { useMemo, useState } from 'react'; +import { useCategories, useExpenses } from '../api/queries.js'; +import { useHouseholdContext } from '../household/HouseholdContext.jsx'; +import { useAuth } from '../auth/AuthContext.jsx'; +import ExpenseListItem from '../components/ExpenseListItem.jsx'; +import ExpenseEditModal from '../components/ExpenseEditModal.jsx'; + +function monthOptions() { + const options = []; + const now = new Date(); + for (let i = 0; i < 12; i++) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + options.push(d.toISOString().slice(0, 7)); + } + return options; +} + +export default function History() { + const { user } = useAuth(); + const { activeHousehold: household } = useHouseholdContext(); + const { data: categories } = useCategories(); + const [month, setMonth] = useState(''); + const [categoryId, setCategoryId] = useState(''); + const [payerId, setPayerId] = useState(''); + const [editing, setEditing] = useState(null); + + const filters = useMemo(() => { + const f = {}; + if (month) f.month = month; + if (categoryId) f.categoryId = categoryId; + if (payerId) f.payerId = payerId; + return f; + }, [month, categoryId, payerId]); + + const { data: expenses, isLoading } = useExpenses(filters); + const members = household?.members || []; + const currency = household?.currency || 'PLN'; + const categoryById = Object.fromEntries((categories || []).map((c) => [c.id, c])); + const memberById = Object.fromEntries(members.map((m) => [m.id, m])); + + return ( +
+

Historia

+ +
+ + + +
+ + {isLoading &&

Ładowanie…

} + {!isLoading && (expenses || []).length === 0 &&

Brak wydatków spełniających filtry

} + + {(expenses || []).map((expense) => ( + setEditing(expense)} + /> + ))} + + {editing && ( + setEditing(null)} + /> + )} +
+ ); +} diff --git a/frontend/src/pages/JoinInvite.jsx b/frontend/src/pages/JoinInvite.jsx new file mode 100644 index 0000000..256f542 --- /dev/null +++ b/frontend/src/pages/JoinInvite.jsx @@ -0,0 +1,48 @@ +import { useEffect, useState } from 'react'; +import { useParams, useNavigate, Navigate } from 'react-router-dom'; +import { useAuth } from '../auth/AuthContext.jsx'; +import { useJoinHousehold } from '../api/queries.js'; +import { useHouseholdContext } from '../household/HouseholdContext.jsx'; + +export default function JoinInvite() { + const { code } = useParams(); + const navigate = useNavigate(); + const { isAuthenticated } = useAuth(); + const { switchHousehold } = useHouseholdContext(); + const joinHousehold = useJoinHousehold(); + const [error, setError] = useState(''); + const [attempted, setAttempted] = useState(false); + + useEffect(() => { + if (!isAuthenticated || attempted) return; + setAttempted(true); + joinHousehold + .mutateAsync(code) + .then((result) => { + switchHousehold(result.household.id); + navigate('/', { replace: true }); + }) + .catch((err) => setError(err.message)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAuthenticated, attempted, code]); + + if (!isAuthenticated) { + return ; + } + + return ( +
+

Dołączanie do gospodarstwa

+ {error ? ( + <> +

{error}

+ + + ) : ( +

Chwileczkę…

+ )} +
+ ); +} diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx new file mode 100644 index 0000000..3dc7c37 --- /dev/null +++ b/frontend/src/pages/Login.jsx @@ -0,0 +1,65 @@ +import { useState } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import { useAuth } from '../auth/AuthContext.jsx'; +import { useJoinHousehold } from '../api/queries.js'; +import { useHouseholdContext } from '../household/HouseholdContext.jsx'; +import PasswordField from '../components/PasswordField.jsx'; + +export default function Login() { + const { login } = useAuth(); + const navigate = useNavigate(); + const [params] = useSearchParams(); + const inviteCode = params.get('code') || ''; + const joinHousehold = useJoinHousehold(); + const { switchHousehold } = useHouseholdContext(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e) { + e.preventDefault(); + setError(''); + setLoading(true); + try { + await login(email, password); + if (inviteCode) { + try { + const result = await joinHousehold.mutateAsync(inviteCode); + switchHousehold(result.household.id); + } catch { + // invalid/expired code, or already a member — just continue into the app + } + } + navigate('/', { replace: true }); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + } + + return ( +
+

KtoCo

+

+ {inviteCode ? 'Zaloguj się, aby dołączyć do zaproszonego gospodarstwa domowego' : 'Zaloguj się, by zarządzać wspólnymi wydatkami'} +

+
+ setEmail(e.target.value)} required /> + setPassword(e.target.value)} /> + {error &&

{error}

} + + +

+ Zapomniałeś hasła? +

+

+ Nie masz konta?{' '} + Zarejestruj się +

+
+ ); +} diff --git a/frontend/src/pages/Onboarding.jsx b/frontend/src/pages/Onboarding.jsx new file mode 100644 index 0000000..113caa9 --- /dev/null +++ b/frontend/src/pages/Onboarding.jsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useCreateHousehold, useJoinHousehold } from '../api/queries.js'; +import { useHouseholdContext } from '../household/HouseholdContext.jsx'; +import Icon from '../components/Icon.jsx'; + +export default function Onboarding() { + const navigate = useNavigate(); + const { switchHousehold } = useHouseholdContext(); + const [mode, setMode] = useState('create'); + const [name, setName] = useState('Nasze gospodarstwo'); + const [currency, setCurrency] = useState('PLN'); + const [code, setCode] = useState(''); + const [error, setError] = useState(''); + + const createHousehold = useCreateHousehold(); + const joinHousehold = useJoinHousehold(); + + async function handleCreate(e) { + e.preventDefault(); + setError(''); + try { + const result = await createHousehold.mutateAsync({ name, currency }); + switchHousehold(result.household.id); + navigate('/', { replace: true }); + } catch (err) { + setError(err.message); + } + } + + async function handleJoin(e) { + e.preventDefault(); + setError(''); + try { + const result = await joinHousehold.mutateAsync(code); + switchHousehold(result.household.id); + navigate('/', { replace: true }); + } catch (err) { + setError(err.message); + } + } + + return ( +
+ +

Witaj!

+

Załóż nowe gospodarstwo domowe albo dołącz do partnera/partnerki kodem

+ +
+ + +
+ + {mode === 'create' ? ( +
+ setName(e.target.value)} /> + + {error &&

{error}

} + +
+ ) : ( +
+ setCode(e.target.value.toUpperCase())} + required + /> + {error &&

{error}

} + +
+ )} +
+ ); +} diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx new file mode 100644 index 0000000..00f6fb1 --- /dev/null +++ b/frontend/src/pages/Register.jsx @@ -0,0 +1,68 @@ +import { useState } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import { useAuth } from '../auth/AuthContext.jsx'; +import { useJoinHousehold } from '../api/queries.js'; +import { useHouseholdContext } from '../household/HouseholdContext.jsx'; +import PasswordField from '../components/PasswordField.jsx'; + +export default function Register() { + const { register } = useAuth(); + const navigate = useNavigate(); + const [params] = useSearchParams(); + const inviteCode = params.get('code') || ''; + const joinHousehold = useJoinHousehold(); + const { switchHousehold } = useHouseholdContext(); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e) { + e.preventDefault(); + setError(''); + setLoading(true); + try { + await register(email, password, name); + if (inviteCode) { + const result = await joinHousehold.mutateAsync(inviteCode); + switchHousehold(result.household.id); + navigate('/', { replace: true }); + } else { + navigate('/onboarding', { replace: true }); + } + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + } + + return ( +
+

KtoCo

+

+ {inviteCode + ? 'Załóż konto, aby dołączyć do zaproszonego gospodarstwa domowego' + : 'Załóż konto, by zacząć dzielić wydatki'} +

+
+ setName(e.target.value)} required /> + setEmail(e.target.value)} required /> + setPassword(e.target.value)} + /> + {error &&

{error}

} + + +

+ Masz już konto?{' '} + Zaloguj się +

+
+ ); +} diff --git a/frontend/src/pages/ResetPassword.jsx b/frontend/src/pages/ResetPassword.jsx new file mode 100644 index 0000000..37a4667 --- /dev/null +++ b/frontend/src/pages/ResetPassword.jsx @@ -0,0 +1,60 @@ +import { useState } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import { useResetPassword } from '../api/queries.js'; +import PasswordField from '../components/PasswordField.jsx'; + +export default function ResetPassword() { + const navigate = useNavigate(); + const [params] = useSearchParams(); + const token = params.get('token') || ''; + const resetPassword = useResetPassword(); + + const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [error, setError] = useState(''); + const [done, setDone] = useState(false); + + async function handleSubmit(e) { + e.preventDefault(); + setError(''); + if (password !== confirm) { + setError('Hasła nie są takie same'); + return; + } + try { + await resetPassword.mutateAsync({ token, password }); + setDone(true); + setTimeout(() => navigate('/login', { replace: true }), 1500); + } catch (err) { + setError(err.message); + } + } + + if (!token) { + return ( +
+

Nieprawidłowy link

+

Brakuje tokenu resetu hasła. Poproś o nowy link.

+

Przypomnij hasło

+
+ ); + } + + return ( +
+

Ustaw nowe hasło

+ {done ? ( +

Hasło zostało zmienione. Przenoszenie do logowania…

+ ) : ( +
+ setPassword(e.target.value)} /> + setConfirm(e.target.value)} /> + {error &&

{error}

} + + + )} +
+ ); +} diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx new file mode 100644 index 0000000..fba8c32 --- /dev/null +++ b/frontend/src/pages/Settings.jsx @@ -0,0 +1,541 @@ +import { useRef, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { + useUpdateHousehold, + useRegenerateInvite, + useRemoveMember, + useDeleteHousehold, + useCategories, + useCreateCategory, + useUpdateCategory, + useDeleteCategory, + useMe, + useUpdateProfile, + useDeleteAccount, + useUpdateNotifications, + useChangePassword, +} from '../api/queries.js'; +import { useHouseholdContext } from '../household/HouseholdContext.jsx'; +import { useAuth } from '../auth/AuthContext.jsx'; +import { useTheme } from '../theme/ThemeContext.jsx'; +import { useInstallPrompt } from '../pwa/useInstallPrompt.js'; +import { useConfirm } from '../components/ConfirmDialogProvider.jsx'; +import { getToken } from '../api/client.js'; +import Icon from '../components/Icon.jsx'; +import Switch from '../components/Switch.jsx'; +import PasswordField from '../components/PasswordField.jsx'; + +const CURRENCIES = ['PLN', 'EUR', 'USD', 'GBP']; +const ICON_CHOICES = [ + 'shopping_cart', 'home', 'bolt', 'directions_car', 'restaurant', 'celebration', + 'inventory_2', 'medication', 'school', 'flight', 'pets', 'fitness_center', + 'spa', 'local_bar', 'redeem', 'work', 'child_care', 'theater_comedy', + 'local_gas_station', 'checkroom', 'sports_esports', 'devices', 'local_cafe', + 'movie', 'wifi', 'local_hospital', 'directions_bike', 'cleaning_services', +]; + +const THEME_OPTIONS = [ + { value: 'light', label: 'Jasny', icon: 'light_mode' }, + { value: 'dark', label: 'Ciemny', icon: 'dark_mode' }, + { value: 'system', label: 'Systemowy', icon: 'contrast' }, +]; + +async function downloadCsv() { + const res = await fetch('/api/stats/export.csv', { + headers: { Authorization: `Bearer ${getToken()}` }, + }); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'wydatki.csv'; + a.click(); + URL.revokeObjectURL(url); +} + +function CopyField({ value, big }) { + const inputRef = useRef(null); + const [status, setStatus] = useState(null); // 'copied' | 'failed' + + async function handleCopy() { + if (!value) return; + let ok = false; + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(value); + ok = true; + } + } catch { + ok = false; + } + if (!ok && inputRef.current) { + try { + inputRef.current.focus(); + inputRef.current.select(); + ok = document.execCommand('copy'); + } catch { + ok = false; + } + } + setStatus(ok ? 'copied' : 'failed'); + setTimeout(() => setStatus(null), 2000); + } + + return ( +
+
+ e.target.select()} + /> + +
+ {status === 'copied' &&

Skopiowano do schowka!

} + {status === 'failed' && ( +

Nie udało się skopiować automatycznie — zaznacz pole powyżej i skopiuj ręcznie.

+ )} +
+ ); +} + +function InviteCode({ code }) { + const inviteLink = code ? `${window.location.origin}/join/${code}` : ''; + return ( +
+ +

+ Albo wyślij link, który od razu przeniesie do rejestracji i dołączy do gospodarstwa: +

+ +
+ ); +} + +function InstallSection() { + const { canInstall, isInstalled, isIOS, isSecureContext, promptInstall } = useInstallPrompt(); + + return ( +
+

Aplikacja

+ {isInstalled && ( +

+ Aplikacja jest zainstalowana na tym urządzeniu +

+ )} + {!isInstalled && canInstall && ( + + )} + {!isInstalled && isIOS && ( +

+ Dotknij Udostępnij, a potem „Dodaj do ekranu + początkowego”. +

+ )} + {!isInstalled && !canInstall && !isIOS && !isSecureContext && ( +

+ Instalacja wymaga bezpiecznego połączenia — otwórz aplikację pod adresem zaczynającym się od{' '} + https://, a nie przez lokalny adres IP. +

+ )} + {!isInstalled && !canInstall && !isIOS && isSecureContext && ( +

+ Przeglądarka jeszcze nie zaproponowała instalacji. Odśwież stronę po chwili korzystania z aplikacji albo + użyj menu przeglądarki (⋮) i wybierz „Zainstaluj aplikację” / „Dodaj do ekranu głównego”. +

+ )} +
+ ); +} + +function AccountSection() { + const navigate = useNavigate(); + const { logout } = useAuth(); + const { data: me } = useMe(); + const updateProfile = useUpdateProfile(); + const deleteAccount = useDeleteAccount(); + const confirmDialog = useConfirm(); + + const [name, setName] = useState(''); + const [nameSaved, setNameSaved] = useState(false); + + if (!me) return null; + + async function handleNameBlur(e) { + const value = e.target.value.trim(); + if (!value || value === me.name) return; + await updateProfile.mutateAsync(value); + setNameSaved(true); + setTimeout(() => setNameSaved(false), 2000); + } + + async function handleDeleteAccount() { + const ok = await confirmDialog({ + title: 'Usunąć swoje konto?', + message: 'Tej operacji nie da się cofnąć.', + confirmLabel: 'Usuń konto', + }); + if (!ok) return; + await deleteAccount.mutateAsync(); + logout(); + navigate('/login', { replace: true }); + } + + return ( +
+

Konto

+
+ + setName(e.target.value)} + onBlur={handleNameBlur} + /> + {nameSaved &&

Zapisano

} +
+
+ + +
+ +
+ ); +} + +function HouseholdsSection() { + const { user } = useAuth(); + const { households, activeHouseholdId, activeHousehold, switchHousehold } = useHouseholdContext(); + const updateHousehold = useUpdateHousehold(); + const regenerateInvite = useRegenerateInvite(); + const removeMember = useRemoveMember(); + const deleteHousehold = useDeleteHousehold(); + const confirmDialog = useConfirm(); + + async function handleRemoveMember(m) { + const isSelf = m.id === user?.id; + const ok = await confirmDialog({ + title: isSelf ? 'Opuścić to gospodarstwo?' : `Usunąć ${m.name} z gospodarstwa?`, + message: isSelf + ? 'Będziesz musiał(a) dołączyć ponownie kodem zaproszenia, żeby wrócić.' + : 'Ta osoba straci dostęp do gospodarstwa (historia wydatków zostanie zachowana).', + confirmLabel: isSelf ? 'Opuść' : 'Usuń', + }); + if (!ok) return; + await removeMember.mutateAsync({ householdId: activeHousehold.id, userId: m.id }); + } + + async function handleDeleteHousehold() { + const ok = await confirmDialog({ + title: `Usunąć gospodarstwo „${activeHousehold.name}”?`, + message: 'Usunie to całą historię wydatków wszystkich członków. Tej operacji nie da się cofnąć.', + confirmLabel: 'Usuń gospodarstwo', + }); + if (!ok) return; + await deleteHousehold.mutateAsync(activeHousehold.id); + } + + return ( +
+

Twoje gospodarstwa

+ {households.map((h) => ( +
+
+
{h.name}
+
{h.members.length} {h.members.length === 1 ? 'osoba' : 'osoby/osób'}
+
+ {h.id === activeHouseholdId ? ( + Aktywne + ) : ( + + )} +
+ ))} + + Utwórz lub dołącz do gospodarstwa + + + {activeHousehold && ( + <> +
+
+ + + e.target.value !== activeHousehold.name && + updateHousehold.mutate({ id: activeHousehold.id, name: e.target.value }) + } + /> +
+
+ + +
+ +

Członkowie

+ {activeHousehold.members.map((m) => ( +
+ +
+
{m.name} {m.id === user?.id && '(Ty)'}
+
{m.email}
+
+ +
+ ))} + +
+

Zaproś kolejną osobę tym kodem:

+ + +
+ + + + )} +
+ ); +} + +export default function Settings() { + const { logout } = useAuth(); + const { theme, setTheme } = useTheme(); + const { activeHouseholdId } = useHouseholdContext(); + const { data: categories } = useCategories(); + const createCategory = useCreateCategory(); + const updateCategory = useUpdateCategory(); + const deleteCategory = useDeleteCategory(); + const { data: me } = useMe(); + const updateNotifications = useUpdateNotifications(); + const changePassword = useChangePassword(); + const confirmDialog = useConfirm(); + + const [editingCategoryId, setEditingCategoryId] = useState(null); + const [catName, setCatName] = useState(''); + const [catIcon, setCatIcon] = useState(ICON_CHOICES[0]); + + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [passwordError, setPasswordError] = useState(''); + const [passwordSuccess, setPasswordSuccess] = useState(false); + + function startEditCategory(c) { + setEditingCategoryId(c.id); + setCatName(c.name); + setCatIcon(c.icon); + } + + function cancelEditCategory() { + setEditingCategoryId(null); + setCatName(''); + setCatIcon(ICON_CHOICES[0]); + } + + async function handleDeleteCategory(c) { + const ok = await confirmDialog({ + title: `Usunąć kategorię „${c.name}”?`, + message: 'Istniejące wydatki zachowają swoją historię, ale stracą przypisaną kategorię.', + confirmLabel: 'Usuń', + }); + if (!ok) return; + deleteCategory.mutate(c.id); + } + + async function handleCategorySubmit(e) { + e.preventDefault(); + if (!catName.trim()) return; + if (editingCategoryId) { + await updateCategory.mutateAsync({ id: editingCategoryId, name: catName, icon: catIcon }); + } else { + await createCategory.mutateAsync({ name: catName, icon: catIcon }); + } + cancelEditCategory(); + } + + async function handlePasswordSubmit(e) { + e.preventDefault(); + setPasswordError(''); + setPasswordSuccess(false); + if (newPassword !== confirmPassword) { + setPasswordError('Nowe hasła nie są takie same'); + return; + } + try { + await changePassword.mutateAsync({ currentPassword, newPassword }); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setPasswordSuccess(true); + setTimeout(() => setPasswordSuccess(false), 3000); + } catch (err) { + setPasswordError(err.message); + } + } + + return ( +
+

Ustawienia

+ + + + + +
+

Wygląd

+
+ {THEME_OPTIONS.map((opt) => ( + + ))} +
+
+ +
+

Powiadomienia

+
+
+ Powiadomienia mailowe + Dostaniesz e-mail, gdy ktoś w gospodarstwie doda nowy wydatek +
+ updateNotifications.mutate(checked)} + /> +
+
+ + + +
+

Bezpieczeństwo

+
+
+ + setCurrentPassword(e.target.value)} /> +
+
+ + setNewPassword(e.target.value)} /> +
+
+ + setConfirmPassword(e.target.value)} /> +
+ {passwordError &&

{passwordError}

} + {passwordSuccess &&

Hasło zostało zmienione

} + +
+
+ + {activeHouseholdId && ( +
+

Kategorie

+ {(categories || []).map((c) => ( +
+ + +
+ ))} + +
+
+ +
+ {ICON_CHOICES.map((icon) => ( +
setCatIcon(icon)} + > + +
+ ))} +
+
+
+ + setCatName(e.target.value)} + /> +
+
+ {editingCategoryId && ( + + )} + +
+
+
+ )} + +
+

Dane

+ +
+ +
+ +
+
+ ); +} diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx new file mode 100644 index 0000000..8dbcaa7 --- /dev/null +++ b/frontend/src/pages/Stats.jsx @@ -0,0 +1,50 @@ +import { useMonthly, useSummary } from '../api/queries.js'; +import { useHouseholdContext } from '../household/HouseholdContext.jsx'; +import MonthlyBarChart from '../components/MonthlyBarChart.jsx'; +import PayerComparisonChart from '../components/PayerComparisonChart.jsx'; +import Icon from '../components/Icon.jsx'; + +export default function Stats() { + const { activeHousehold: household } = useHouseholdContext(); + const { data: monthly } = useMonthly(); + const { data: summary } = useSummary(); + + const members = household?.members || []; + const currency = household?.currency || 'PLN'; + + return ( +
+

Statystyki

+ +
+

Wydatki miesiąc do miesiąca

+ {!monthly ?

Ładowanie…

: } +
+ +
+

Kto więcej konsumuje (ten miesiąc)

+ {!summary ? ( +

Ładowanie…

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

Kategorie od najdroższej

+ {summary && ( +
+ {summary.byCategory.length === 0 &&

Brak wydatków w tym miesiącu

} + {summary.byCategory.map((c) => ( +
+ + {c.name || 'Bez kategorii'} + {c.total.toFixed(2)} {currency} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pwa/useInstallPrompt.js b/frontend/src/pwa/useInstallPrompt.js new file mode 100644 index 0000000..8d9c1e0 --- /dev/null +++ b/frontend/src/pwa/useInstallPrompt.js @@ -0,0 +1,47 @@ +import { useCallback, useEffect, useState } from 'react'; + +function detectIOS() { + return /iphone|ipad|ipod/i.test(navigator.userAgent) && !window.MSStream; +} + +function detectStandalone() { + return window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true; +} + +export function useInstallPrompt() { + const [deferredPrompt, setDeferredPrompt] = useState(null); + const [isInstalled, setIsInstalled] = useState(detectStandalone()); + + useEffect(() => { + function handleBeforeInstall(e) { + e.preventDefault(); + setDeferredPrompt(e); + } + function handleInstalled() { + setIsInstalled(true); + setDeferredPrompt(null); + } + window.addEventListener('beforeinstallprompt', handleBeforeInstall); + window.addEventListener('appinstalled', handleInstalled); + return () => { + window.removeEventListener('beforeinstallprompt', handleBeforeInstall); + window.removeEventListener('appinstalled', handleInstalled); + }; + }, []); + + const promptInstall = useCallback(async () => { + if (!deferredPrompt) return false; + deferredPrompt.prompt(); + const choice = await deferredPrompt.userChoice; + setDeferredPrompt(null); + return choice.outcome === 'accepted'; + }, [deferredPrompt]); + + return { + canInstall: !!deferredPrompt && !isInstalled, + isInstalled, + isIOS: detectIOS() && !isInstalled, + isSecureContext: window.isSecureContext, + promptInstall, + }; +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..8935220 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,585 @@ +:root { + --primary: #4f46e5; + --primary-dark: #4338ca; + --bg: #f8fafc; + --card: #ffffff; + --input-bg: #ffffff; + --text: #1e293b; + --muted: #64748b; + --border: #e2e8f0; + --danger: #ef4444; + --success: #22c55e; + --accent-bg: #eef2ff; + --accent-text: #4338ca; + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']) { + --bg: #0f172a; + --card: #1e293b; + --input-bg: #1e293b; + --text: #f1f5f9; + --muted: #94a3b8; + --border: #334155; + --accent-bg: #312e81; + --accent-text: #c7d2fe; + color-scheme: dark; + } +} + +:root[data-theme='dark'] { + --bg: #0f172a; + --card: #1e293b; + --input-bg: #1e293b; + --text: #f1f5f9; + --muted: #94a3b8; + --border: #334155; + --accent-bg: #312e81; + --accent-text: #c7d2fe; + color-scheme: dark; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--text); + -webkit-tap-highlight-color: transparent; +} + +input, select, button, textarea { font-family: inherit; font-size: 1rem; color: var(--text); } + +.material-symbols-outlined { + font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; + vertical-align: middle; + line-height: 1; +} + +.app-shell { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.app-content { + flex: 1; + padding: 16px 16px 96px; + max-width: 560px; + width: 100%; + margin: 0 auto; +} + +.page-loading { + padding: 40px; + text-align: center; + color: var(--muted); +} + +/* Bottom nav */ +.bottom-nav { + position: fixed; + bottom: 0; + left: 0; + right: 0; + display: grid; + grid-template-columns: repeat(5, 1fr); + align-items: center; + justify-items: center; + background: var(--card); + border-top: 1px solid var(--border); + padding: 6px 8px calc(6px + env(safe-area-inset-bottom)); + z-index: 20; +} + +.nav-item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + font-size: 0.7rem; + color: var(--muted); + text-decoration: none; + padding: 6px 4px; + border-radius: 12px; + width: 100%; +} + +.nav-item.active { + color: var(--primary); + font-weight: 600; +} + +.nav-icon { font-size: 1.4rem; } + +.fab { + width: 56px; + height: 56px; + border-radius: 50%; + background: var(--primary); + color: white; + display: flex; + align-items: center; + justify-content: center; + text-decoration: none; + box-shadow: 0 4px 14px rgba(79, 70, 229, 0.4); + margin-top: -28px; + line-height: 1; +} + +.fab .material-symbols-outlined { font-size: 30px; } + +/* Auth pages */ +.auth-page { + max-width: 400px; + margin: 40px auto; + padding: 24px; + text-align: center; + position: relative; +} + +.back-btn { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.85rem; + color: var(--muted); + margin-bottom: 12px; +} + +.auth-subtitle { color: var(--muted); margin-bottom: 24px; } + +.auth-form { + display: flex; + flex-direction: column; + gap: 12px; +} + +.auth-form input, .auth-form select { + padding: 14px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--input-bg); + width: 100%; + box-sizing: border-box; +} + +.password-field { position: relative; } +.password-field input { width: 100%; padding-right: 44px; } +.password-toggle { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 8px; +} + +.btn-primary { + background: var(--primary); + color: white; + border: none; + padding: 14px; + border-radius: 10px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.btn-primary:disabled { opacity: 0.6; } + +.btn-secondary { + background: transparent; + border: 1px solid var(--border); + color: var(--text); + padding: 10px 14px; + border-radius: 10px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.btn-full { width: 100%; display: block; } + +.theme-toggle { display: flex; gap: 8px; } +.theme-toggle .tab { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px; } + +.form-error { color: var(--danger); font-size: 0.9rem; } + +.tabs { display: flex; gap: 8px; margin-bottom: 16px; justify-content: center; } +.tab { + padding: 10px 18px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--card); + cursor: pointer; +} +.tab.active { background: var(--primary); color: white; border-color: var(--primary); } + +/* Cards */ +.card { + background: var(--card); + border-radius: 16px; + padding: 20px; + margin-bottom: 16px; + box-shadow: 0 1px 3px rgba(0,0,0,0.06); +} + +.settlement-tile { text-align: center; } +.settlement-tile .amount { font-size: 2rem; font-weight: 700; margin: 8px 0; } +.settlement-tile .amount.settled { color: var(--success); } +.settlement-tile .amount.owed { color: var(--danger); } +.settlement-transactions { display: flex; flex-direction: column; gap: 4px; } +.settlement-transactions .amount { font-size: 1.3rem; margin: 0; } + +.summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); + gap: 8px; + text-align: center; +} +.summary-grid .label { font-size: 0.75rem; color: var(--muted); } +.summary-grid .value { font-size: 1.1rem; font-weight: 600; } + +/* Add expense form */ +.expense-form { display: flex; flex-direction: column; gap: 16px; } +.amount-input { + font-size: 2.5rem; + text-align: center; + border: none; + border-bottom: 2px solid var(--border); + padding: 12px; + background: transparent; + width: 100%; +} +.amount-input:focus { outline: none; border-color: var(--primary); } + +.field { display: flex; flex-direction: column; gap: 6px; } +.field + .field { margin-top: 16px; } +.field label { font-size: 0.85rem; color: var(--muted); font-weight: 600; } +.field input, .field select, .field textarea { + width: 100%; + box-sizing: border-box; + padding: 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--input-bg); + color: var(--text); +} + +.category-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; +} +.category-chip { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 10px 4px; + border-radius: 12px; + border: 2px solid transparent; + background: var(--bg); + cursor: pointer; + font-size: 0.7rem; +} +.category-chip.selected { border-color: var(--primary); background: var(--accent-bg); color: var(--accent-text); } +.category-chip .icon { font-size: 1.4rem; } + +.toggle-row { display: flex; flex-wrap: wrap; gap: 10px; } +.toggle-row .toggle-btn { min-width: 100px; } +.toggle-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 14px; + border-radius: 12px; + border: 2px solid var(--border); + background: var(--card); + color: var(--text); + cursor: pointer; + font-weight: 600; +} +.toggle-btn.selected { border-color: var(--primary); background: var(--accent-bg); color: var(--accent-text); } + +.split-options { display: flex; flex-direction: column; gap: 8px; } +.split-option { + padding: 12px; + border-radius: 12px; + border: 2px solid var(--border); + cursor: pointer; +} +.split-option.selected { border-color: var(--primary); background: var(--accent-bg); color: var(--accent-text); } +.exact-shares { display: flex; flex-direction: column; gap: 10px; margin-top: 8px; } +.exact-shares .field { flex: 1; } + +/* History */ +.filters { display: flex; flex-direction: column; gap: 10px; margin-bottom: 16px; } +.filters select, .filters input { + width: 100%; + box-sizing: border-box; + padding: 12px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--input-bg); + color: var(--text); +} + +.expense-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: var(--card); + border-radius: 12px; + margin-bottom: 8px; + cursor: pointer; +} +.expense-item .cat-icon { + width: 40px; height: 40px; + border-radius: 50%; + display: flex; align-items: center; justify-content: center; + font-size: 1.2rem; + flex-shrink: 0; +} +.expense-item .details { flex: 1; min-width: 0; } +.expense-item .title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.expense-item .meta { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.8rem; + color: var(--muted); +} +.expense-item .meta .material-symbols-outlined { font-size: 1rem; } +.expense-item .amount-col { text-align: right; } +.expense-item .amount { font-weight: 700; } +.expense-item .share { font-size: 0.75rem; color: var(--muted); } + +.empty-state { text-align: center; color: var(--muted); padding: 40px 20px; } + +/* Settings */ +.member-row { display: flex; align-items: center; gap: 10px; padding: 10px 0; } + +.household-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 0; + border-bottom: 1px solid var(--border); +} +.household-row:last-of-type { border-bottom: none; } +.household-row-main { display: flex; flex-direction: column; gap: 2px; } +.badge-active { + background: var(--accent-bg); + color: var(--accent-text); + font-size: 0.75rem; + font-weight: 600; + padding: 4px 10px; + border-radius: 999px; +} +.section-divider { border: none; border-top: 1px solid var(--border); margin: 16px 0; } +.category-manage-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); } +.category-manage-row-main { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; cursor: pointer; background: none; border: none; padding: 0; text-align: left; color: inherit; font: inherit; } +.category-manage-row-main.editing { color: var(--primary); font-weight: 600; } +.category-manage-row .icon { font-size: 1.3rem; } +.category-manage-row .name { flex: 1; } +.invite-code-row { + display: flex; + align-items: stretch; + gap: 8px; + margin: 8px 0; +} +.invite-code-input { + flex: 1; + min-width: 0; + font-size: 1.4rem; + font-weight: 700; + letter-spacing: 3px; + text-align: center; + padding: 12px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--accent-bg); + color: var(--accent-text); +} +.invite-link-input { + flex: 1; + min-width: 0; + font-size: 0.85rem; + padding: 12px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--input-bg); + color: var(--text); +} +.invite-code-copy-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 0 16px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--card); + color: var(--text); + cursor: pointer; +} +.invite-code-hint { + font-size: 0.8rem; + color: var(--muted); + margin-top: 4px; +} +.invite-code-hint.success { color: var(--success); } +.invite-code-hint.error { color: var(--danger); } + +.switch-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 0; +} +.switch-row .switch-label { display: flex; flex-direction: column; gap: 2px; } +.switch-desc { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.8rem; + color: var(--muted); + margin: 0; +} +.switch-desc .material-symbols-outlined { font-size: 1.1rem; } + +.switch { + position: relative; + display: inline-block; + width: 48px; + height: 28px; + flex-shrink: 0; +} +.switch input { opacity: 0; width: 0; height: 0; } +.switch-track { + position: absolute; + inset: 0; + background: var(--border); + border-radius: 999px; + cursor: pointer; + transition: background 0.15s; +} +.switch-track::before { + content: ''; + position: absolute; + width: 22px; + height: 22px; + left: 3px; + top: 3px; + background: white; + border-radius: 50%; + transition: transform 0.15s; +} +.switch input:checked + .switch-track { background: var(--primary); } +.switch input:checked + .switch-track::before { transform: translateX(20px); } + +.page-title { margin-top: 0; } + +.offline-banner { + background: #fef3c7; + color: #92400e; + text-align: center; + padding: 8px; + font-size: 0.85rem; +} +.offline-banner--syncing { background: #dbeafe; color: #1e40af; } + +.install-banner { + display: flex; + align-items: center; + gap: 10px; + background: var(--accent-bg); + color: var(--accent-text); + padding: 10px 12px; + font-size: 0.85rem; +} +.install-banner-text { flex: 1; display: flex; align-items: center; gap: 4px; flex-wrap: wrap; } +.install-banner-btn { padding: 8px 14px; white-space: nowrap; } +.inline-icon { font-size: 1rem; } + +.modal-actions { display: flex; gap: 10px; } +.modal-actions button { flex: 1; } + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.5); + display: flex; + align-items: flex-end; + justify-content: center; + z-index: 30; +} +.modal-sheet { + background: var(--card); + width: 100%; + max-width: 560px; + max-height: 90vh; + overflow-y: auto; + border-radius: 20px 20px 0 0; + padding: 20px; +} +.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } + +.confirm-sheet { + max-width: 400px; + border-radius: 20px; + margin-bottom: 24px; + text-align: center; +} +.confirm-icon { + width: 56px; + height: 56px; + border-radius: 50%; + background: var(--accent-bg); + color: var(--accent-text); + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 12px; +} +.confirm-icon .material-symbols-outlined { font-size: 28px; } +.confirm-icon.danger { background: #fee2e2; color: var(--danger); } +:root[data-theme='dark'] .confirm-icon.danger { background: rgba(239, 68, 68, 0.18); } +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']) .confirm-icon.danger { background: rgba(239, 68, 68, 0.18); } +} +.confirm-sheet h3 { margin: 0 0 8px; } +.confirm-message { color: var(--muted); margin: 0 0 20px; font-size: 0.9rem; } +.icon-btn { background: none; border: none; font-size: 1.4rem; cursor: pointer; } +.btn-danger { + background: var(--danger); + color: white; + border: none; + padding: 12px; + border-radius: 10px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} diff --git a/frontend/src/theme/ThemeContext.jsx b/frontend/src/theme/ThemeContext.jsx new file mode 100644 index 0000000..8b42062 --- /dev/null +++ b/frontend/src/theme/ThemeContext.jsx @@ -0,0 +1,34 @@ +import { createContext, useContext, useEffect, useState, useCallback } from 'react'; + +const THEME_KEY = 'ktoco_theme'; +const ThemeContext = createContext(null); + +function applyTheme(theme) { + const root = document.documentElement; + if (theme === 'system') { + root.removeAttribute('data-theme'); + } else { + root.setAttribute('data-theme', theme); + } +} + +export function ThemeProvider({ children }) { + const [theme, setThemeState] = useState(() => localStorage.getItem(THEME_KEY) || 'system'); + + useEffect(() => { + applyTheme(theme); + }, [theme]); + + const setTheme = useCallback((t) => { + localStorage.setItem(THEME_KEY, t); + setThemeState(t); + }, []); + + return {children}; +} + +export function useTheme() { + const ctx = useContext(ThemeContext); + if (!ctx) throw new Error('useTheme must be used within ThemeProvider'); + return ctx; +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..fc78e46 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,57 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { VitePWA } from 'vite-plugin-pwa'; + +export default defineConfig({ + plugins: [ + react(), + VitePWA({ + registerType: 'autoUpdate', + includeAssets: ['icons/icon-192.png', 'icons/icon-512.png'], + manifest: { + name: 'KtoCo - Wydatki wspólne', + short_name: 'KtoCo', + description: 'Dzielenie wydatków dla par i współlokatorów', + theme_color: '#4f46e5', + background_color: '#ffffff', + display: 'standalone', + start_url: '/', + icons: [ + { src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png' }, + { src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png' }, + { src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ], + }, + workbox: { + navigateFallbackDenylist: [/^\/api\//], + runtimeCaching: [ + { + urlPattern: ({ url }) => url.pathname.startsWith('/api/') && url.pathname !== '/api/expenses', + handler: 'NetworkFirst', + method: 'GET', + options: { + cacheName: 'api-get-cache', + networkTimeoutSeconds: 4, + expiration: { maxEntries: 100, maxAgeSeconds: 7 * 24 * 60 * 60 }, + }, + }, + { + urlPattern: ({ url }) => url.pathname === '/api/expenses', + handler: 'NetworkFirst', + method: 'GET', + options: { cacheName: 'api-expenses-cache', networkTimeoutSeconds: 4 }, + }, + ], + }, + }), + ], + server: { + proxy: { + '/api': { + target: 'http://localhost:3000', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ''), + }, + }, + }, +});