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

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