v1.0.0
This commit is contained in:
68
frontend/src/App.jsx
Normal file
68
frontend/src/App.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Routes, Route, Navigate, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from './auth/AuthContext.jsx';
|
||||
import { useHouseholdContext } from './household/HouseholdContext.jsx';
|
||||
import Login from './pages/Login.jsx';
|
||||
import Register from './pages/Register.jsx';
|
||||
import ForgotPassword from './pages/ForgotPassword.jsx';
|
||||
import ResetPassword from './pages/ResetPassword.jsx';
|
||||
import JoinInvite from './pages/JoinInvite.jsx';
|
||||
import Onboarding from './pages/Onboarding.jsx';
|
||||
import Dashboard from './pages/Dashboard.jsx';
|
||||
import AddExpense from './pages/AddExpense.jsx';
|
||||
import History from './pages/History.jsx';
|
||||
import Stats from './pages/Stats.jsx';
|
||||
import Settings from './pages/Settings.jsx';
|
||||
import BottomNav from './components/BottomNav.jsx';
|
||||
import OfflineBanner from './components/OfflineBanner.jsx';
|
||||
import InstallBanner from './components/InstallBanner.jsx';
|
||||
|
||||
function RequireAuth() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
function RequireHousehold() {
|
||||
const { households, householdsLoading } = useHouseholdContext();
|
||||
if (householdsLoading) return <div className="page-loading">Ładowanie…</div>;
|
||||
if (households.length === 0) return <Navigate to="/onboarding" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
function Layout() {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<OfflineBanner />
|
||||
<InstallBanner />
|
||||
<main className="app-content">
|
||||
<Outlet />
|
||||
</main>
|
||||
<BottomNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
<Route path="/join/:code" element={<JoinInvite />} />
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route path="/onboarding" element={<Onboarding />} />
|
||||
<Route element={<RequireHousehold />}>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/add" element={<AddExpense />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/stats" element={<Stats />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
73
frontend/src/api/client.js
Normal file
73
frontend/src/api/client.js
Normal file
@@ -0,0 +1,73 @@
|
||||
const TOKEN_KEY = 'ktoco_token';
|
||||
const ACTIVE_HOUSEHOLD_KEY = 'ktoco_active_household';
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setToken(token) {
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token);
|
||||
else localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
// Called when the server rejects our token (expired/invalid). Clears it and tells
|
||||
// AuthContext to drop its cached user, so the app redirects to /login instead of
|
||||
// misreading "no household" and bouncing to /onboarding.
|
||||
export function clearInvalidSession() {
|
||||
setToken(null);
|
||||
window.dispatchEvent(new Event('ktoco:auth-invalid'));
|
||||
}
|
||||
|
||||
export function getActiveHouseholdId() {
|
||||
return localStorage.getItem(ACTIVE_HOUSEHOLD_KEY);
|
||||
}
|
||||
|
||||
export function setActiveHouseholdId(id) {
|
||||
if (id) localStorage.setItem(ACTIVE_HOUSEHOLD_KEY, id);
|
||||
else localStorage.removeItem(ACTIVE_HOUSEHOLD_KEY);
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(message, status) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch(path, options = {}) {
|
||||
const token = getToken();
|
||||
const headers = { ...(options.headers || {}) };
|
||||
if (options.body && !(options.body instanceof FormData)) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
const activeHouseholdId = getActiveHouseholdId();
|
||||
if (activeHouseholdId) {
|
||||
headers['X-Household-Id'] = activeHouseholdId;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api${path}`, { ...options, headers });
|
||||
|
||||
if (res.status === 401 && token) {
|
||||
clearInvalidSession();
|
||||
}
|
||||
|
||||
if (res.status === 204) return null;
|
||||
|
||||
const isJson = res.headers.get('content-type')?.includes('application/json');
|
||||
const data = isJson ? await res.json() : await res.text();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(isJson ? data.error || 'Wystąpił błąd' : data, res.status);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: (path) => apiFetch(path, { method: 'GET' }),
|
||||
post: (path, body) => apiFetch(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
|
||||
put: (path, body) => apiFetch(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
|
||||
delete: (path) => apiFetch(path, { method: 'DELETE' }),
|
||||
};
|
||||
229
frontend/src/api/queries.js
Normal file
229
frontend/src/api/queries.js
Normal file
@@ -0,0 +1,229 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from './client.js';
|
||||
import { useHouseholdContext } from '../household/HouseholdContext.jsx';
|
||||
|
||||
export function useHouseholds() {
|
||||
return useQuery({
|
||||
queryKey: ['households'],
|
||||
queryFn: () => api.get('/households'),
|
||||
select: (data) => data.households,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateHousehold() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload) => api.post('/households', payload),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['households'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useJoinHousehold() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (code) => api.post('/households/join', { code }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['households'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateHousehold() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...payload }) => api.put(`/households/${id}`, payload),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['households'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRegenerateInvite() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (householdId) => api.post(`/households/${householdId}/invite`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['households'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveMember() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ householdId, userId }) => api.delete(`/households/${householdId}/members/${userId}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['households'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteHousehold() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (householdId) => api.delete(`/households/${householdId}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['households'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCategories() {
|
||||
const { activeHouseholdId } = useHouseholdContext();
|
||||
const query = useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: () => api.get('/categories'),
|
||||
select: (data) => data.categories,
|
||||
enabled: !!activeHouseholdId,
|
||||
});
|
||||
return { ...query, isLoading: !activeHouseholdId || query.isLoading };
|
||||
}
|
||||
|
||||
export function useCreateCategory() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload) => api.post('/categories', payload),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['categories'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCategory() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...payload }) => api.put(`/categories/${id}`, payload),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['categories'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCategory() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id) => api.delete(`/categories/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['categories'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useExpenses(filters = {}) {
|
||||
const { activeHouseholdId } = useHouseholdContext();
|
||||
const params = new URLSearchParams(filters);
|
||||
const qs = params.toString();
|
||||
const query = useQuery({
|
||||
queryKey: ['expenses', filters],
|
||||
queryFn: () => api.get(`/expenses${qs ? `?${qs}` : ''}`),
|
||||
select: (data) => data.expenses,
|
||||
enabled: !!activeHouseholdId,
|
||||
});
|
||||
return { ...query, isLoading: !activeHouseholdId || query.isLoading };
|
||||
}
|
||||
|
||||
export function useCreateExpense() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload) => api.post('/expenses', payload),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['expenses'] });
|
||||
qc.invalidateQueries({ queryKey: ['balance'] });
|
||||
qc.invalidateQueries({ queryKey: ['summary'] });
|
||||
qc.invalidateQueries({ queryKey: ['monthly'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateExpense() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...payload }) => api.put(`/expenses/${id}`, payload),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['expenses'] });
|
||||
qc.invalidateQueries({ queryKey: ['balance'] });
|
||||
qc.invalidateQueries({ queryKey: ['summary'] });
|
||||
qc.invalidateQueries({ queryKey: ['monthly'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteExpense() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id) => api.delete(`/expenses/${id}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['expenses'] });
|
||||
qc.invalidateQueries({ queryKey: ['balance'] });
|
||||
qc.invalidateQueries({ queryKey: ['summary'] });
|
||||
qc.invalidateQueries({ queryKey: ['monthly'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useBalance() {
|
||||
const { activeHouseholdId } = useHouseholdContext();
|
||||
const query = useQuery({
|
||||
queryKey: ['balance'],
|
||||
queryFn: () => api.get('/stats/balance'),
|
||||
enabled: !!activeHouseholdId,
|
||||
});
|
||||
return { ...query, isLoading: !activeHouseholdId || query.isLoading };
|
||||
}
|
||||
|
||||
export function useSummary(month) {
|
||||
const { activeHouseholdId } = useHouseholdContext();
|
||||
const query = useQuery({
|
||||
queryKey: ['summary', month],
|
||||
queryFn: () => api.get(`/stats/summary${month ? `?month=${month}` : ''}`),
|
||||
enabled: !!activeHouseholdId,
|
||||
});
|
||||
return { ...query, isLoading: !activeHouseholdId || query.isLoading };
|
||||
}
|
||||
|
||||
export function useMonthly() {
|
||||
const { activeHouseholdId } = useHouseholdContext();
|
||||
const query = useQuery({
|
||||
queryKey: ['monthly'],
|
||||
queryFn: () => api.get('/stats/monthly'),
|
||||
select: (d) => d.months,
|
||||
enabled: !!activeHouseholdId,
|
||||
});
|
||||
return { ...query, isLoading: !activeHouseholdId || query.isLoading };
|
||||
}
|
||||
|
||||
export function useMe() {
|
||||
return useQuery({ queryKey: ['me'], queryFn: () => api.get('/auth/me'), select: (d) => d.user });
|
||||
}
|
||||
|
||||
export function useUpdateProfile() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (name) => api.put('/auth/me', { name }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['me'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAccount() {
|
||||
return useMutation({ mutationFn: () => api.delete('/auth/me') });
|
||||
}
|
||||
|
||||
export function useUpdateNotifications() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (enabled) => api.put('/auth/me/notifications', { enabled }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['me'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useChangePassword() {
|
||||
return useMutation({
|
||||
mutationFn: ({ currentPassword, newPassword }) =>
|
||||
api.post('/auth/change-password', { currentPassword, newPassword }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useForgotPassword() {
|
||||
return useMutation({ mutationFn: (email) => api.post('/auth/forgot-password', { email }) });
|
||||
}
|
||||
|
||||
export function useResetPassword() {
|
||||
return useMutation({
|
||||
mutationFn: ({ token, password }) => api.post('/auth/reset-password', { token, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSettleUp() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => api.post('/settlements'),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['balance'] });
|
||||
qc.invalidateQueries({ queryKey: ['settlements'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
74
frontend/src/auth/AuthContext.jsx
Normal file
74
frontend/src/auth/AuthContext.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { api, getToken, setToken } from '../api/client.js';
|
||||
|
||||
const USER_KEY = 'ktoco_user';
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const qc = useQueryClient();
|
||||
const [user, setUser] = useState(() => {
|
||||
const raw = localStorage.getItem(USER_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
});
|
||||
const [ready, setReady] = useState(true);
|
||||
|
||||
const persistUser = useCallback((u) => {
|
||||
setUser(u);
|
||||
if (u) localStorage.setItem(USER_KEY, JSON.stringify(u));
|
||||
else localStorage.removeItem(USER_KEY);
|
||||
}, []);
|
||||
|
||||
const login = useCallback(
|
||||
async (email, password) => {
|
||||
const data = await api.post('/auth/login', { email, password });
|
||||
setToken(data.token);
|
||||
persistUser(data.user);
|
||||
return data.user;
|
||||
},
|
||||
[persistUser]
|
||||
);
|
||||
|
||||
const register = useCallback(
|
||||
async (email, password, name) => {
|
||||
const data = await api.post('/auth/register', { email, password, name });
|
||||
setToken(data.token);
|
||||
persistUser(data.user);
|
||||
return data.user;
|
||||
},
|
||||
[persistUser]
|
||||
);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
setToken(null);
|
||||
persistUser(null);
|
||||
qc.clear();
|
||||
}, [persistUser, qc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user && !getToken()) {
|
||||
persistUser(null);
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
function handleAuthInvalid() {
|
||||
persistUser(null);
|
||||
qc.clear();
|
||||
}
|
||||
window.addEventListener('ktoco:auth-invalid', handleAuthInvalid);
|
||||
return () => window.removeEventListener('ktoco:auth-invalid', handleAuthInvalid);
|
||||
}, [persistUser, qc]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, ready, login, register, logout, isAuthenticated: !!user }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
34
frontend/src/components/BottomNav.jsx
Normal file
34
frontend/src/components/BottomNav.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import Icon from './Icon.jsx';
|
||||
|
||||
const items = [
|
||||
{ to: '/', label: 'Start', icon: 'home', end: true },
|
||||
{ to: '/history', label: 'Historia', icon: 'receipt_long' },
|
||||
];
|
||||
|
||||
const rightItems = [
|
||||
{ to: '/stats', label: 'Statystyki', icon: 'bar_chart' },
|
||||
{ to: '/settings', label: 'Ustawienia', icon: 'settings' },
|
||||
];
|
||||
|
||||
export default function BottomNav() {
|
||||
return (
|
||||
<nav className="bottom-nav">
|
||||
{items.map((item) => (
|
||||
<NavLink key={item.to} to={item.to} end={item.end} className="nav-item">
|
||||
<Icon name={item.icon} className="nav-icon" />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
<NavLink to="/add" className="fab" aria-label="Dodaj wydatek">
|
||||
<Icon name="add" />
|
||||
</NavLink>
|
||||
{rightItems.map((item) => (
|
||||
<NavLink key={item.to} to={item.to} className="nav-item">
|
||||
<Icon name={item.icon} className="nav-icon" />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
25
frontend/src/components/CategoryPieChart.jsx
Normal file
25
frontend/src/components/CategoryPieChart.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
|
||||
export default function CategoryPieChart({ data, currency }) {
|
||||
const chartData = data
|
||||
.filter((d) => d.total > 0)
|
||||
.map((d) => ({ name: d.name || 'Bez kategorii', value: d.total, color: d.color || '#6b7280' }));
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return <p className="empty-state">Brak wydatków w tym miesiącu</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<PieChart>
|
||||
<Pie data={chartData} dataKey="value" nameKey="name" innerRadius={55} outerRadius={85} paddingAngle={2}>
|
||||
{chartData.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(value) => `${value.toFixed(2)} ${currency}`} />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
57
frontend/src/components/ConfirmDialogProvider.jsx
Normal file
57
frontend/src/components/ConfirmDialogProvider.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { createContext, useCallback, useContext, useState } from 'react';
|
||||
import Icon from './Icon.jsx';
|
||||
|
||||
const ConfirmContext = createContext(null);
|
||||
|
||||
export function ConfirmProvider({ children }) {
|
||||
const [state, setState] = useState(null);
|
||||
|
||||
const confirmAction = useCallback((options) => {
|
||||
return new Promise((resolve) => {
|
||||
setState({
|
||||
title: options.title || 'Czy na pewno?',
|
||||
message: options.message || '',
|
||||
confirmLabel: options.confirmLabel || 'Usuń',
|
||||
cancelLabel: options.cancelLabel || 'Anuluj',
|
||||
danger: options.danger !== false,
|
||||
resolve,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
function handle(result) {
|
||||
state?.resolve(result);
|
||||
setState(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={confirmAction}>
|
||||
{children}
|
||||
{state && (
|
||||
<div className="modal-overlay" onClick={() => handle(false)}>
|
||||
<div className="modal-sheet confirm-sheet" onClick={(e) => e.stopPropagation()}>
|
||||
<div className={`confirm-icon ${state.danger ? 'danger' : ''}`}>
|
||||
<Icon name={state.danger ? 'warning' : 'help'} />
|
||||
</div>
|
||||
<h3>{state.title}</h3>
|
||||
{state.message && <p className="confirm-message">{state.message}</p>}
|
||||
<div className="modal-actions">
|
||||
<button className="btn-secondary" onClick={() => handle(false)}>
|
||||
{state.cancelLabel}
|
||||
</button>
|
||||
<button className={state.danger ? 'btn-danger' : 'btn-primary'} onClick={() => handle(true)}>
|
||||
{state.confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ConfirmContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useConfirm() {
|
||||
const ctx = useContext(ConfirmContext);
|
||||
if (!ctx) throw new Error('useConfirm must be used within ConfirmProvider');
|
||||
return ctx;
|
||||
}
|
||||
28
frontend/src/components/ErrorBoundary.jsx
Normal file
28
frontend/src/components/ErrorBoundary.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Component } from 'react';
|
||||
|
||||
export default class ErrorBoundary extends Component {
|
||||
state = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error, info) {
|
||||
console.error('Nieobsłużony błąd renderowania:', error, info);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<h1>Coś poszło nie tak</h1>
|
||||
<p className="auth-subtitle">Wystąpił nieoczekiwany błąd. Spróbuj odświeżyć stronę.</p>
|
||||
<button className="btn-primary" onClick={() => window.location.reload()}>
|
||||
Odśwież
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
142
frontend/src/components/ExpenseEditModal.jsx
Normal file
142
frontend/src/components/ExpenseEditModal.jsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useState } from 'react';
|
||||
import { useUpdateExpense, useDeleteExpense } from '../api/queries.js';
|
||||
import { useConfirm } from './ConfirmDialogProvider.jsx';
|
||||
import PayerToggle from './PayerToggle.jsx';
|
||||
import SplitSelector from './SplitSelector.jsx';
|
||||
import Icon from './Icon.jsx';
|
||||
|
||||
export default function ExpenseEditModal({ expense, members, categories, currency, onClose }) {
|
||||
const updateExpense = useUpdateExpense();
|
||||
const deleteExpense = useDeleteExpense();
|
||||
const confirmDialog = useConfirm();
|
||||
|
||||
const [amount, setAmount] = useState(String(expense.amount));
|
||||
const [title, setTitle] = useState(expense.title);
|
||||
const [categoryId, setCategoryId] = useState(expense.category_id);
|
||||
const [payerId, setPayerId] = useState(expense.payer_id);
|
||||
const [expenseDate, setExpenseDate] = useState(expense.expense_date);
|
||||
const [splitType, setSplitType] = useState(expense.split_type);
|
||||
const [exactShares, setExactShares] = useState(
|
||||
Object.fromEntries(expense.shares.map((s) => [s.user_id, String(s.share_amount)]))
|
||||
);
|
||||
const [fullOwedBy, setFullOwedBy] = useState(
|
||||
expense.shares.find((s) => s.share_amount === expense.amount)?.user_id || null
|
||||
);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function buildShares() {
|
||||
if (splitType === 'exact') {
|
||||
return Object.fromEntries(members.map((m) => [m.id, Number(exactShares[m.id]) || 0]));
|
||||
}
|
||||
if (splitType === 'full') {
|
||||
const owedBy = fullOwedBy || members[0]?.id;
|
||||
return Object.fromEntries(members.map((m) => [m.id, m.id === owedBy ? Number(amount) : 0]));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setError('');
|
||||
try {
|
||||
await updateExpense.mutateAsync({
|
||||
id: expense.id,
|
||||
amount: Number(amount),
|
||||
title,
|
||||
categoryId,
|
||||
expenseDate,
|
||||
payerId,
|
||||
splitType,
|
||||
shares: buildShares(),
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
const ok = await confirmDialog({
|
||||
title: 'Usunąć ten wydatek?',
|
||||
message: 'Tej operacji nie da się cofnąć.',
|
||||
confirmLabel: 'Usuń',
|
||||
});
|
||||
if (!ok) return;
|
||||
await deleteExpense.mutateAsync(expense.id);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-sheet" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>Edytuj wydatek</h3>
|
||||
<button className="icon-btn" onClick={onClose}><Icon name="close" /></button>
|
||||
</div>
|
||||
|
||||
<div className="expense-form">
|
||||
<input
|
||||
className="amount-input"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="field">
|
||||
<label>Tytuł</label>
|
||||
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>Kategoria</label>
|
||||
<div className="category-grid">
|
||||
{(categories || []).map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`category-chip ${categoryId === c.id ? 'selected' : ''}`}
|
||||
onClick={() => setCategoryId(c.id)}
|
||||
>
|
||||
<Icon name={c.icon} className="icon" />
|
||||
<span>{c.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>Kto płacił?</label>
|
||||
<PayerToggle members={members} payerId={payerId} onChange={setPayerId} />
|
||||
</div>
|
||||
|
||||
<SplitSelector
|
||||
members={members}
|
||||
amount={amount}
|
||||
splitType={splitType}
|
||||
onSplitTypeChange={setSplitType}
|
||||
exactShares={exactShares}
|
||||
onExactSharesChange={setExactShares}
|
||||
fullOwedBy={fullOwedBy}
|
||||
onFullOwedByChange={setFullOwedBy}
|
||||
/>
|
||||
|
||||
<div className="field">
|
||||
<label>Data</label>
|
||||
<input type="date" value={expenseDate} onChange={(e) => setExpenseDate(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="btn-danger" onClick={handleDelete} disabled={deleteExpense.isPending}>
|
||||
<Icon name="delete" /> Usuń
|
||||
</button>
|
||||
<button className="btn-primary" onClick={handleSave} disabled={updateExpense.isPending}>
|
||||
Zapisz
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
frontend/src/components/ExpenseListItem.jsx
Normal file
26
frontend/src/components/ExpenseListItem.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import Icon from './Icon.jsx';
|
||||
|
||||
export default function ExpenseListItem({ expense, category, payer, currentUserId, currency, onClick }) {
|
||||
const myShare = expense.shares.find((s) => s.user_id === currentUserId)?.share_amount ?? 0;
|
||||
|
||||
return (
|
||||
<div className="expense-item" onClick={onClick}>
|
||||
<div className="cat-icon" style={{ background: (category?.color || '#6b7280') + '22' }}>
|
||||
<Icon name={category?.icon || 'inventory_2'} />
|
||||
</div>
|
||||
<div className="details">
|
||||
<div className="title">{expense.title}</div>
|
||||
<div className="meta">
|
||||
<span>{expense.expense_date}</span>
|
||||
<span>·</span>
|
||||
<Icon name="account_circle" />
|
||||
<span>{payer?.name || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="amount-col">
|
||||
<div className="amount">{expense.amount.toFixed(2)} {currency}</div>
|
||||
<div className="share">Twoja część: {myShare.toFixed(2)} {currency}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
frontend/src/components/Icon.jsx
Normal file
7
frontend/src/components/Icon.jsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function Icon({ name, className = '', style }) {
|
||||
return (
|
||||
<span className={`material-symbols-outlined ${className}`} style={style} aria-hidden="true">
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
41
frontend/src/components/InstallBanner.jsx
Normal file
41
frontend/src/components/InstallBanner.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useState } from 'react';
|
||||
import { useInstallPrompt } from '../pwa/useInstallPrompt.js';
|
||||
import Icon from './Icon.jsx';
|
||||
|
||||
const DISMISS_KEY = 'ktoco_install_dismissed';
|
||||
|
||||
export default function InstallBanner() {
|
||||
const { canInstall, isIOS, promptInstall } = useInstallPrompt();
|
||||
const [dismissed, setDismissed] = useState(() => localStorage.getItem(DISMISS_KEY) === '1');
|
||||
|
||||
if (dismissed || (!canInstall && !isIOS)) return null;
|
||||
|
||||
function handleDismiss() {
|
||||
localStorage.setItem(DISMISS_KEY, '1');
|
||||
setDismissed(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="install-banner">
|
||||
<Icon name="install_mobile" />
|
||||
<div className="install-banner-text">
|
||||
{canInstall ? (
|
||||
<span>Zainstaluj KtoCo jako aplikację na telefonie</span>
|
||||
) : (
|
||||
<span>
|
||||
Dodaj KtoCo do ekranu głównego: dotknij <Icon name="ios_share" className="inline-icon" /> Udostępnij, a
|
||||
potem „Dodaj do ekranu początkowego”
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{canInstall && (
|
||||
<button className="btn-primary install-banner-btn" onClick={promptInstall}>
|
||||
Zainstaluj
|
||||
</button>
|
||||
)}
|
||||
<button className="icon-btn" onClick={handleDismiss} aria-label="Zamknij">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
frontend/src/components/MonthlyBarChart.jsx
Normal file
19
frontend/src/components/MonthlyBarChart.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts';
|
||||
|
||||
export default function MonthlyBarChart({ data, currency }) {
|
||||
if (!data || data.length === 0) {
|
||||
return <p className="empty-state">Brak danych historycznych</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="month" fontSize={12} />
|
||||
<YAxis fontSize={12} />
|
||||
<Tooltip formatter={(value) => `${Number(value).toFixed(2)} ${currency}`} />
|
||||
<Bar dataKey="total" fill="#4f46e5" radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
14
frontend/src/components/OfflineBanner.jsx
Normal file
14
frontend/src/components/OfflineBanner.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useOnlineSync } from '../offline/useOnlineSync.js';
|
||||
|
||||
export default function OfflineBanner() {
|
||||
const { isOnline, pendingCount } = useOnlineSync();
|
||||
|
||||
if (isOnline && pendingCount === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={`offline-banner ${isOnline ? 'offline-banner--syncing' : ''}`}>
|
||||
{!isOnline && <span>Brak połączenia — wydatki zapisują się lokalnie</span>}
|
||||
{isOnline && pendingCount > 0 && <span>Synchronizowanie {pendingCount} wydatków…</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
frontend/src/components/PasswordField.jsx
Normal file
26
frontend/src/components/PasswordField.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useState } from 'react';
|
||||
import Icon from './Icon.jsx';
|
||||
|
||||
export default function PasswordField({ value, onChange, placeholder }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="password-field">
|
||||
<input
|
||||
type={visible ? 'text' : 'password'}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="password-toggle"
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
aria-label={visible ? 'Ukryj hasło' : 'Pokaż hasło'}
|
||||
>
|
||||
<Icon name={visible ? 'visibility_off' : 'visibility'} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
frontend/src/components/PayerComparisonChart.jsx
Normal file
30
frontend/src/components/PayerComparisonChart.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Cell } from 'recharts';
|
||||
|
||||
const COLORS = ['#4f46e5', '#f59e0b', '#22c55e', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16'];
|
||||
|
||||
export default function PayerComparisonChart({ members, byShare, currency }) {
|
||||
const data = members.map((m) => ({
|
||||
name: m.name,
|
||||
value: byShare.find((s) => s.userId === m.id)?.total || 0,
|
||||
}));
|
||||
|
||||
if (data.every((d) => d.value === 0)) {
|
||||
return <p className="empty-state">Brak wydatków w tym miesiącu</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={Math.max(120, data.length * 44)}>
|
||||
<BarChart data={data} layout="vertical" margin={{ left: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
|
||||
<XAxis type="number" fontSize={12} />
|
||||
<YAxis type="category" dataKey="name" fontSize={13} width={90} />
|
||||
<Tooltip formatter={(value) => `${Number(value).toFixed(2)} ${currency}`} />
|
||||
<Bar dataKey="value" radius={[0, 6, 6, 0]}>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
18
frontend/src/components/PayerToggle.jsx
Normal file
18
frontend/src/components/PayerToggle.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import Icon from './Icon.jsx';
|
||||
|
||||
export default function PayerToggle({ members, payerId, onChange }) {
|
||||
return (
|
||||
<div className="toggle-row">
|
||||
{members.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
className={`toggle-btn ${payerId === m.id ? 'selected' : ''}`}
|
||||
onClick={() => onChange(m.id)}
|
||||
>
|
||||
<Icon name="account_circle" /> {m.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
frontend/src/components/SplitSelector.jsx
Normal file
69
frontend/src/components/SplitSelector.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
const OPTIONS = [
|
||||
{ value: 'equal', label: 'Po równo (50/50)' },
|
||||
{ value: 'exact', label: 'Dokładny podział' },
|
||||
{ value: 'full', label: 'Całość na jedną osobę' },
|
||||
];
|
||||
|
||||
export default function SplitSelector({
|
||||
members,
|
||||
amount,
|
||||
splitType,
|
||||
onSplitTypeChange,
|
||||
exactShares,
|
||||
onExactSharesChange,
|
||||
fullOwedBy,
|
||||
onFullOwedByChange,
|
||||
}) {
|
||||
const exactSum = members.reduce((acc, m) => acc + (Number(exactShares[m.id]) || 0), 0);
|
||||
const exactValid = Math.abs(exactSum - Number(amount || 0)) < 0.01;
|
||||
|
||||
return (
|
||||
<div className="field">
|
||||
<label>Jak dzielimy wydatek?</label>
|
||||
<div className="split-options">
|
||||
{OPTIONS.map((opt) => (
|
||||
<div
|
||||
key={opt.value}
|
||||
className={`split-option ${splitType === opt.value ? 'selected' : ''}`}
|
||||
onClick={() => onSplitTypeChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
|
||||
{opt.value === 'exact' && splitType === 'exact' && (
|
||||
<div className="exact-shares" onClick={(e) => e.stopPropagation()}>
|
||||
{members.map((m) => (
|
||||
<div className="field" key={m.id}>
|
||||
<label>{m.name}</label>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
value={exactShares[m.id] ?? ''}
|
||||
onChange={(e) => onExactSharesChange({ ...exactShares, [m.id]: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{!exactValid && amount && <p className="form-error">Suma udziałów musi wynosić {amount}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{opt.value === 'full' && splitType === 'full' && (
|
||||
<div className="exact-shares" onClick={(e) => e.stopPropagation()}>
|
||||
{members.map((m) => (
|
||||
<button
|
||||
type="button"
|
||||
key={m.id}
|
||||
className={`toggle-btn ${fullOwedBy === m.id ? 'selected' : ''}`}
|
||||
onClick={() => onFullOwedByChange(m.id)}
|
||||
>
|
||||
{m.name} winien(-na) całość
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
frontend/src/components/Switch.jsx
Normal file
8
frontend/src/components/Switch.jsx
Normal file
@@ -0,0 +1,8 @@
|
||||
export default function Switch({ checked, onChange, disabled }) {
|
||||
return (
|
||||
<label className="switch">
|
||||
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} disabled={disabled} />
|
||||
<span className="switch-track" />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
68
frontend/src/household/HouseholdContext.jsx
Normal file
68
frontend/src/household/HouseholdContext.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, getActiveHouseholdId, setActiveHouseholdId as persistActiveHouseholdId } from '../api/client.js';
|
||||
import { useAuth } from '../auth/AuthContext.jsx';
|
||||
|
||||
const HouseholdContext = createContext(null);
|
||||
|
||||
const HOUSEHOLD_SCOPED_KEYS = ['expenses', 'categories', 'balance', 'summary', 'monthly', 'settlements'];
|
||||
|
||||
export function HouseholdProvider({ children }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const qc = useQueryClient();
|
||||
const [activeHouseholdId, setActiveHouseholdIdState] = useState(() => getActiveHouseholdId());
|
||||
|
||||
const householdsQuery = useQuery({
|
||||
queryKey: ['households'],
|
||||
queryFn: () => api.get('/households'),
|
||||
select: (d) => d.households,
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const households = householdsQuery.data || [];
|
||||
|
||||
const switchHousehold = useCallback(
|
||||
(id) => {
|
||||
persistActiveHouseholdId(id);
|
||||
setActiveHouseholdIdState(id);
|
||||
for (const key of HOUSEHOLD_SCOPED_KEYS) {
|
||||
qc.invalidateQueries({ queryKey: [key] });
|
||||
}
|
||||
},
|
||||
[qc]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || householdsQuery.isLoading) return;
|
||||
if (households.length === 0) {
|
||||
if (activeHouseholdId) switchHousehold(null);
|
||||
return;
|
||||
}
|
||||
if (!activeHouseholdId || !households.some((h) => h.id === activeHouseholdId)) {
|
||||
switchHousehold(households[0].id);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [households, householdsQuery.isLoading, isAuthenticated, activeHouseholdId]);
|
||||
|
||||
const activeHousehold = households.find((h) => h.id === activeHouseholdId) || null;
|
||||
|
||||
return (
|
||||
<HouseholdContext.Provider
|
||||
value={{
|
||||
households,
|
||||
householdsLoading: householdsQuery.isLoading,
|
||||
activeHouseholdId,
|
||||
activeHousehold,
|
||||
switchHousehold,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</HouseholdContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useHouseholdContext() {
|
||||
const ctx = useContext(HouseholdContext);
|
||||
if (!ctx) throw new Error('useHouseholdContext must be used within HouseholdProvider');
|
||||
return ctx;
|
||||
}
|
||||
37
frontend/src/main.jsx
Normal file
37
frontend/src/main.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App.jsx';
|
||||
import { AuthProvider } from './auth/AuthContext.jsx';
|
||||
import { ThemeProvider } from './theme/ThemeContext.jsx';
|
||||
import { HouseholdProvider } from './household/HouseholdContext.jsx';
|
||||
import { ConfirmProvider } from './components/ConfirmDialogProvider.jsx';
|
||||
import ErrorBoundary from './components/ErrorBoundary.jsx';
|
||||
import './styles.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: 1, staleTime: 30_000 },
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<HouseholdProvider>
|
||||
<ConfirmProvider>
|
||||
<App />
|
||||
</ConfirmProvider>
|
||||
</HouseholdProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
31
frontend/src/offline/db.js
Normal file
31
frontend/src/offline/db.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { openDB } from 'idb';
|
||||
|
||||
const DB_NAME = 'ktoco-offline';
|
||||
const STORE = 'pending-expenses';
|
||||
|
||||
async function getDb() {
|
||||
return openDB(DB_NAME, 1, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains(STORE)) {
|
||||
db.createObjectStore(STORE, { keyPath: 'localId' });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function queueExpense(payload) {
|
||||
const db = await getDb();
|
||||
const localId = `local-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
await db.put(STORE, { localId, payload, createdAt: Date.now() });
|
||||
return localId;
|
||||
}
|
||||
|
||||
export async function getPendingExpenses() {
|
||||
const db = await getDb();
|
||||
return db.getAll(STORE);
|
||||
}
|
||||
|
||||
export async function removePendingExpense(localId) {
|
||||
const db = await getDb();
|
||||
await db.delete(STORE, localId);
|
||||
}
|
||||
24
frontend/src/offline/syncQueue.js
Normal file
24
frontend/src/offline/syncQueue.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { api } from '../api/client.js';
|
||||
import { getPendingExpenses, removePendingExpense } from './db.js';
|
||||
|
||||
let syncing = false;
|
||||
|
||||
export async function syncPendingExpenses(onSynced) {
|
||||
if (syncing || !navigator.onLine) return;
|
||||
syncing = true;
|
||||
try {
|
||||
const pending = await getPendingExpenses();
|
||||
for (const item of pending) {
|
||||
try {
|
||||
await api.post('/expenses', item.payload);
|
||||
await removePendingExpense(item.localId);
|
||||
onSynced?.(item.localId);
|
||||
} catch (err) {
|
||||
// stop on first failure (e.g. still offline or auth issue); retry on next trigger
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
syncing = false;
|
||||
}
|
||||
}
|
||||
43
frontend/src/offline/useOnlineSync.js
Normal file
43
frontend/src/offline/useOnlineSync.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { getPendingExpenses } from './db.js';
|
||||
import { syncPendingExpenses } from './syncQueue.js';
|
||||
|
||||
export function useOnlineSync() {
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const refreshPendingCount = useCallback(async () => {
|
||||
const pending = await getPendingExpenses();
|
||||
setPendingCount(pending.length);
|
||||
}, []);
|
||||
|
||||
const runSync = useCallback(async () => {
|
||||
await syncPendingExpenses();
|
||||
await refreshPendingCount();
|
||||
qc.invalidateQueries({ queryKey: ['expenses'] });
|
||||
qc.invalidateQueries({ queryKey: ['balance'] });
|
||||
qc.invalidateQueries({ queryKey: ['summary'] });
|
||||
qc.invalidateQueries({ queryKey: ['monthly'] });
|
||||
}, [qc, refreshPendingCount]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshPendingCount();
|
||||
function handleOnline() {
|
||||
setIsOnline(true);
|
||||
runSync();
|
||||
}
|
||||
function handleOffline() {
|
||||
setIsOnline(false);
|
||||
}
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, [runSync, refreshPendingCount]);
|
||||
|
||||
return { isOnline, pendingCount, refreshPendingCount, runSync };
|
||||
}
|
||||
154
frontend/src/pages/AddExpense.jsx
Normal file
154
frontend/src/pages/AddExpense.jsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useCategories, useCreateExpense } from '../api/queries.js';
|
||||
import { useHouseholdContext } from '../household/HouseholdContext.jsx';
|
||||
import { useAuth } from '../auth/AuthContext.jsx';
|
||||
import { queueExpense } from '../offline/db.js';
|
||||
import PayerToggle from '../components/PayerToggle.jsx';
|
||||
import SplitSelector from '../components/SplitSelector.jsx';
|
||||
import Icon from '../components/Icon.jsx';
|
||||
|
||||
function todayStr() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export default function AddExpense() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { activeHousehold: household } = useHouseholdContext();
|
||||
const { data: categories } = useCategories();
|
||||
const createExpense = useCreateExpense();
|
||||
|
||||
const members = household?.members || [];
|
||||
|
||||
const [amount, setAmount] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
const [categoryId, setCategoryId] = useState(null);
|
||||
const [payerId, setPayerId] = useState(user?.id);
|
||||
const [expenseDate, setExpenseDate] = useState(todayStr());
|
||||
const [splitType, setSplitType] = useState('equal');
|
||||
const [exactShares, setExactShares] = useState({});
|
||||
const [fullOwedBy, setFullOwedBy] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
|
||||
function buildShares() {
|
||||
if (splitType === 'exact') {
|
||||
return Object.fromEntries(members.map((m) => [m.id, Number(exactShares[m.id]) || 0]));
|
||||
}
|
||||
if (splitType === 'full') {
|
||||
const owedBy = fullOwedBy || members[0]?.id;
|
||||
return Object.fromEntries(members.map((m) => [m.id, m.id === owedBy ? Number(amount) : 0]));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setNotice('');
|
||||
|
||||
if (!amount || Number(amount) <= 0) {
|
||||
setError('Podaj poprawną kwotę');
|
||||
return;
|
||||
}
|
||||
if (!payerId) {
|
||||
setError('Wybierz kto płacił');
|
||||
return;
|
||||
}
|
||||
if (splitType === 'full' && !fullOwedBy) {
|
||||
setError('Wybierz kto jest winien całość');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
amount: Number(amount),
|
||||
title: title || 'Wydatek',
|
||||
categoryId,
|
||||
expenseDate,
|
||||
payerId,
|
||||
splitType,
|
||||
shares: buildShares(),
|
||||
};
|
||||
|
||||
try {
|
||||
await createExpense.mutateAsync(payload);
|
||||
navigate('/', { replace: true });
|
||||
} catch (err) {
|
||||
if (!navigator.onLine) {
|
||||
await queueExpense(payload);
|
||||
setNotice('Brak sieci — wydatek zapisano lokalnie i zsynchronizuje się automatycznie.');
|
||||
setTimeout(() => navigate('/', { replace: true }), 1200);
|
||||
} else {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Dodaj wydatek</h1>
|
||||
<form onSubmit={handleSubmit} className="expense-form">
|
||||
<input
|
||||
className="amount-input"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div className="field">
|
||||
<label>Tytuł / opis</label>
|
||||
<input type="text" placeholder="np. Zakupy Biedronka" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>Kategoria</label>
|
||||
<div className="category-grid">
|
||||
{(categories || []).map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`category-chip ${categoryId === c.id ? 'selected' : ''}`}
|
||||
onClick={() => setCategoryId(c.id)}
|
||||
>
|
||||
<Icon name={c.icon} className="icon" />
|
||||
<span>{c.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>Kto płacił?</label>
|
||||
<PayerToggle members={members} payerId={payerId} onChange={setPayerId} />
|
||||
</div>
|
||||
|
||||
<SplitSelector
|
||||
members={members}
|
||||
amount={amount}
|
||||
splitType={splitType}
|
||||
onSplitTypeChange={setSplitType}
|
||||
exactShares={exactShares}
|
||||
onExactSharesChange={setExactShares}
|
||||
fullOwedBy={fullOwedBy}
|
||||
onFullOwedByChange={setFullOwedBy}
|
||||
/>
|
||||
|
||||
<div className="field">
|
||||
<label>Data</label>
|
||||
<input type="date" value={expenseDate} onChange={(e) => setExpenseDate(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
{notice && <p className="form-error" style={{ color: '#1e40af' }}>{notice}</p>}
|
||||
|
||||
<button type="submit" className="btn-primary" disabled={createExpense.isPending}>
|
||||
{createExpense.isPending ? 'Zapisywanie…' : 'Dodaj wydatek'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
frontend/src/pages/Dashboard.jsx
Normal file
76
frontend/src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useBalance, useSummary, useSettleUp } from '../api/queries.js';
|
||||
import { useHouseholdContext } from '../household/HouseholdContext.jsx';
|
||||
import CategoryPieChart from '../components/CategoryPieChart.jsx';
|
||||
import Icon from '../components/Icon.jsx';
|
||||
|
||||
function memberName(members, id) {
|
||||
return members.find((m) => m.id === id)?.name || '—';
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { activeHousehold: household } = useHouseholdContext();
|
||||
const { data: balance } = useBalance();
|
||||
const { data: summary } = useSummary();
|
||||
const settleUp = useSettleUp();
|
||||
|
||||
const members = household?.members || [];
|
||||
const currency = household?.currency || 'PLN';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Dashboard</h1>
|
||||
|
||||
<div className="card settlement-tile">
|
||||
{!balance ? (
|
||||
<p>Ładowanie…</p>
|
||||
) : balance.settled ? (
|
||||
<>
|
||||
<p>Rozliczenia</p>
|
||||
<p className="amount settled">
|
||||
Jesteście na czysto! <Icon name="celebration" />
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>Rozliczenia</p>
|
||||
<div className="settlement-transactions">
|
||||
{balance.transactions.map((tx, i) => (
|
||||
<p key={i} className="amount owed">
|
||||
{memberName(members, tx.from)} → {memberName(members, tx.to)}: {tx.amount.toFixed(2)} {currency}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn-primary" disabled={settleUp.isPending} onClick={() => settleUp.mutate()}>
|
||||
{settleUp.isPending ? 'Rozliczanie…' : 'Rozlicz się'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Wydatki wg kategorii (ten miesiąc)</h3>
|
||||
{!summary ? <p>Ładowanie…</p> : <CategoryPieChart data={summary.byCategory} currency={currency} />}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Podsumowanie miesiąca</h3>
|
||||
{summary && (
|
||||
<div className="summary-grid">
|
||||
<div>
|
||||
<div className="label">Suma</div>
|
||||
<div className="value">{summary.total.toFixed(2)} {currency}</div>
|
||||
</div>
|
||||
{members.map((m) => (
|
||||
<div key={m.id}>
|
||||
<div className="label">{m.name}</div>
|
||||
<div className="value">
|
||||
{(summary.byPayer.find((p) => p.userId === m.id)?.total || 0).toFixed(2)} {currency}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
frontend/src/pages/ForgotPassword.jsx
Normal file
50
frontend/src/pages/ForgotPassword.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useForgotPassword } from '../api/queries.js';
|
||||
|
||||
export default function ForgotPassword() {
|
||||
const forgotPassword = useForgotPassword();
|
||||
const [email, setEmail] = useState('');
|
||||
const [sent, setSent] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await forgotPassword.mutateAsync(email);
|
||||
setSent(true);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<h1>Przypomnij hasło</h1>
|
||||
{sent ? (
|
||||
<p>Jeśli konto z tym adresem e-mail istnieje, wysłaliśmy wiadomość z linkiem do resetu hasła.</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="auth-subtitle">Podaj e-mail, na który wyślemy link do zresetowania hasła</p>
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="E-mail"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<button type="submit" className="btn-primary" disabled={forgotPassword.isPending}>
|
||||
{forgotPassword.isPending ? 'Wysyłanie…' : 'Wyślij link'}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
<p>
|
||||
<Link to="/login">Powrót do logowania</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
frontend/src/pages/History.jsx
Normal file
92
frontend/src/pages/History.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useCategories, useExpenses } from '../api/queries.js';
|
||||
import { useHouseholdContext } from '../household/HouseholdContext.jsx';
|
||||
import { useAuth } from '../auth/AuthContext.jsx';
|
||||
import ExpenseListItem from '../components/ExpenseListItem.jsx';
|
||||
import ExpenseEditModal from '../components/ExpenseEditModal.jsx';
|
||||
|
||||
function monthOptions() {
|
||||
const options = [];
|
||||
const now = new Date();
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
options.push(d.toISOString().slice(0, 7));
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
export default function History() {
|
||||
const { user } = useAuth();
|
||||
const { activeHousehold: household } = useHouseholdContext();
|
||||
const { data: categories } = useCategories();
|
||||
const [month, setMonth] = useState('');
|
||||
const [categoryId, setCategoryId] = useState('');
|
||||
const [payerId, setPayerId] = useState('');
|
||||
const [editing, setEditing] = useState(null);
|
||||
|
||||
const filters = useMemo(() => {
|
||||
const f = {};
|
||||
if (month) f.month = month;
|
||||
if (categoryId) f.categoryId = categoryId;
|
||||
if (payerId) f.payerId = payerId;
|
||||
return f;
|
||||
}, [month, categoryId, payerId]);
|
||||
|
||||
const { data: expenses, isLoading } = useExpenses(filters);
|
||||
const members = household?.members || [];
|
||||
const currency = household?.currency || 'PLN';
|
||||
const categoryById = Object.fromEntries((categories || []).map((c) => [c.id, c]));
|
||||
const memberById = Object.fromEntries(members.map((m) => [m.id, m]));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Historia</h1>
|
||||
|
||||
<div className="filters">
|
||||
<select value={month} onChange={(e) => setMonth(e.target.value)}>
|
||||
<option value="">Wszystkie miesiące</option>
|
||||
{monthOptions().map((m) => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={categoryId} onChange={(e) => setCategoryId(e.target.value)}>
|
||||
<option value="">Wszystkie kategorie</option>
|
||||
{(categories || []).map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.icon} {c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={payerId} onChange={(e) => setPayerId(e.target.value)}>
|
||||
<option value="">Wszyscy płacący</option>
|
||||
{members.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{isLoading && <p>Ładowanie…</p>}
|
||||
{!isLoading && (expenses || []).length === 0 && <p className="empty-state">Brak wydatków spełniających filtry</p>}
|
||||
|
||||
{(expenses || []).map((expense) => (
|
||||
<ExpenseListItem
|
||||
key={expense.id}
|
||||
expense={expense}
|
||||
category={categoryById[expense.category_id]}
|
||||
payer={memberById[expense.payer_id]}
|
||||
currentUserId={user?.id}
|
||||
currency={currency}
|
||||
onClick={() => setEditing(expense)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{editing && (
|
||||
<ExpenseEditModal
|
||||
expense={editing}
|
||||
members={members}
|
||||
categories={categories}
|
||||
currency={currency}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
frontend/src/pages/JoinInvite.jsx
Normal file
48
frontend/src/pages/JoinInvite.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate, Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/AuthContext.jsx';
|
||||
import { useJoinHousehold } from '../api/queries.js';
|
||||
import { useHouseholdContext } from '../household/HouseholdContext.jsx';
|
||||
|
||||
export default function JoinInvite() {
|
||||
const { code } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { switchHousehold } = useHouseholdContext();
|
||||
const joinHousehold = useJoinHousehold();
|
||||
const [error, setError] = useState('');
|
||||
const [attempted, setAttempted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || attempted) return;
|
||||
setAttempted(true);
|
||||
joinHousehold
|
||||
.mutateAsync(code)
|
||||
.then((result) => {
|
||||
switchHousehold(result.household.id);
|
||||
navigate('/', { replace: true });
|
||||
})
|
||||
.catch((err) => setError(err.message));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAuthenticated, attempted, code]);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to={`/register?code=${encodeURIComponent(code)}`} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<h1>Dołączanie do gospodarstwa</h1>
|
||||
{error ? (
|
||||
<>
|
||||
<p className="form-error">{error}</p>
|
||||
<button className="btn-primary" onClick={() => navigate('/', { replace: true })}>
|
||||
Przejdź do aplikacji
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className="auth-subtitle">Chwileczkę…</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
frontend/src/pages/Login.jsx
Normal file
65
frontend/src/pages/Login.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/AuthContext.jsx';
|
||||
import { useJoinHousehold } from '../api/queries.js';
|
||||
import { useHouseholdContext } from '../household/HouseholdContext.jsx';
|
||||
import PasswordField from '../components/PasswordField.jsx';
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const inviteCode = params.get('code') || '';
|
||||
const joinHousehold = useJoinHousehold();
|
||||
const { switchHousehold } = useHouseholdContext();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
if (inviteCode) {
|
||||
try {
|
||||
const result = await joinHousehold.mutateAsync(inviteCode);
|
||||
switchHousehold(result.household.id);
|
||||
} catch {
|
||||
// invalid/expired code, or already a member — just continue into the app
|
||||
}
|
||||
}
|
||||
navigate('/', { replace: true });
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<h1>KtoCo</h1>
|
||||
<p className="auth-subtitle">
|
||||
{inviteCode ? 'Zaloguj się, aby dołączyć do zaproszonego gospodarstwa domowego' : 'Zaloguj się, by zarządzać wspólnymi wydatkami'}
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<input type="email" placeholder="E-mail" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
<PasswordField placeholder="Hasło" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<button type="submit" className="btn-primary" disabled={loading}>
|
||||
{loading ? 'Logowanie…' : 'Zaloguj się'}
|
||||
</button>
|
||||
</form>
|
||||
<p>
|
||||
<Link to="/forgot-password">Zapomniałeś hasła?</Link>
|
||||
</p>
|
||||
<p>
|
||||
Nie masz konta?{' '}
|
||||
<Link to={inviteCode ? `/register?code=${encodeURIComponent(inviteCode)}` : '/register'}>Zarejestruj się</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
frontend/src/pages/Onboarding.jsx
Normal file
90
frontend/src/pages/Onboarding.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useCreateHousehold, useJoinHousehold } from '../api/queries.js';
|
||||
import { useHouseholdContext } from '../household/HouseholdContext.jsx';
|
||||
import Icon from '../components/Icon.jsx';
|
||||
|
||||
export default function Onboarding() {
|
||||
const navigate = useNavigate();
|
||||
const { switchHousehold } = useHouseholdContext();
|
||||
const [mode, setMode] = useState('create');
|
||||
const [name, setName] = useState('Nasze gospodarstwo');
|
||||
const [currency, setCurrency] = useState('PLN');
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const createHousehold = useCreateHousehold();
|
||||
const joinHousehold = useJoinHousehold();
|
||||
|
||||
async function handleCreate(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
const result = await createHousehold.mutateAsync({ name, currency });
|
||||
switchHousehold(result.household.id);
|
||||
navigate('/', { replace: true });
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleJoin(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
const result = await joinHousehold.mutateAsync(code);
|
||||
switchHousehold(result.household.id);
|
||||
navigate('/', { replace: true });
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<button type="button" className="icon-btn back-btn" onClick={() => navigate(-1)} aria-label="Cofnij">
|
||||
<Icon name="arrow_back" /> Cofnij
|
||||
</button>
|
||||
<h1>Witaj!</h1>
|
||||
<p className="auth-subtitle">Załóż nowe gospodarstwo domowe albo dołącz do partnera/partnerki kodem</p>
|
||||
|
||||
<div className="tabs">
|
||||
<button className={mode === 'create' ? 'tab active' : 'tab'} onClick={() => setMode('create')}>
|
||||
Utwórz
|
||||
</button>
|
||||
<button className={mode === 'join' ? 'tab active' : 'tab'} onClick={() => setMode('join')}>
|
||||
Dołącz kodem
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'create' ? (
|
||||
<form onSubmit={handleCreate} className="auth-form">
|
||||
<input type="text" placeholder="Nazwa gospodarstwa" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<select value={currency} onChange={(e) => setCurrency(e.target.value)}>
|
||||
<option value="PLN">PLN</option>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<button type="submit" className="btn-primary" disabled={createHousehold.isPending}>
|
||||
{createHousehold.isPending ? 'Tworzenie…' : 'Utwórz gospodarstwo'}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleJoin} className="auth-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Kod zaproszenia"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
required
|
||||
/>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<button type="submit" className="btn-primary" disabled={joinHousehold.isPending}>
|
||||
{joinHousehold.isPending ? 'Dołączanie…' : 'Dołącz'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
frontend/src/pages/Register.jsx
Normal file
68
frontend/src/pages/Register.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/AuthContext.jsx';
|
||||
import { useJoinHousehold } from '../api/queries.js';
|
||||
import { useHouseholdContext } from '../household/HouseholdContext.jsx';
|
||||
import PasswordField from '../components/PasswordField.jsx';
|
||||
|
||||
export default function Register() {
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const inviteCode = params.get('code') || '';
|
||||
const joinHousehold = useJoinHousehold();
|
||||
const { switchHousehold } = useHouseholdContext();
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await register(email, password, name);
|
||||
if (inviteCode) {
|
||||
const result = await joinHousehold.mutateAsync(inviteCode);
|
||||
switchHousehold(result.household.id);
|
||||
navigate('/', { replace: true });
|
||||
} else {
|
||||
navigate('/onboarding', { replace: true });
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<h1>KtoCo</h1>
|
||||
<p className="auth-subtitle">
|
||||
{inviteCode
|
||||
? 'Załóż konto, aby dołączyć do zaproszonego gospodarstwa domowego'
|
||||
: 'Załóż konto, by zacząć dzielić wydatki'}
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<input type="text" placeholder="Imię" value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
<input type="email" placeholder="E-mail" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
<PasswordField
|
||||
placeholder="Hasło (min. 6 znaków)"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<button type="submit" className="btn-primary" disabled={loading}>
|
||||
{loading ? 'Tworzenie konta…' : 'Zarejestruj się'}
|
||||
</button>
|
||||
</form>
|
||||
<p>
|
||||
Masz już konto?{' '}
|
||||
<Link to={inviteCode ? `/login?code=${encodeURIComponent(inviteCode)}` : '/login'}>Zaloguj się</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
frontend/src/pages/ResetPassword.jsx
Normal file
60
frontend/src/pages/ResetPassword.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useResetPassword } from '../api/queries.js';
|
||||
import PasswordField from '../components/PasswordField.jsx';
|
||||
|
||||
export default function ResetPassword() {
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const token = params.get('token') || '';
|
||||
const resetPassword = useResetPassword();
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (password !== confirm) {
|
||||
setError('Hasła nie są takie same');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await resetPassword.mutateAsync({ token, password });
|
||||
setDone(true);
|
||||
setTimeout(() => navigate('/login', { replace: true }), 1500);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<h1>Nieprawidłowy link</h1>
|
||||
<p>Brakuje tokenu resetu hasła. Poproś o nowy link.</p>
|
||||
<p><Link to="/forgot-password">Przypomnij hasło</Link></p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<h1>Ustaw nowe hasło</h1>
|
||||
{done ? (
|
||||
<p>Hasło zostało zmienione. Przenoszenie do logowania…</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<PasswordField placeholder="Nowe hasło (min. 6 znaków)" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
<PasswordField placeholder="Powtórz nowe hasło" value={confirm} onChange={(e) => setConfirm(e.target.value)} />
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<button type="submit" className="btn-primary" disabled={resetPassword.isPending}>
|
||||
{resetPassword.isPending ? 'Zapisywanie…' : 'Ustaw nowe hasło'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
541
frontend/src/pages/Settings.jsx
Normal file
541
frontend/src/pages/Settings.jsx
Normal file
@@ -0,0 +1,541 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
useUpdateHousehold,
|
||||
useRegenerateInvite,
|
||||
useRemoveMember,
|
||||
useDeleteHousehold,
|
||||
useCategories,
|
||||
useCreateCategory,
|
||||
useUpdateCategory,
|
||||
useDeleteCategory,
|
||||
useMe,
|
||||
useUpdateProfile,
|
||||
useDeleteAccount,
|
||||
useUpdateNotifications,
|
||||
useChangePassword,
|
||||
} from '../api/queries.js';
|
||||
import { useHouseholdContext } from '../household/HouseholdContext.jsx';
|
||||
import { useAuth } from '../auth/AuthContext.jsx';
|
||||
import { useTheme } from '../theme/ThemeContext.jsx';
|
||||
import { useInstallPrompt } from '../pwa/useInstallPrompt.js';
|
||||
import { useConfirm } from '../components/ConfirmDialogProvider.jsx';
|
||||
import { getToken } from '../api/client.js';
|
||||
import Icon from '../components/Icon.jsx';
|
||||
import Switch from '../components/Switch.jsx';
|
||||
import PasswordField from '../components/PasswordField.jsx';
|
||||
|
||||
const CURRENCIES = ['PLN', 'EUR', 'USD', 'GBP'];
|
||||
const ICON_CHOICES = [
|
||||
'shopping_cart', 'home', 'bolt', 'directions_car', 'restaurant', 'celebration',
|
||||
'inventory_2', 'medication', 'school', 'flight', 'pets', 'fitness_center',
|
||||
'spa', 'local_bar', 'redeem', 'work', 'child_care', 'theater_comedy',
|
||||
'local_gas_station', 'checkroom', 'sports_esports', 'devices', 'local_cafe',
|
||||
'movie', 'wifi', 'local_hospital', 'directions_bike', 'cleaning_services',
|
||||
];
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{ value: 'light', label: 'Jasny', icon: 'light_mode' },
|
||||
{ value: 'dark', label: 'Ciemny', icon: 'dark_mode' },
|
||||
{ value: 'system', label: 'Systemowy', icon: 'contrast' },
|
||||
];
|
||||
|
||||
async function downloadCsv() {
|
||||
const res = await fetch('/api/stats/export.csv', {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
});
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'wydatki.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function CopyField({ value, big }) {
|
||||
const inputRef = useRef(null);
|
||||
const [status, setStatus] = useState(null); // 'copied' | 'failed'
|
||||
|
||||
async function handleCopy() {
|
||||
if (!value) return;
|
||||
let ok = false;
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
ok = true;
|
||||
}
|
||||
} catch {
|
||||
ok = false;
|
||||
}
|
||||
if (!ok && inputRef.current) {
|
||||
try {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
ok = document.execCommand('copy');
|
||||
} catch {
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
setStatus(ok ? 'copied' : 'failed');
|
||||
setTimeout(() => setStatus(null), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="invite-code-row">
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={big ? 'invite-code-input' : 'invite-link-input'}
|
||||
readOnly
|
||||
value={value || ''}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
<button type="button" className="invite-code-copy-btn" onClick={handleCopy} aria-label="Kopiuj">
|
||||
<Icon name={status === 'copied' ? 'check' : 'content_copy'} />
|
||||
</button>
|
||||
</div>
|
||||
{status === 'copied' && <p className="invite-code-hint success">Skopiowano do schowka!</p>}
|
||||
{status === 'failed' && (
|
||||
<p className="invite-code-hint error">Nie udało się skopiować automatycznie — zaznacz pole powyżej i skopiuj ręcznie.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteCode({ code }) {
|
||||
const inviteLink = code ? `${window.location.origin}/join/${code}` : '';
|
||||
return (
|
||||
<div>
|
||||
<CopyField value={code} big />
|
||||
<p className="switch-desc" style={{ marginTop: 12 }}>
|
||||
Albo wyślij link, który od razu przeniesie do rejestracji i dołączy do gospodarstwa:
|
||||
</p>
|
||||
<CopyField value={inviteLink} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstallSection() {
|
||||
const { canInstall, isInstalled, isIOS, isSecureContext, promptInstall } = useInstallPrompt();
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3>Aplikacja</h3>
|
||||
{isInstalled && (
|
||||
<p className="switch-desc">
|
||||
<Icon name="check_circle" /> Aplikacja jest zainstalowana na tym urządzeniu
|
||||
</p>
|
||||
)}
|
||||
{!isInstalled && canInstall && (
|
||||
<button className="btn-primary btn-full" onClick={promptInstall}>
|
||||
<Icon name="install_mobile" /> Zainstaluj aplikację
|
||||
</button>
|
||||
)}
|
||||
{!isInstalled && isIOS && (
|
||||
<p className="switch-desc">
|
||||
Dotknij <Icon name="ios_share" className="inline-icon" /> Udostępnij, a potem „Dodaj do ekranu
|
||||
początkowego”.
|
||||
</p>
|
||||
)}
|
||||
{!isInstalled && !canInstall && !isIOS && !isSecureContext && (
|
||||
<p className="switch-desc">
|
||||
Instalacja wymaga bezpiecznego połączenia — otwórz aplikację pod adresem zaczynającym się od{' '}
|
||||
<strong>https://</strong>, a nie przez lokalny adres IP.
|
||||
</p>
|
||||
)}
|
||||
{!isInstalled && !canInstall && !isIOS && isSecureContext && (
|
||||
<p className="switch-desc">
|
||||
Przeglądarka jeszcze nie zaproponowała instalacji. Odśwież stronę po chwili korzystania z aplikacji albo
|
||||
użyj menu przeglądarki (⋮) i wybierz „Zainstaluj aplikację” / „Dodaj do ekranu głównego”.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountSection() {
|
||||
const navigate = useNavigate();
|
||||
const { logout } = useAuth();
|
||||
const { data: me } = useMe();
|
||||
const updateProfile = useUpdateProfile();
|
||||
const deleteAccount = useDeleteAccount();
|
||||
const confirmDialog = useConfirm();
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [nameSaved, setNameSaved] = useState(false);
|
||||
|
||||
if (!me) return null;
|
||||
|
||||
async function handleNameBlur(e) {
|
||||
const value = e.target.value.trim();
|
||||
if (!value || value === me.name) return;
|
||||
await updateProfile.mutateAsync(value);
|
||||
setNameSaved(true);
|
||||
setTimeout(() => setNameSaved(false), 2000);
|
||||
}
|
||||
|
||||
async function handleDeleteAccount() {
|
||||
const ok = await confirmDialog({
|
||||
title: 'Usunąć swoje konto?',
|
||||
message: 'Tej operacji nie da się cofnąć.',
|
||||
confirmLabel: 'Usuń konto',
|
||||
});
|
||||
if (!ok) return;
|
||||
await deleteAccount.mutateAsync();
|
||||
logout();
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3>Konto</h3>
|
||||
<div className="field">
|
||||
<label>Imię</label>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={name || me.name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={handleNameBlur}
|
||||
/>
|
||||
{nameSaved && <p className="invite-code-hint success">Zapisano</p>}
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>E-mail</label>
|
||||
<input type="email" value={me.email} readOnly disabled />
|
||||
</div>
|
||||
<button
|
||||
className="btn-danger btn-full"
|
||||
style={{ marginTop: 16 }}
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={deleteAccount.isPending}
|
||||
>
|
||||
<Icon name="delete_forever" /> Usuń konto
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HouseholdsSection() {
|
||||
const { user } = useAuth();
|
||||
const { households, activeHouseholdId, activeHousehold, switchHousehold } = useHouseholdContext();
|
||||
const updateHousehold = useUpdateHousehold();
|
||||
const regenerateInvite = useRegenerateInvite();
|
||||
const removeMember = useRemoveMember();
|
||||
const deleteHousehold = useDeleteHousehold();
|
||||
const confirmDialog = useConfirm();
|
||||
|
||||
async function handleRemoveMember(m) {
|
||||
const isSelf = m.id === user?.id;
|
||||
const ok = await confirmDialog({
|
||||
title: isSelf ? 'Opuścić to gospodarstwo?' : `Usunąć ${m.name} z gospodarstwa?`,
|
||||
message: isSelf
|
||||
? 'Będziesz musiał(a) dołączyć ponownie kodem zaproszenia, żeby wrócić.'
|
||||
: 'Ta osoba straci dostęp do gospodarstwa (historia wydatków zostanie zachowana).',
|
||||
confirmLabel: isSelf ? 'Opuść' : 'Usuń',
|
||||
});
|
||||
if (!ok) return;
|
||||
await removeMember.mutateAsync({ householdId: activeHousehold.id, userId: m.id });
|
||||
}
|
||||
|
||||
async function handleDeleteHousehold() {
|
||||
const ok = await confirmDialog({
|
||||
title: `Usunąć gospodarstwo „${activeHousehold.name}”?`,
|
||||
message: 'Usunie to całą historię wydatków wszystkich członków. Tej operacji nie da się cofnąć.',
|
||||
confirmLabel: 'Usuń gospodarstwo',
|
||||
});
|
||||
if (!ok) return;
|
||||
await deleteHousehold.mutateAsync(activeHousehold.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3>Twoje gospodarstwa</h3>
|
||||
{households.map((h) => (
|
||||
<div className="household-row" key={h.id}>
|
||||
<div className="household-row-main">
|
||||
<div>{h.name}</div>
|
||||
<div className="switch-desc">{h.members.length} {h.members.length === 1 ? 'osoba' : 'osoby/osób'}</div>
|
||||
</div>
|
||||
{h.id === activeHouseholdId ? (
|
||||
<span className="badge-active">Aktywne</span>
|
||||
) : (
|
||||
<button className="btn-secondary" onClick={() => switchHousehold(h.id)}>
|
||||
Przełącz
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Link to="/onboarding" className="btn-secondary btn-full" style={{ marginTop: 12 }}>
|
||||
<Icon name="add" /> Utwórz lub dołącz do gospodarstwa
|
||||
</Link>
|
||||
|
||||
{activeHousehold && (
|
||||
<>
|
||||
<hr className="section-divider" />
|
||||
<div className="field">
|
||||
<label>Nazwa aktywnego gospodarstwa</label>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={activeHousehold.name}
|
||||
onBlur={(e) =>
|
||||
e.target.value !== activeHousehold.name &&
|
||||
updateHousehold.mutate({ id: activeHousehold.id, name: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Waluta</label>
|
||||
<select
|
||||
value={activeHousehold.currency}
|
||||
onChange={(e) => updateHousehold.mutate({ id: activeHousehold.id, currency: e.target.value })}
|
||||
>
|
||||
{CURRENCIES.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<h4>Członkowie</h4>
|
||||
{activeHousehold.members.map((m) => (
|
||||
<div className="member-row" key={m.id}>
|
||||
<Icon name="account_circle" style={{ fontSize: '28px' }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div>{m.name} {m.id === user?.id && '(Ty)'}</div>
|
||||
<div className="meta" style={{ color: 'var(--muted)', fontSize: '0.8rem' }}>{m.email}</div>
|
||||
</div>
|
||||
<button className="icon-btn" onClick={() => handleRemoveMember(m)} aria-label="Usuń">
|
||||
<Icon name={m.id === user?.id ? 'logout' : 'person_remove'} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<p>Zaproś kolejną osobę tym kodem:</p>
|
||||
<InviteCode code={activeHousehold.inviteCode} />
|
||||
<button className="btn-secondary btn-full" onClick={() => regenerateInvite.mutate(activeHousehold.id)}>
|
||||
Wygeneruj nowy kod
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn-danger btn-full"
|
||||
style={{ marginTop: 16 }}
|
||||
onClick={handleDeleteHousehold}
|
||||
disabled={deleteHousehold.isPending}
|
||||
>
|
||||
<Icon name="delete_forever" /> Usuń gospodarstwo
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const { logout } = useAuth();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { activeHouseholdId } = useHouseholdContext();
|
||||
const { data: categories } = useCategories();
|
||||
const createCategory = useCreateCategory();
|
||||
const updateCategory = useUpdateCategory();
|
||||
const deleteCategory = useDeleteCategory();
|
||||
const { data: me } = useMe();
|
||||
const updateNotifications = useUpdateNotifications();
|
||||
const changePassword = useChangePassword();
|
||||
const confirmDialog = useConfirm();
|
||||
|
||||
const [editingCategoryId, setEditingCategoryId] = useState(null);
|
||||
const [catName, setCatName] = useState('');
|
||||
const [catIcon, setCatIcon] = useState(ICON_CHOICES[0]);
|
||||
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const [passwordSuccess, setPasswordSuccess] = useState(false);
|
||||
|
||||
function startEditCategory(c) {
|
||||
setEditingCategoryId(c.id);
|
||||
setCatName(c.name);
|
||||
setCatIcon(c.icon);
|
||||
}
|
||||
|
||||
function cancelEditCategory() {
|
||||
setEditingCategoryId(null);
|
||||
setCatName('');
|
||||
setCatIcon(ICON_CHOICES[0]);
|
||||
}
|
||||
|
||||
async function handleDeleteCategory(c) {
|
||||
const ok = await confirmDialog({
|
||||
title: `Usunąć kategorię „${c.name}”?`,
|
||||
message: 'Istniejące wydatki zachowają swoją historię, ale stracą przypisaną kategorię.',
|
||||
confirmLabel: 'Usuń',
|
||||
});
|
||||
if (!ok) return;
|
||||
deleteCategory.mutate(c.id);
|
||||
}
|
||||
|
||||
async function handleCategorySubmit(e) {
|
||||
e.preventDefault();
|
||||
if (!catName.trim()) return;
|
||||
if (editingCategoryId) {
|
||||
await updateCategory.mutateAsync({ id: editingCategoryId, name: catName, icon: catIcon });
|
||||
} else {
|
||||
await createCategory.mutateAsync({ name: catName, icon: catIcon });
|
||||
}
|
||||
cancelEditCategory();
|
||||
}
|
||||
|
||||
async function handlePasswordSubmit(e) {
|
||||
e.preventDefault();
|
||||
setPasswordError('');
|
||||
setPasswordSuccess(false);
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordError('Nowe hasła nie są takie same');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await changePassword.mutateAsync({ currentPassword, newPassword });
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setPasswordSuccess(true);
|
||||
setTimeout(() => setPasswordSuccess(false), 3000);
|
||||
} catch (err) {
|
||||
setPasswordError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Ustawienia</h1>
|
||||
|
||||
<InstallSection />
|
||||
|
||||
<AccountSection />
|
||||
|
||||
<div className="card">
|
||||
<h3>Wygląd</h3>
|
||||
<div className="theme-toggle">
|
||||
{THEME_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={theme === opt.value ? 'tab active' : 'tab'}
|
||||
onClick={() => setTheme(opt.value)}
|
||||
>
|
||||
<Icon name={opt.icon} />
|
||||
<span>{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Powiadomienia</h3>
|
||||
<div className="switch-row">
|
||||
<div className="switch-label">
|
||||
<span>Powiadomienia mailowe</span>
|
||||
<span className="switch-desc">Dostaniesz e-mail, gdy ktoś w gospodarstwie doda nowy wydatek</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={!!me?.emailNotifications}
|
||||
disabled={updateNotifications.isPending}
|
||||
onChange={(checked) => updateNotifications.mutate(checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HouseholdsSection />
|
||||
|
||||
<div className="card">
|
||||
<h3>Bezpieczeństwo</h3>
|
||||
<form onSubmit={handlePasswordSubmit} className="expense-form">
|
||||
<div className="field">
|
||||
<label>Bieżące hasło</label>
|
||||
<PasswordField value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Nowe hasło</label>
|
||||
<PasswordField value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Powtórz nowe hasło</label>
|
||||
<PasswordField value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} />
|
||||
</div>
|
||||
{passwordError && <p className="form-error">{passwordError}</p>}
|
||||
{passwordSuccess && <p className="invite-code-hint success">Hasło zostało zmienione</p>}
|
||||
<button type="submit" className="btn-primary btn-full" disabled={changePassword.isPending}>
|
||||
{changePassword.isPending ? 'Zapisywanie…' : 'Zmień hasło'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{activeHouseholdId && (
|
||||
<div className="card">
|
||||
<h3>Kategorie</h3>
|
||||
{(categories || []).map((c) => (
|
||||
<div className="category-manage-row" key={c.id}>
|
||||
<button
|
||||
className={`category-manage-row-main ${editingCategoryId === c.id ? 'editing' : ''}`}
|
||||
onClick={() => startEditCategory(c)}
|
||||
>
|
||||
<Icon name={c.icon} className="icon" />
|
||||
<span className="name">{c.name}</span>
|
||||
</button>
|
||||
<button className="icon-btn" onClick={() => deleteCategory.mutate(c.id)}>
|
||||
<Icon name="delete" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<form onSubmit={handleCategorySubmit} style={{ marginTop: 16 }}>
|
||||
<div className="field">
|
||||
<label>Ikona</label>
|
||||
<div className="category-grid">
|
||||
{ICON_CHOICES.map((icon) => (
|
||||
<div
|
||||
key={icon}
|
||||
className={`category-chip ${catIcon === icon ? 'selected' : ''}`}
|
||||
onClick={() => setCatIcon(icon)}
|
||||
>
|
||||
<Icon name={icon} className="icon" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="field" style={{ marginTop: 12 }}>
|
||||
<label>Nazwa kategorii</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="np. Zwierzęta"
|
||||
value={catName}
|
||||
onChange={(e) => setCatName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-actions" style={{ marginTop: 12 }}>
|
||||
{editingCategoryId && (
|
||||
<button type="button" className="btn-secondary" onClick={cancelEditCategory}>
|
||||
Anuluj
|
||||
</button>
|
||||
)}
|
||||
<button type="submit" className="btn-primary">
|
||||
{editingCategoryId ? 'Zapisz zmiany' : 'Dodaj kategorię'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<h3>Dane</h3>
|
||||
<button className="btn-secondary btn-full" onClick={downloadCsv}>Pobierz CSV</button>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<button className="btn-secondary btn-full" onClick={logout}>Wyloguj się</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
frontend/src/pages/Stats.jsx
Normal file
50
frontend/src/pages/Stats.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useMonthly, useSummary } from '../api/queries.js';
|
||||
import { useHouseholdContext } from '../household/HouseholdContext.jsx';
|
||||
import MonthlyBarChart from '../components/MonthlyBarChart.jsx';
|
||||
import PayerComparisonChart from '../components/PayerComparisonChart.jsx';
|
||||
import Icon from '../components/Icon.jsx';
|
||||
|
||||
export default function Stats() {
|
||||
const { activeHousehold: household } = useHouseholdContext();
|
||||
const { data: monthly } = useMonthly();
|
||||
const { data: summary } = useSummary();
|
||||
|
||||
const members = household?.members || [];
|
||||
const currency = household?.currency || 'PLN';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Statystyki</h1>
|
||||
|
||||
<div className="card">
|
||||
<h3>Wydatki miesiąc do miesiąca</h3>
|
||||
{!monthly ? <p>Ładowanie…</p> : <MonthlyBarChart data={monthly} currency={currency} />}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Kto więcej konsumuje (ten miesiąc)</h3>
|
||||
{!summary ? (
|
||||
<p>Ładowanie…</p>
|
||||
) : (
|
||||
<PayerComparisonChart members={members} byShare={summary.byShare} currency={currency} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Kategorie od najdroższej</h3>
|
||||
{summary && (
|
||||
<div>
|
||||
{summary.byCategory.length === 0 && <p className="empty-state">Brak wydatków w tym miesiącu</p>}
|
||||
{summary.byCategory.map((c) => (
|
||||
<div key={c.categoryId || 'none'} className="category-manage-row">
|
||||
<Icon name={c.icon || 'inventory_2'} className="icon" />
|
||||
<span className="name">{c.name || 'Bez kategorii'}</span>
|
||||
<strong>{c.total.toFixed(2)} {currency}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
frontend/src/pwa/useInstallPrompt.js
Normal file
47
frontend/src/pwa/useInstallPrompt.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
function detectIOS() {
|
||||
return /iphone|ipad|ipod/i.test(navigator.userAgent) && !window.MSStream;
|
||||
}
|
||||
|
||||
function detectStandalone() {
|
||||
return window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true;
|
||||
}
|
||||
|
||||
export function useInstallPrompt() {
|
||||
const [deferredPrompt, setDeferredPrompt] = useState(null);
|
||||
const [isInstalled, setIsInstalled] = useState(detectStandalone());
|
||||
|
||||
useEffect(() => {
|
||||
function handleBeforeInstall(e) {
|
||||
e.preventDefault();
|
||||
setDeferredPrompt(e);
|
||||
}
|
||||
function handleInstalled() {
|
||||
setIsInstalled(true);
|
||||
setDeferredPrompt(null);
|
||||
}
|
||||
window.addEventListener('beforeinstallprompt', handleBeforeInstall);
|
||||
window.addEventListener('appinstalled', handleInstalled);
|
||||
return () => {
|
||||
window.removeEventListener('beforeinstallprompt', handleBeforeInstall);
|
||||
window.removeEventListener('appinstalled', handleInstalled);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const promptInstall = useCallback(async () => {
|
||||
if (!deferredPrompt) return false;
|
||||
deferredPrompt.prompt();
|
||||
const choice = await deferredPrompt.userChoice;
|
||||
setDeferredPrompt(null);
|
||||
return choice.outcome === 'accepted';
|
||||
}, [deferredPrompt]);
|
||||
|
||||
return {
|
||||
canInstall: !!deferredPrompt && !isInstalled,
|
||||
isInstalled,
|
||||
isIOS: detectIOS() && !isInstalled,
|
||||
isSecureContext: window.isSecureContext,
|
||||
promptInstall,
|
||||
};
|
||||
}
|
||||
585
frontend/src/styles.css
Normal file
585
frontend/src/styles.css
Normal file
@@ -0,0 +1,585 @@
|
||||
:root {
|
||||
--primary: #4f46e5;
|
||||
--primary-dark: #4338ca;
|
||||
--bg: #f8fafc;
|
||||
--card: #ffffff;
|
||||
--input-bg: #ffffff;
|
||||
--text: #1e293b;
|
||||
--muted: #64748b;
|
||||
--border: #e2e8f0;
|
||||
--danger: #ef4444;
|
||||
--success: #22c55e;
|
||||
--accent-bg: #eef2ff;
|
||||
--accent-text: #4338ca;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme='light']) {
|
||||
--bg: #0f172a;
|
||||
--card: #1e293b;
|
||||
--input-bg: #1e293b;
|
||||
--text: #f1f5f9;
|
||||
--muted: #94a3b8;
|
||||
--border: #334155;
|
||||
--accent-bg: #312e81;
|
||||
--accent-text: #c7d2fe;
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
--bg: #0f172a;
|
||||
--card: #1e293b;
|
||||
--input-bg: #1e293b;
|
||||
--text: #f1f5f9;
|
||||
--muted: #94a3b8;
|
||||
--border: #334155;
|
||||
--accent-bg: #312e81;
|
||||
--accent-text: #c7d2fe;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
input, select, button, textarea { font-family: inherit; font-size: 1rem; color: var(--text); }
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
vertical-align: middle;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
flex: 1;
|
||||
padding: 16px 16px 96px;
|
||||
max-width: 560px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-loading {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Bottom nav */
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
background: var(--card);
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 6px 8px calc(6px + env(safe-area-inset-bottom));
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
padding: 6px 4px;
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-icon { font-size: 1.4rem; }
|
||||
|
||||
.fab {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 4px 14px rgba(79, 70, 229, 0.4);
|
||||
margin-top: -28px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fab .material-symbols-outlined { font-size: 30px; }
|
||||
|
||||
/* Auth pages */
|
||||
.auth-page {
|
||||
max-width: 400px;
|
||||
margin: 40px auto;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.auth-subtitle { color: var(--muted); margin-bottom: 24px; }
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.auth-form input, .auth-form select {
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--input-bg);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.password-field { position: relative; }
|
||||
.password-field input { width: 100%; padding-right: 44px; }
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 14px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-primary:disabled { opacity: 0.6; }
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-full { width: 100%; display: block; }
|
||||
|
||||
.theme-toggle { display: flex; gap: 8px; }
|
||||
.theme-toggle .tab { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px; }
|
||||
|
||||
.form-error { color: var(--danger); font-size: 0.9rem; }
|
||||
|
||||
.tabs { display: flex; gap: 8px; margin-bottom: 16px; justify-content: center; }
|
||||
.tab {
|
||||
padding: 10px 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
cursor: pointer;
|
||||
}
|
||||
.tab.active { background: var(--primary); color: white; border-color: var(--primary); }
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--card);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.settlement-tile { text-align: center; }
|
||||
.settlement-tile .amount { font-size: 2rem; font-weight: 700; margin: 8px 0; }
|
||||
.settlement-tile .amount.settled { color: var(--success); }
|
||||
.settlement-tile .amount.owed { color: var(--danger); }
|
||||
.settlement-transactions { display: flex; flex-direction: column; gap: 4px; }
|
||||
.settlement-transactions .amount { font-size: 1.3rem; margin: 0; }
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.summary-grid .label { font-size: 0.75rem; color: var(--muted); }
|
||||
.summary-grid .value { font-size: 1.1rem; font-weight: 600; }
|
||||
|
||||
/* Add expense form */
|
||||
.expense-form { display: flex; flex-direction: column; gap: 16px; }
|
||||
.amount-input {
|
||||
font-size: 2.5rem;
|
||||
text-align: center;
|
||||
border: none;
|
||||
border-bottom: 2px solid var(--border);
|
||||
padding: 12px;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
}
|
||||
.amount-input:focus { outline: none; border-color: var(--primary); }
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field + .field { margin-top: 16px; }
|
||||
.field label { font-size: 0.85rem; color: var(--muted); font-weight: 600; }
|
||||
.field input, .field select, .field textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.category-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.category-chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 4px;
|
||||
border-radius: 12px;
|
||||
border: 2px solid transparent;
|
||||
background: var(--bg);
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.category-chip.selected { border-color: var(--primary); background: var(--accent-bg); color: var(--accent-text); }
|
||||
.category-chip .icon { font-size: 1.4rem; }
|
||||
|
||||
.toggle-row { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.toggle-row .toggle-btn { min-width: 100px; }
|
||||
.toggle-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
border: 2px solid var(--border);
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.toggle-btn.selected { border-color: var(--primary); background: var(--accent-bg); color: var(--accent-text); }
|
||||
|
||||
.split-options { display: flex; flex-direction: column; gap: 8px; }
|
||||
.split-option {
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 2px solid var(--border);
|
||||
cursor: pointer;
|
||||
}
|
||||
.split-option.selected { border-color: var(--primary); background: var(--accent-bg); color: var(--accent-text); }
|
||||
.exact-shares { display: flex; flex-direction: column; gap: 10px; margin-top: 8px; }
|
||||
.exact-shares .field { flex: 1; }
|
||||
|
||||
/* History */
|
||||
.filters { display: flex; flex-direction: column; gap: 10px; margin-bottom: 16px; }
|
||||
.filters select, .filters input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.expense-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--card);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.expense-item .cat-icon {
|
||||
width: 40px; height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.expense-item .details { flex: 1; min-width: 0; }
|
||||
.expense-item .title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.expense-item .meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.expense-item .meta .material-symbols-outlined { font-size: 1rem; }
|
||||
.expense-item .amount-col { text-align: right; }
|
||||
.expense-item .amount { font-weight: 700; }
|
||||
.expense-item .share { font-size: 0.75rem; color: var(--muted); }
|
||||
|
||||
.empty-state { text-align: center; color: var(--muted); padding: 40px 20px; }
|
||||
|
||||
/* Settings */
|
||||
.member-row { display: flex; align-items: center; gap: 10px; padding: 10px 0; }
|
||||
|
||||
.household-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.household-row:last-of-type { border-bottom: none; }
|
||||
.household-row-main { display: flex; flex-direction: column; gap: 2px; }
|
||||
.badge-active {
|
||||
background: var(--accent-bg);
|
||||
color: var(--accent-text);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.section-divider { border: none; border-top: 1px solid var(--border); margin: 16px 0; }
|
||||
.category-manage-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); }
|
||||
.category-manage-row-main { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; cursor: pointer; background: none; border: none; padding: 0; text-align: left; color: inherit; font: inherit; }
|
||||
.category-manage-row-main.editing { color: var(--primary); font-weight: 600; }
|
||||
.category-manage-row .icon { font-size: 1.3rem; }
|
||||
.category-manage-row .name { flex: 1; }
|
||||
.invite-code-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.invite-code-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 3px;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--accent-bg);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
.invite-link-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.85rem;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.invite-code-copy-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.invite-code-hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.invite-code-hint.success { color: var(--success); }
|
||||
.invite-code-hint.error { color: var(--danger); }
|
||||
|
||||
.switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.switch-row .switch-label { display: flex; flex-direction: column; gap: 2px; }
|
||||
.switch-desc {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
.switch-desc .material-symbols-outlined { font-size: 1.1rem; }
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 48px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
.switch-track {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--border);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.switch-track::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
left: 3px;
|
||||
top: 3px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.switch input:checked + .switch-track { background: var(--primary); }
|
||||
.switch input:checked + .switch-track::before { transform: translateX(20px); }
|
||||
|
||||
.page-title { margin-top: 0; }
|
||||
|
||||
.offline-banner {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.offline-banner--syncing { background: #dbeafe; color: #1e40af; }
|
||||
|
||||
.install-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--accent-bg);
|
||||
color: var(--accent-text);
|
||||
padding: 10px 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.install-banner-text { flex: 1; display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
|
||||
.install-banner-btn { padding: 8px 14px; white-space: nowrap; }
|
||||
.inline-icon { font-size: 1rem; }
|
||||
|
||||
.modal-actions { display: flex; gap: 10px; }
|
||||
.modal-actions button { flex: 1; }
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
z-index: 30;
|
||||
}
|
||||
.modal-sheet {
|
||||
background: var(--card);
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
border-radius: 20px 20px 0 0;
|
||||
padding: 20px;
|
||||
}
|
||||
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
|
||||
.confirm-sheet {
|
||||
max-width: 400px;
|
||||
border-radius: 20px;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.confirm-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-bg);
|
||||
color: var(--accent-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 12px;
|
||||
}
|
||||
.confirm-icon .material-symbols-outlined { font-size: 28px; }
|
||||
.confirm-icon.danger { background: #fee2e2; color: var(--danger); }
|
||||
:root[data-theme='dark'] .confirm-icon.danger { background: rgba(239, 68, 68, 0.18); }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme='light']) .confirm-icon.danger { background: rgba(239, 68, 68, 0.18); }
|
||||
}
|
||||
.confirm-sheet h3 { margin: 0 0 8px; }
|
||||
.confirm-message { color: var(--muted); margin: 0 0 20px; font-size: 0.9rem; }
|
||||
.icon-btn { background: none; border: none; font-size: 1.4rem; cursor: pointer; }
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
34
frontend/src/theme/ThemeContext.jsx
Normal file
34
frontend/src/theme/ThemeContext.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||
|
||||
const THEME_KEY = 'ktoco_theme';
|
||||
const ThemeContext = createContext(null);
|
||||
|
||||
function applyTheme(theme) {
|
||||
const root = document.documentElement;
|
||||
if (theme === 'system') {
|
||||
root.removeAttribute('data-theme');
|
||||
} else {
|
||||
root.setAttribute('data-theme', theme);
|
||||
}
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }) {
|
||||
const [theme, setThemeState] = useState(() => localStorage.getItem(THEME_KEY) || 'system');
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
}, [theme]);
|
||||
|
||||
const setTheme = useCallback((t) => {
|
||||
localStorage.setItem(THEME_KEY, t);
|
||||
setThemeState(t);
|
||||
}, []);
|
||||
|
||||
return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
|
||||
return ctx;
|
||||
}
|
||||
Reference in New Issue
Block a user