v1.0.0
This commit is contained in:
23
.env.example
Normal file
23
.env.example
Normal file
@@ -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=
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
sqlite/
|
||||
.env
|
||||
149
README.md
Normal file
149
README.md
Normal file
@@ -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: <id aktywnego gospodarstwa>` 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 <token>`. Endpointy gospodarstwa/kategorii/wydatków/rozliczeń/statystyk dodatkowo wymagają `X-Household-Id: <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
|
||||
```
|
||||
3
backend/.dockerignore
Normal file
3
backend/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
data
|
||||
.git
|
||||
18
backend/Dockerfile
Normal file
18
backend/Dockerfile
Normal file
@@ -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"]
|
||||
20
backend/package.json
Normal file
20
backend/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
25
backend/src/db/db.js
Normal file
25
backend/src/db/db.js
Normal file
@@ -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 };
|
||||
82
backend/src/db/schema.sql
Normal file
82
backend/src/db/schema.sql
Normal file
@@ -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);
|
||||
31
backend/src/index.js
Normal file
31
backend/src/index.js
Normal file
@@ -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}`));
|
||||
36
backend/src/middleware/auth.js
Normal file
36
backend/src/middleware/auth.js
Normal file
@@ -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 };
|
||||
206
backend/src/routes/auth.js
Normal file
206
backend/src/routes/auth.js
Normal file
@@ -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 };
|
||||
66
backend/src/routes/categories.js
Normal file
66
backend/src/routes/categories.js
Normal file
@@ -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 };
|
||||
214
backend/src/routes/expenses.js
Normal file
214
backend/src/routes/expenses.js
Normal file
@@ -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 };
|
||||
154
backend/src/routes/households.js
Normal file
154
backend/src/routes/households.js
Normal file
@@ -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 };
|
||||
38
backend/src/routes/settlements.js
Normal file
38
backend/src/routes/settlements.js
Normal file
@@ -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 };
|
||||
92
backend/src/routes/stats.js
Normal file
92
backend/src/routes/stats.js
Normal file
@@ -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 };
|
||||
80
backend/src/utils/balance.js
Normal file
80
backend/src/utils/balance.js
Normal file
@@ -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 };
|
||||
51
backend/src/utils/households.js
Normal file
51
backend/src/utils/households.js
Normal file
@@ -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 };
|
||||
26
backend/src/utils/mailer.js
Normal file
26
backend/src/utils/mailer.js
Normal file
@@ -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 };
|
||||
43
docker-compose.yaml
Normal file
43
docker-compose.yaml
Normal file
@@ -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}
|
||||
3
frontend/.dockerignore
Normal file
3
frontend/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
11
frontend/Dockerfile
Normal file
11
frontend/Dockerfile
Normal file
@@ -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
|
||||
28
frontend/index.html
Normal file
28
frontend/index.html
Normal file
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#4f46e5" />
|
||||
<title>KtoCo - Wydatki wspólne</title>
|
||||
<link rel="icon" href="/icons/icon-192.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<script>
|
||||
(function () {
|
||||
var t = localStorage.getItem('ktoco_theme');
|
||||
if (t === 'light' || t === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
31
frontend/nginx.conf
Normal file
31
frontend/nginx.conf
Normal file
@@ -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";
|
||||
}
|
||||
}
|
||||
24
frontend/package.json
Normal file
24
frontend/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/icons/icon-192.png
Normal file
BIN
frontend/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
BIN
frontend/public/icons/icon-512.png
Normal file
BIN
frontend/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
68
frontend/src/App.jsx
Normal file
68
frontend/src/App.jsx
Normal file
@@ -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 <Navigate to="/login" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
function RequireHousehold() {
|
||||
const { households, householdsLoading } = useHouseholdContext();
|
||||
if (householdsLoading) return <div className="page-loading">Ładowanie…</div>;
|
||||
if (households.length === 0) return <Navigate to="/onboarding" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
function Layout() {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<OfflineBanner />
|
||||
<InstallBanner />
|
||||
<main className="app-content">
|
||||
<Outlet />
|
||||
</main>
|
||||
<BottomNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
<Route path="/join/:code" element={<JoinInvite />} />
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route path="/onboarding" element={<Onboarding />} />
|
||||
<Route element={<RequireHousehold />}>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/add" element={<AddExpense />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/stats" element={<Stats />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
73
frontend/src/api/client.js
Normal file
73
frontend/src/api/client.js
Normal file
@@ -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' }),
|
||||
};
|
||||
229
frontend/src/api/queries.js
Normal file
229
frontend/src/api/queries.js
Normal file
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
74
frontend/src/auth/AuthContext.jsx
Normal file
74
frontend/src/auth/AuthContext.jsx
Normal file
@@ -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 (
|
||||
<AuthContext.Provider value={{ user, ready, login, register, logout, isAuthenticated: !!user }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
34
frontend/src/components/BottomNav.jsx
Normal file
34
frontend/src/components/BottomNav.jsx
Normal file
@@ -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 (
|
||||
<nav className="bottom-nav">
|
||||
{items.map((item) => (
|
||||
<NavLink key={item.to} to={item.to} end={item.end} className="nav-item">
|
||||
<Icon name={item.icon} className="nav-icon" />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
<NavLink to="/add" className="fab" aria-label="Dodaj wydatek">
|
||||
<Icon name="add" />
|
||||
</NavLink>
|
||||
{rightItems.map((item) => (
|
||||
<NavLink key={item.to} to={item.to} className="nav-item">
|
||||
<Icon name={item.icon} className="nav-icon" />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
25
frontend/src/components/CategoryPieChart.jsx
Normal file
25
frontend/src/components/CategoryPieChart.jsx
Normal file
@@ -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 <p className="empty-state">Brak wydatków w tym miesiącu</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<PieChart>
|
||||
<Pie data={chartData} dataKey="value" nameKey="name" innerRadius={55} outerRadius={85} paddingAngle={2}>
|
||||
{chartData.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(value) => `${value.toFixed(2)} ${currency}`} />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
57
frontend/src/components/ConfirmDialogProvider.jsx
Normal file
57
frontend/src/components/ConfirmDialogProvider.jsx
Normal file
@@ -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 (
|
||||
<ConfirmContext.Provider value={confirmAction}>
|
||||
{children}
|
||||
{state && (
|
||||
<div className="modal-overlay" onClick={() => handle(false)}>
|
||||
<div className="modal-sheet confirm-sheet" onClick={(e) => e.stopPropagation()}>
|
||||
<div className={`confirm-icon ${state.danger ? 'danger' : ''}`}>
|
||||
<Icon name={state.danger ? 'warning' : 'help'} />
|
||||
</div>
|
||||
<h3>{state.title}</h3>
|
||||
{state.message && <p className="confirm-message">{state.message}</p>}
|
||||
<div className="modal-actions">
|
||||
<button className="btn-secondary" onClick={() => handle(false)}>
|
||||
{state.cancelLabel}
|
||||
</button>
|
||||
<button className={state.danger ? 'btn-danger' : 'btn-primary'} onClick={() => handle(true)}>
|
||||
{state.confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ConfirmContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useConfirm() {
|
||||
const ctx = useContext(ConfirmContext);
|
||||
if (!ctx) throw new Error('useConfirm must be used within ConfirmProvider');
|
||||
return ctx;
|
||||
}
|
||||
28
frontend/src/components/ErrorBoundary.jsx
Normal file
28
frontend/src/components/ErrorBoundary.jsx
Normal file
@@ -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 (
|
||||
<div className="auth-page">
|
||||
<h1>Coś poszło nie tak</h1>
|
||||
<p className="auth-subtitle">Wystąpił nieoczekiwany błąd. Spróbuj odświeżyć stronę.</p>
|
||||
<button className="btn-primary" onClick={() => window.location.reload()}>
|
||||
Odśwież
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
142
frontend/src/components/ExpenseEditModal.jsx
Normal file
142
frontend/src/components/ExpenseEditModal.jsx
Normal file
@@ -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 (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-sheet" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>Edytuj wydatek</h3>
|
||||
<button className="icon-btn" onClick={onClose}><Icon name="close" /></button>
|
||||
</div>
|
||||
|
||||
<div className="expense-form">
|
||||
<input
|
||||
className="amount-input"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="field">
|
||||
<label>Tytuł</label>
|
||||
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>Kategoria</label>
|
||||
<div className="category-grid">
|
||||
{(categories || []).map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`category-chip ${categoryId === c.id ? 'selected' : ''}`}
|
||||
onClick={() => setCategoryId(c.id)}
|
||||
>
|
||||
<Icon name={c.icon} className="icon" />
|
||||
<span>{c.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>Kto płacił?</label>
|
||||
<PayerToggle members={members} payerId={payerId} onChange={setPayerId} />
|
||||
</div>
|
||||
|
||||
<SplitSelector
|
||||
members={members}
|
||||
amount={amount}
|
||||
splitType={splitType}
|
||||
onSplitTypeChange={setSplitType}
|
||||
exactShares={exactShares}
|
||||
onExactSharesChange={setExactShares}
|
||||
fullOwedBy={fullOwedBy}
|
||||
onFullOwedByChange={setFullOwedBy}
|
||||
/>
|
||||
|
||||
<div className="field">
|
||||
<label>Data</label>
|
||||
<input type="date" value={expenseDate} onChange={(e) => setExpenseDate(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="btn-danger" onClick={handleDelete} disabled={deleteExpense.isPending}>
|
||||
<Icon name="delete" /> Usuń
|
||||
</button>
|
||||
<button className="btn-primary" onClick={handleSave} disabled={updateExpense.isPending}>
|
||||
Zapisz
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
frontend/src/components/ExpenseListItem.jsx
Normal file
26
frontend/src/components/ExpenseListItem.jsx
Normal file
@@ -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 (
|
||||
<div className="expense-item" onClick={onClick}>
|
||||
<div className="cat-icon" style={{ background: (category?.color || '#6b7280') + '22' }}>
|
||||
<Icon name={category?.icon || 'inventory_2'} />
|
||||
</div>
|
||||
<div className="details">
|
||||
<div className="title">{expense.title}</div>
|
||||
<div className="meta">
|
||||
<span>{expense.expense_date}</span>
|
||||
<span>·</span>
|
||||
<Icon name="account_circle" />
|
||||
<span>{payer?.name || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="amount-col">
|
||||
<div className="amount">{expense.amount.toFixed(2)} {currency}</div>
|
||||
<div className="share">Twoja część: {myShare.toFixed(2)} {currency}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
frontend/src/components/Icon.jsx
Normal file
7
frontend/src/components/Icon.jsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function Icon({ name, className = '', style }) {
|
||||
return (
|
||||
<span className={`material-symbols-outlined ${className}`} style={style} aria-hidden="true">
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
41
frontend/src/components/InstallBanner.jsx
Normal file
41
frontend/src/components/InstallBanner.jsx
Normal file
@@ -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 (
|
||||
<div className="install-banner">
|
||||
<Icon name="install_mobile" />
|
||||
<div className="install-banner-text">
|
||||
{canInstall ? (
|
||||
<span>Zainstaluj KtoCo jako aplikację na telefonie</span>
|
||||
) : (
|
||||
<span>
|
||||
Dodaj KtoCo do ekranu głównego: dotknij <Icon name="ios_share" className="inline-icon" /> Udostępnij, a
|
||||
potem „Dodaj do ekranu początkowego”
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{canInstall && (
|
||||
<button className="btn-primary install-banner-btn" onClick={promptInstall}>
|
||||
Zainstaluj
|
||||
</button>
|
||||
)}
|
||||
<button className="icon-btn" onClick={handleDismiss} aria-label="Zamknij">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
frontend/src/components/MonthlyBarChart.jsx
Normal file
19
frontend/src/components/MonthlyBarChart.jsx
Normal file
@@ -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 <p className="empty-state">Brak danych historycznych</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="month" fontSize={12} />
|
||||
<YAxis fontSize={12} />
|
||||
<Tooltip formatter={(value) => `${Number(value).toFixed(2)} ${currency}`} />
|
||||
<Bar dataKey="total" fill="#4f46e5" radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
14
frontend/src/components/OfflineBanner.jsx
Normal file
14
frontend/src/components/OfflineBanner.jsx
Normal file
@@ -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 (
|
||||
<div className={`offline-banner ${isOnline ? 'offline-banner--syncing' : ''}`}>
|
||||
{!isOnline && <span>Brak połączenia — wydatki zapisują się lokalnie</span>}
|
||||
{isOnline && pendingCount > 0 && <span>Synchronizowanie {pendingCount} wydatków…</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
frontend/src/components/PasswordField.jsx
Normal file
26
frontend/src/components/PasswordField.jsx
Normal file
@@ -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 (
|
||||
<div className="password-field">
|
||||
<input
|
||||
type={visible ? 'text' : 'password'}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="password-toggle"
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
aria-label={visible ? 'Ukryj hasło' : 'Pokaż hasło'}
|
||||
>
|
||||
<Icon name={visible ? 'visibility_off' : 'visibility'} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
frontend/src/components/PayerComparisonChart.jsx
Normal file
30
frontend/src/components/PayerComparisonChart.jsx
Normal file
@@ -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 <p className="empty-state">Brak wydatków w tym miesiącu</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={Math.max(120, data.length * 44)}>
|
||||
<BarChart data={data} layout="vertical" margin={{ left: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
|
||||
<XAxis type="number" fontSize={12} />
|
||||
<YAxis type="category" dataKey="name" fontSize={13} width={90} />
|
||||
<Tooltip formatter={(value) => `${Number(value).toFixed(2)} ${currency}`} />
|
||||
<Bar dataKey="value" radius={[0, 6, 6, 0]}>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
18
frontend/src/components/PayerToggle.jsx
Normal file
18
frontend/src/components/PayerToggle.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import Icon from './Icon.jsx';
|
||||
|
||||
export default function PayerToggle({ members, payerId, onChange }) {
|
||||
return (
|
||||
<div className="toggle-row">
|
||||
{members.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
className={`toggle-btn ${payerId === m.id ? 'selected' : ''}`}
|
||||
onClick={() => onChange(m.id)}
|
||||
>
|
||||
<Icon name="account_circle" /> {m.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
frontend/src/components/SplitSelector.jsx
Normal file
69
frontend/src/components/SplitSelector.jsx
Normal file
@@ -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 (
|
||||
<div className="field">
|
||||
<label>Jak dzielimy wydatek?</label>
|
||||
<div className="split-options">
|
||||
{OPTIONS.map((opt) => (
|
||||
<div
|
||||
key={opt.value}
|
||||
className={`split-option ${splitType === opt.value ? 'selected' : ''}`}
|
||||
onClick={() => onSplitTypeChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
|
||||
{opt.value === 'exact' && splitType === 'exact' && (
|
||||
<div className="exact-shares" onClick={(e) => e.stopPropagation()}>
|
||||
{members.map((m) => (
|
||||
<div className="field" key={m.id}>
|
||||
<label>{m.name}</label>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
value={exactShares[m.id] ?? ''}
|
||||
onChange={(e) => onExactSharesChange({ ...exactShares, [m.id]: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{!exactValid && amount && <p className="form-error">Suma udziałów musi wynosić {amount}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{opt.value === 'full' && splitType === 'full' && (
|
||||
<div className="exact-shares" onClick={(e) => e.stopPropagation()}>
|
||||
{members.map((m) => (
|
||||
<button
|
||||
type="button"
|
||||
key={m.id}
|
||||
className={`toggle-btn ${fullOwedBy === m.id ? 'selected' : ''}`}
|
||||
onClick={() => onFullOwedByChange(m.id)}
|
||||
>
|
||||
{m.name} winien(-na) całość
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
frontend/src/components/Switch.jsx
Normal file
8
frontend/src/components/Switch.jsx
Normal file
@@ -0,0 +1,8 @@
|
||||
export default function Switch({ checked, onChange, disabled }) {
|
||||
return (
|
||||
<label className="switch">
|
||||
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} disabled={disabled} />
|
||||
<span className="switch-track" />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
68
frontend/src/household/HouseholdContext.jsx
Normal file
68
frontend/src/household/HouseholdContext.jsx
Normal file
@@ -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 (
|
||||
<HouseholdContext.Provider
|
||||
value={{
|
||||
households,
|
||||
householdsLoading: householdsQuery.isLoading,
|
||||
activeHouseholdId,
|
||||
activeHousehold,
|
||||
switchHousehold,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</HouseholdContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useHouseholdContext() {
|
||||
const ctx = useContext(HouseholdContext);
|
||||
if (!ctx) throw new Error('useHouseholdContext must be used within HouseholdProvider');
|
||||
return ctx;
|
||||
}
|
||||
37
frontend/src/main.jsx
Normal file
37
frontend/src/main.jsx
Normal file
@@ -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(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<HouseholdProvider>
|
||||
<ConfirmProvider>
|
||||
<App />
|
||||
</ConfirmProvider>
|
||||
</HouseholdProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
31
frontend/src/offline/db.js
Normal file
31
frontend/src/offline/db.js
Normal file
@@ -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);
|
||||
}
|
||||
24
frontend/src/offline/syncQueue.js
Normal file
24
frontend/src/offline/syncQueue.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
43
frontend/src/offline/useOnlineSync.js
Normal file
43
frontend/src/offline/useOnlineSync.js
Normal file
@@ -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 };
|
||||
}
|
||||
154
frontend/src/pages/AddExpense.jsx
Normal file
154
frontend/src/pages/AddExpense.jsx
Normal file
@@ -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 (
|
||||
<div>
|
||||
<h1 className="page-title">Dodaj wydatek</h1>
|
||||
<form onSubmit={handleSubmit} className="expense-form">
|
||||
<input
|
||||
className="amount-input"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div className="field">
|
||||
<label>Tytuł / opis</label>
|
||||
<input type="text" placeholder="np. Zakupy Biedronka" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>Kategoria</label>
|
||||
<div className="category-grid">
|
||||
{(categories || []).map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`category-chip ${categoryId === c.id ? 'selected' : ''}`}
|
||||
onClick={() => setCategoryId(c.id)}
|
||||
>
|
||||
<Icon name={c.icon} className="icon" />
|
||||
<span>{c.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>Kto płacił?</label>
|
||||
<PayerToggle members={members} payerId={payerId} onChange={setPayerId} />
|
||||
</div>
|
||||
|
||||
<SplitSelector
|
||||
members={members}
|
||||
amount={amount}
|
||||
splitType={splitType}
|
||||
onSplitTypeChange={setSplitType}
|
||||
exactShares={exactShares}
|
||||
onExactSharesChange={setExactShares}
|
||||
fullOwedBy={fullOwedBy}
|
||||
onFullOwedByChange={setFullOwedBy}
|
||||
/>
|
||||
|
||||
<div className="field">
|
||||
<label>Data</label>
|
||||
<input type="date" value={expenseDate} onChange={(e) => setExpenseDate(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
{notice && <p className="form-error" style={{ color: '#1e40af' }}>{notice}</p>}
|
||||
|
||||
<button type="submit" className="btn-primary" disabled={createExpense.isPending}>
|
||||
{createExpense.isPending ? 'Zapisywanie…' : 'Dodaj wydatek'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
frontend/src/pages/Dashboard.jsx
Normal file
76
frontend/src/pages/Dashboard.jsx
Normal file
@@ -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 (
|
||||
<div>
|
||||
<h1 className="page-title">Dashboard</h1>
|
||||
|
||||
<div className="card settlement-tile">
|
||||
{!balance ? (
|
||||
<p>Ładowanie…</p>
|
||||
) : balance.settled ? (
|
||||
<>
|
||||
<p>Rozliczenia</p>
|
||||
<p className="amount settled">
|
||||
Jesteście na czysto! <Icon name="celebration" />
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>Rozliczenia</p>
|
||||
<div className="settlement-transactions">
|
||||
{balance.transactions.map((tx, i) => (
|
||||
<p key={i} className="amount owed">
|
||||
{memberName(members, tx.from)} → {memberName(members, tx.to)}: {tx.amount.toFixed(2)} {currency}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn-primary" disabled={settleUp.isPending} onClick={() => settleUp.mutate()}>
|
||||
{settleUp.isPending ? 'Rozliczanie…' : 'Rozlicz się'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Wydatki wg kategorii (ten miesiąc)</h3>
|
||||
{!summary ? <p>Ładowanie…</p> : <CategoryPieChart data={summary.byCategory} currency={currency} />}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Podsumowanie miesiąca</h3>
|
||||
{summary && (
|
||||
<div className="summary-grid">
|
||||
<div>
|
||||
<div className="label">Suma</div>
|
||||
<div className="value">{summary.total.toFixed(2)} {currency}</div>
|
||||
</div>
|
||||
{members.map((m) => (
|
||||
<div key={m.id}>
|
||||
<div className="label">{m.name}</div>
|
||||
<div className="value">
|
||||
{(summary.byPayer.find((p) => p.userId === m.id)?.total || 0).toFixed(2)} {currency}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
frontend/src/pages/ForgotPassword.jsx
Normal file
50
frontend/src/pages/ForgotPassword.jsx
Normal file
@@ -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 (
|
||||
<div className="auth-page">
|
||||
<h1>Przypomnij hasło</h1>
|
||||
{sent ? (
|
||||
<p>Jeśli konto z tym adresem e-mail istnieje, wysłaliśmy wiadomość z linkiem do resetu hasła.</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="auth-subtitle">Podaj e-mail, na który wyślemy link do zresetowania hasła</p>
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="E-mail"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<button type="submit" className="btn-primary" disabled={forgotPassword.isPending}>
|
||||
{forgotPassword.isPending ? 'Wysyłanie…' : 'Wyślij link'}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
<p>
|
||||
<Link to="/login">Powrót do logowania</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
frontend/src/pages/History.jsx
Normal file
92
frontend/src/pages/History.jsx
Normal file
@@ -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 (
|
||||
<div>
|
||||
<h1 className="page-title">Historia</h1>
|
||||
|
||||
<div className="filters">
|
||||
<select value={month} onChange={(e) => setMonth(e.target.value)}>
|
||||
<option value="">Wszystkie miesiące</option>
|
||||
{monthOptions().map((m) => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={categoryId} onChange={(e) => setCategoryId(e.target.value)}>
|
||||
<option value="">Wszystkie kategorie</option>
|
||||
{(categories || []).map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.icon} {c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={payerId} onChange={(e) => setPayerId(e.target.value)}>
|
||||
<option value="">Wszyscy płacący</option>
|
||||
{members.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{isLoading && <p>Ładowanie…</p>}
|
||||
{!isLoading && (expenses || []).length === 0 && <p className="empty-state">Brak wydatków spełniających filtry</p>}
|
||||
|
||||
{(expenses || []).map((expense) => (
|
||||
<ExpenseListItem
|
||||
key={expense.id}
|
||||
expense={expense}
|
||||
category={categoryById[expense.category_id]}
|
||||
payer={memberById[expense.payer_id]}
|
||||
currentUserId={user?.id}
|
||||
currency={currency}
|
||||
onClick={() => setEditing(expense)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{editing && (
|
||||
<ExpenseEditModal
|
||||
expense={editing}
|
||||
members={members}
|
||||
categories={categories}
|
||||
currency={currency}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
frontend/src/pages/JoinInvite.jsx
Normal file
48
frontend/src/pages/JoinInvite.jsx
Normal file
@@ -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 <Navigate to={`/register?code=${encodeURIComponent(code)}`} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<h1>Dołączanie do gospodarstwa</h1>
|
||||
{error ? (
|
||||
<>
|
||||
<p className="form-error">{error}</p>
|
||||
<button className="btn-primary" onClick={() => navigate('/', { replace: true })}>
|
||||
Przejdź do aplikacji
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className="auth-subtitle">Chwileczkę…</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
frontend/src/pages/Login.jsx
Normal file
65
frontend/src/pages/Login.jsx
Normal file
@@ -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 (
|
||||
<div className="auth-page">
|
||||
<h1>KtoCo</h1>
|
||||
<p className="auth-subtitle">
|
||||
{inviteCode ? 'Zaloguj się, aby dołączyć do zaproszonego gospodarstwa domowego' : 'Zaloguj się, by zarządzać wspólnymi wydatkami'}
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<input type="email" placeholder="E-mail" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
<PasswordField placeholder="Hasło" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<button type="submit" className="btn-primary" disabled={loading}>
|
||||
{loading ? 'Logowanie…' : 'Zaloguj się'}
|
||||
</button>
|
||||
</form>
|
||||
<p>
|
||||
<Link to="/forgot-password">Zapomniałeś hasła?</Link>
|
||||
</p>
|
||||
<p>
|
||||
Nie masz konta?{' '}
|
||||
<Link to={inviteCode ? `/register?code=${encodeURIComponent(inviteCode)}` : '/register'}>Zarejestruj się</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
frontend/src/pages/Onboarding.jsx
Normal file
90
frontend/src/pages/Onboarding.jsx
Normal file
@@ -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 (
|
||||
<div className="auth-page">
|
||||
<button type="button" className="icon-btn back-btn" onClick={() => navigate(-1)} aria-label="Cofnij">
|
||||
<Icon name="arrow_back" /> Cofnij
|
||||
</button>
|
||||
<h1>Witaj!</h1>
|
||||
<p className="auth-subtitle">Załóż nowe gospodarstwo domowe albo dołącz do partnera/partnerki kodem</p>
|
||||
|
||||
<div className="tabs">
|
||||
<button className={mode === 'create' ? 'tab active' : 'tab'} onClick={() => setMode('create')}>
|
||||
Utwórz
|
||||
</button>
|
||||
<button className={mode === 'join' ? 'tab active' : 'tab'} onClick={() => setMode('join')}>
|
||||
Dołącz kodem
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'create' ? (
|
||||
<form onSubmit={handleCreate} className="auth-form">
|
||||
<input type="text" placeholder="Nazwa gospodarstwa" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<select value={currency} onChange={(e) => setCurrency(e.target.value)}>
|
||||
<option value="PLN">PLN</option>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<button type="submit" className="btn-primary" disabled={createHousehold.isPending}>
|
||||
{createHousehold.isPending ? 'Tworzenie…' : 'Utwórz gospodarstwo'}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleJoin} className="auth-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Kod zaproszenia"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
required
|
||||
/>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<button type="submit" className="btn-primary" disabled={joinHousehold.isPending}>
|
||||
{joinHousehold.isPending ? 'Dołączanie…' : 'Dołącz'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
frontend/src/pages/Register.jsx
Normal file
68
frontend/src/pages/Register.jsx
Normal file
@@ -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 (
|
||||
<div className="auth-page">
|
||||
<h1>KtoCo</h1>
|
||||
<p className="auth-subtitle">
|
||||
{inviteCode
|
||||
? 'Załóż konto, aby dołączyć do zaproszonego gospodarstwa domowego'
|
||||
: 'Załóż konto, by zacząć dzielić wydatki'}
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<input type="text" placeholder="Imię" value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
<input type="email" placeholder="E-mail" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
<PasswordField
|
||||
placeholder="Hasło (min. 6 znaków)"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<button type="submit" className="btn-primary" disabled={loading}>
|
||||
{loading ? 'Tworzenie konta…' : 'Zarejestruj się'}
|
||||
</button>
|
||||
</form>
|
||||
<p>
|
||||
Masz już konto?{' '}
|
||||
<Link to={inviteCode ? `/login?code=${encodeURIComponent(inviteCode)}` : '/login'}>Zaloguj się</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
frontend/src/pages/ResetPassword.jsx
Normal file
60
frontend/src/pages/ResetPassword.jsx
Normal file
@@ -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 (
|
||||
<div className="auth-page">
|
||||
<h1>Nieprawidłowy link</h1>
|
||||
<p>Brakuje tokenu resetu hasła. Poproś o nowy link.</p>
|
||||
<p><Link to="/forgot-password">Przypomnij hasło</Link></p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<h1>Ustaw nowe hasło</h1>
|
||||
{done ? (
|
||||
<p>Hasło zostało zmienione. Przenoszenie do logowania…</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<PasswordField placeholder="Nowe hasło (min. 6 znaków)" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
<PasswordField placeholder="Powtórz nowe hasło" value={confirm} onChange={(e) => setConfirm(e.target.value)} />
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<button type="submit" className="btn-primary" disabled={resetPassword.isPending}>
|
||||
{resetPassword.isPending ? 'Zapisywanie…' : 'Ustaw nowe hasło'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
541
frontend/src/pages/Settings.jsx
Normal file
541
frontend/src/pages/Settings.jsx
Normal file
@@ -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 (
|
||||
<div>
|
||||
<div className="invite-code-row">
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={big ? 'invite-code-input' : 'invite-link-input'}
|
||||
readOnly
|
||||
value={value || ''}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
<button type="button" className="invite-code-copy-btn" onClick={handleCopy} aria-label="Kopiuj">
|
||||
<Icon name={status === 'copied' ? 'check' : 'content_copy'} />
|
||||
</button>
|
||||
</div>
|
||||
{status === 'copied' && <p className="invite-code-hint success">Skopiowano do schowka!</p>}
|
||||
{status === 'failed' && (
|
||||
<p className="invite-code-hint error">Nie udało się skopiować automatycznie — zaznacz pole powyżej i skopiuj ręcznie.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteCode({ code }) {
|
||||
const inviteLink = code ? `${window.location.origin}/join/${code}` : '';
|
||||
return (
|
||||
<div>
|
||||
<CopyField value={code} big />
|
||||
<p className="switch-desc" style={{ marginTop: 12 }}>
|
||||
Albo wyślij link, który od razu przeniesie do rejestracji i dołączy do gospodarstwa:
|
||||
</p>
|
||||
<CopyField value={inviteLink} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstallSection() {
|
||||
const { canInstall, isInstalled, isIOS, isSecureContext, promptInstall } = useInstallPrompt();
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3>Aplikacja</h3>
|
||||
{isInstalled && (
|
||||
<p className="switch-desc">
|
||||
<Icon name="check_circle" /> Aplikacja jest zainstalowana na tym urządzeniu
|
||||
</p>
|
||||
)}
|
||||
{!isInstalled && canInstall && (
|
||||
<button className="btn-primary btn-full" onClick={promptInstall}>
|
||||
<Icon name="install_mobile" /> Zainstaluj aplikację
|
||||
</button>
|
||||
)}
|
||||
{!isInstalled && isIOS && (
|
||||
<p className="switch-desc">
|
||||
Dotknij <Icon name="ios_share" className="inline-icon" /> Udostępnij, a potem „Dodaj do ekranu
|
||||
początkowego”.
|
||||
</p>
|
||||
)}
|
||||
{!isInstalled && !canInstall && !isIOS && !isSecureContext && (
|
||||
<p className="switch-desc">
|
||||
Instalacja wymaga bezpiecznego połączenia — otwórz aplikację pod adresem zaczynającym się od{' '}
|
||||
<strong>https://</strong>, a nie przez lokalny adres IP.
|
||||
</p>
|
||||
)}
|
||||
{!isInstalled && !canInstall && !isIOS && isSecureContext && (
|
||||
<p className="switch-desc">
|
||||
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”.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="card">
|
||||
<h3>Konto</h3>
|
||||
<div className="field">
|
||||
<label>Imię</label>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={name || me.name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={handleNameBlur}
|
||||
/>
|
||||
{nameSaved && <p className="invite-code-hint success">Zapisano</p>}
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>E-mail</label>
|
||||
<input type="email" value={me.email} readOnly disabled />
|
||||
</div>
|
||||
<button
|
||||
className="btn-danger btn-full"
|
||||
style={{ marginTop: 16 }}
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={deleteAccount.isPending}
|
||||
>
|
||||
<Icon name="delete_forever" /> Usuń konto
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="card">
|
||||
<h3>Twoje gospodarstwa</h3>
|
||||
{households.map((h) => (
|
||||
<div className="household-row" key={h.id}>
|
||||
<div className="household-row-main">
|
||||
<div>{h.name}</div>
|
||||
<div className="switch-desc">{h.members.length} {h.members.length === 1 ? 'osoba' : 'osoby/osób'}</div>
|
||||
</div>
|
||||
{h.id === activeHouseholdId ? (
|
||||
<span className="badge-active">Aktywne</span>
|
||||
) : (
|
||||
<button className="btn-secondary" onClick={() => switchHousehold(h.id)}>
|
||||
Przełącz
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Link to="/onboarding" className="btn-secondary btn-full" style={{ marginTop: 12 }}>
|
||||
<Icon name="add" /> Utwórz lub dołącz do gospodarstwa
|
||||
</Link>
|
||||
|
||||
{activeHousehold && (
|
||||
<>
|
||||
<hr className="section-divider" />
|
||||
<div className="field">
|
||||
<label>Nazwa aktywnego gospodarstwa</label>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={activeHousehold.name}
|
||||
onBlur={(e) =>
|
||||
e.target.value !== activeHousehold.name &&
|
||||
updateHousehold.mutate({ id: activeHousehold.id, name: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Waluta</label>
|
||||
<select
|
||||
value={activeHousehold.currency}
|
||||
onChange={(e) => updateHousehold.mutate({ id: activeHousehold.id, currency: e.target.value })}
|
||||
>
|
||||
{CURRENCIES.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<h4>Członkowie</h4>
|
||||
{activeHousehold.members.map((m) => (
|
||||
<div className="member-row" key={m.id}>
|
||||
<Icon name="account_circle" style={{ fontSize: '28px' }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div>{m.name} {m.id === user?.id && '(Ty)'}</div>
|
||||
<div className="meta" style={{ color: 'var(--muted)', fontSize: '0.8rem' }}>{m.email}</div>
|
||||
</div>
|
||||
<button className="icon-btn" onClick={() => handleRemoveMember(m)} aria-label="Usuń">
|
||||
<Icon name={m.id === user?.id ? 'logout' : 'person_remove'} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<p>Zaproś kolejną osobę tym kodem:</p>
|
||||
<InviteCode code={activeHousehold.inviteCode} />
|
||||
<button className="btn-secondary btn-full" onClick={() => regenerateInvite.mutate(activeHousehold.id)}>
|
||||
Wygeneruj nowy kod
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn-danger btn-full"
|
||||
style={{ marginTop: 16 }}
|
||||
onClick={handleDeleteHousehold}
|
||||
disabled={deleteHousehold.isPending}
|
||||
>
|
||||
<Icon name="delete_forever" /> Usuń gospodarstwo
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<h1 className="page-title">Ustawienia</h1>
|
||||
|
||||
<InstallSection />
|
||||
|
||||
<AccountSection />
|
||||
|
||||
<div className="card">
|
||||
<h3>Wygląd</h3>
|
||||
<div className="theme-toggle">
|
||||
{THEME_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={theme === opt.value ? 'tab active' : 'tab'}
|
||||
onClick={() => setTheme(opt.value)}
|
||||
>
|
||||
<Icon name={opt.icon} />
|
||||
<span>{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Powiadomienia</h3>
|
||||
<div className="switch-row">
|
||||
<div className="switch-label">
|
||||
<span>Powiadomienia mailowe</span>
|
||||
<span className="switch-desc">Dostaniesz e-mail, gdy ktoś w gospodarstwie doda nowy wydatek</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={!!me?.emailNotifications}
|
||||
disabled={updateNotifications.isPending}
|
||||
onChange={(checked) => updateNotifications.mutate(checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HouseholdsSection />
|
||||
|
||||
<div className="card">
|
||||
<h3>Bezpieczeństwo</h3>
|
||||
<form onSubmit={handlePasswordSubmit} className="expense-form">
|
||||
<div className="field">
|
||||
<label>Bieżące hasło</label>
|
||||
<PasswordField value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Nowe hasło</label>
|
||||
<PasswordField value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Powtórz nowe hasło</label>
|
||||
<PasswordField value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} />
|
||||
</div>
|
||||
{passwordError && <p className="form-error">{passwordError}</p>}
|
||||
{passwordSuccess && <p className="invite-code-hint success">Hasło zostało zmienione</p>}
|
||||
<button type="submit" className="btn-primary btn-full" disabled={changePassword.isPending}>
|
||||
{changePassword.isPending ? 'Zapisywanie…' : 'Zmień hasło'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{activeHouseholdId && (
|
||||
<div className="card">
|
||||
<h3>Kategorie</h3>
|
||||
{(categories || []).map((c) => (
|
||||
<div className="category-manage-row" key={c.id}>
|
||||
<button
|
||||
className={`category-manage-row-main ${editingCategoryId === c.id ? 'editing' : ''}`}
|
||||
onClick={() => startEditCategory(c)}
|
||||
>
|
||||
<Icon name={c.icon} className="icon" />
|
||||
<span className="name">{c.name}</span>
|
||||
</button>
|
||||
<button className="icon-btn" onClick={() => deleteCategory.mutate(c.id)}>
|
||||
<Icon name="delete" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<form onSubmit={handleCategorySubmit} style={{ marginTop: 16 }}>
|
||||
<div className="field">
|
||||
<label>Ikona</label>
|
||||
<div className="category-grid">
|
||||
{ICON_CHOICES.map((icon) => (
|
||||
<div
|
||||
key={icon}
|
||||
className={`category-chip ${catIcon === icon ? 'selected' : ''}`}
|
||||
onClick={() => setCatIcon(icon)}
|
||||
>
|
||||
<Icon name={icon} className="icon" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="field" style={{ marginTop: 12 }}>
|
||||
<label>Nazwa kategorii</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="np. Zwierzęta"
|
||||
value={catName}
|
||||
onChange={(e) => setCatName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-actions" style={{ marginTop: 12 }}>
|
||||
{editingCategoryId && (
|
||||
<button type="button" className="btn-secondary" onClick={cancelEditCategory}>
|
||||
Anuluj
|
||||
</button>
|
||||
)}
|
||||
<button type="submit" className="btn-primary">
|
||||
{editingCategoryId ? 'Zapisz zmiany' : 'Dodaj kategorię'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<h3>Dane</h3>
|
||||
<button className="btn-secondary btn-full" onClick={downloadCsv}>Pobierz CSV</button>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<button className="btn-secondary btn-full" onClick={logout}>Wyloguj się</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
frontend/src/pages/Stats.jsx
Normal file
50
frontend/src/pages/Stats.jsx
Normal file
@@ -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 (
|
||||
<div>
|
||||
<h1 className="page-title">Statystyki</h1>
|
||||
|
||||
<div className="card">
|
||||
<h3>Wydatki miesiąc do miesiąca</h3>
|
||||
{!monthly ? <p>Ładowanie…</p> : <MonthlyBarChart data={monthly} currency={currency} />}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Kto więcej konsumuje (ten miesiąc)</h3>
|
||||
{!summary ? (
|
||||
<p>Ładowanie…</p>
|
||||
) : (
|
||||
<PayerComparisonChart members={members} byShare={summary.byShare} currency={currency} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Kategorie od najdroższej</h3>
|
||||
{summary && (
|
||||
<div>
|
||||
{summary.byCategory.length === 0 && <p className="empty-state">Brak wydatków w tym miesiącu</p>}
|
||||
{summary.byCategory.map((c) => (
|
||||
<div key={c.categoryId || 'none'} className="category-manage-row">
|
||||
<Icon name={c.icon || 'inventory_2'} className="icon" />
|
||||
<span className="name">{c.name || 'Bez kategorii'}</span>
|
||||
<strong>{c.total.toFixed(2)} {currency}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
frontend/src/pwa/useInstallPrompt.js
Normal file
47
frontend/src/pwa/useInstallPrompt.js
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
585
frontend/src/styles.css
Normal file
585
frontend/src/styles.css
Normal file
@@ -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;
|
||||
}
|
||||
34
frontend/src/theme/ThemeContext.jsx
Normal file
34
frontend/src/theme/ThemeContext.jsx
Normal file
@@ -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 <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
|
||||
return ctx;
|
||||
}
|
||||
57
frontend/vite.config.js
Normal file
57
frontend/vite.config.js
Normal file
@@ -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/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user