This commit is contained in:
2026-07-10 22:57:16 +02:00
commit c4a692b5d2
66 changed files with 4666 additions and 0 deletions

23
.env.example Normal file
View 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
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
sqlite/
.env

149
README.md Normal file
View 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
View File

@@ -0,0 +1,3 @@
node_modules
data
.git

18
backend/Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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}`));

View 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
View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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
View 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
View File

@@ -0,0 +1,3 @@
node_modules
dist
.git

11
frontend/Dockerfile Normal file
View 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
View 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
View 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
View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

68
frontend/src/App.jsx Normal file
View 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>
);
}

View 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
View 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'] });
},
});
}

View 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;
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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;
}
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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>
);

View 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);
}

View 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;
}
}

View 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 };
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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;
}

View 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
View 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/, ''),
},
},
},
});