v1.0.1
Rozliczenia
This commit is contained in:
@@ -35,4 +35,76 @@ router.post('/', (req, res) => {
|
|||||||
res.status(201).json({ transactions: current.transactions });
|
res.status(201).json({ transactions: current.transactions });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function validateManualSettlement(req, res, { fromUserId, toUserId, amount }) {
|
||||||
|
if (!fromUserId || !toUserId || !amount) {
|
||||||
|
res.status(400).json({ error: 'fromUserId, toUserId i amount są wymagane' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (fromUserId === toUserId) {
|
||||||
|
res.status(400).json({ error: 'Płacący i odbiorca muszą być różnymi osobami' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (Number(amount) <= 0) {
|
||||||
|
res.status(400).json({ error: 'Kwota musi być większa od zera' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const memberIds = getMembers(req.household.id).map((m) => m.id);
|
||||||
|
if (!memberIds.includes(fromUserId) || !memberIds.includes(toUserId)) {
|
||||||
|
res.status(400).json({ error: 'Obie osoby muszą być członkami gospodarstwa' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/manual', (req, res) => {
|
||||||
|
const { fromUserId, toUserId, amount } = req.body || {};
|
||||||
|
if (!validateManualSettlement(req, res, { fromUserId, toUserId, amount })) return;
|
||||||
|
|
||||||
|
const settlement = {
|
||||||
|
id: uuid(),
|
||||||
|
household_id: req.household.id,
|
||||||
|
from_user_id: fromUserId,
|
||||||
|
to_user_id: toUserId,
|
||||||
|
amount: Number(amount),
|
||||||
|
};
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO settlements (id, household_id, from_user_id, to_user_id, amount) VALUES (?, ?, ?, ?, ?)'
|
||||||
|
).run(settlement.id, settlement.household_id, settlement.from_user_id, settlement.to_user_id, settlement.amount);
|
||||||
|
|
||||||
|
res.status(201).json({ settlement });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', (req, res) => {
|
||||||
|
const existing = db
|
||||||
|
.prepare('SELECT * FROM settlements WHERE id = ? AND household_id = ?')
|
||||||
|
.get(req.params.id, req.household.id);
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ error: 'Rozliczenie nie znalezione' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromUserId = req.body?.fromUserId ?? existing.from_user_id;
|
||||||
|
const toUserId = req.body?.toUserId ?? existing.to_user_id;
|
||||||
|
const amount = req.body?.amount ?? existing.amount;
|
||||||
|
if (!validateManualSettlement(req, res, { fromUserId, toUserId, amount })) return;
|
||||||
|
|
||||||
|
db.prepare('UPDATE settlements SET from_user_id = ?, to_user_id = ?, amount = ? WHERE id = ?').run(
|
||||||
|
fromUserId,
|
||||||
|
toUserId,
|
||||||
|
Number(amount),
|
||||||
|
existing.id
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ settlement: db.prepare('SELECT * FROM settlements WHERE id = ?').get(existing.id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', (req, res) => {
|
||||||
|
const result = db
|
||||||
|
.prepare('DELETE FROM settlements WHERE id = ? AND household_id = ?')
|
||||||
|
.run(req.params.id, req.household.id);
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return res.status(404).json({ error: 'Rozliczenie nie znalezione' });
|
||||||
|
}
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = { router };
|
module.exports = { router };
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import Onboarding from './pages/Onboarding.jsx';
|
|||||||
import Dashboard from './pages/Dashboard.jsx';
|
import Dashboard from './pages/Dashboard.jsx';
|
||||||
import AddExpense from './pages/AddExpense.jsx';
|
import AddExpense from './pages/AddExpense.jsx';
|
||||||
import History from './pages/History.jsx';
|
import History from './pages/History.jsx';
|
||||||
|
import Settlements from './pages/Settlements.jsx';
|
||||||
import Stats from './pages/Stats.jsx';
|
import Stats from './pages/Stats.jsx';
|
||||||
import Settings from './pages/Settings.jsx';
|
import Settings from './pages/Settings.jsx';
|
||||||
import BottomNav from './components/BottomNav.jsx';
|
import BottomNav from './components/BottomNav.jsx';
|
||||||
import OfflineBanner from './components/OfflineBanner.jsx';
|
import OfflineBanner from './components/OfflineBanner.jsx';
|
||||||
import InstallBanner from './components/InstallBanner.jsx';
|
import InstallBanner from './components/InstallBanner.jsx';
|
||||||
|
import SettingsButton from './components/SettingsButton.jsx';
|
||||||
|
|
||||||
function RequireAuth() {
|
function RequireAuth() {
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
@@ -34,6 +36,7 @@ function Layout() {
|
|||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<OfflineBanner />
|
<OfflineBanner />
|
||||||
<InstallBanner />
|
<InstallBanner />
|
||||||
|
<SettingsButton />
|
||||||
<main className="app-content">
|
<main className="app-content">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
@@ -57,6 +60,7 @@ export default function App() {
|
|||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/add" element={<AddExpense />} />
|
<Route path="/add" element={<AddExpense />} />
|
||||||
<Route path="/history" element={<History />} />
|
<Route path="/history" element={<History />} />
|
||||||
|
<Route path="/settlements" element={<Settlements />} />
|
||||||
<Route path="/stats" element={<Stats />} />
|
<Route path="/stats" element={<Stats />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -227,3 +227,47 @@ export function useSettleUp() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useSettlements() {
|
||||||
|
const { activeHouseholdId } = useHouseholdContext();
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['settlements'],
|
||||||
|
queryFn: () => api.get('/settlements'),
|
||||||
|
select: (d) => d.settlements,
|
||||||
|
enabled: !!activeHouseholdId,
|
||||||
|
});
|
||||||
|
return { ...query, isLoading: !activeHouseholdId || query.isLoading };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateManualSettlement() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload) => api.post('/settlements/manual', payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['balance'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['settlements'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateSettlement() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, ...payload }) => api.put(`/settlements/${id}`, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['balance'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['settlements'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteSettlement() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id) => api.delete(`/settlements/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['balance'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['settlements'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ const items = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const rightItems = [
|
const rightItems = [
|
||||||
|
{ to: '/settlements', label: 'Rozliczenia', icon: 'payments' },
|
||||||
{ to: '/stats', label: 'Statystyki', icon: 'bar_chart' },
|
{ to: '/stats', label: 'Statystyki', icon: 'bar_chart' },
|
||||||
{ to: '/settings', label: 'Ustawienia', icon: 'settings' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function BottomNav() {
|
export default function BottomNav() {
|
||||||
|
|||||||
10
frontend/src/components/SettingsButton.jsx
Normal file
10
frontend/src/components/SettingsButton.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import Icon from './Icon.jsx';
|
||||||
|
|
||||||
|
export default function SettingsButton() {
|
||||||
|
return (
|
||||||
|
<NavLink to="/settings" className="top-settings-btn" aria-label="Ustawienia">
|
||||||
|
<Icon name="settings" />
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
frontend/src/components/SettlementEditModal.jsx
Normal file
92
frontend/src/components/SettlementEditModal.jsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useUpdateSettlement, useDeleteSettlement } from '../api/queries.js';
|
||||||
|
import { useConfirm } from './ConfirmDialogProvider.jsx';
|
||||||
|
import PayerToggle from './PayerToggle.jsx';
|
||||||
|
import Icon from './Icon.jsx';
|
||||||
|
|
||||||
|
export default function SettlementEditModal({ settlement, members, currency, onClose }) {
|
||||||
|
const updateSettlement = useUpdateSettlement();
|
||||||
|
const deleteSettlement = useDeleteSettlement();
|
||||||
|
const confirmDialog = useConfirm();
|
||||||
|
|
||||||
|
const [fromUserId, setFromUserId] = useState(settlement.from_user_id);
|
||||||
|
const [toUserId, setToUserId] = useState(settlement.to_user_id);
|
||||||
|
const [amount, setAmount] = useState(String(settlement.amount));
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const toOptions = members.filter((m) => m.id !== fromUserId);
|
||||||
|
|
||||||
|
function handleFromChange(id) {
|
||||||
|
setFromUserId(id);
|
||||||
|
if (id === toUserId) {
|
||||||
|
const fallback = members.find((m) => m.id !== id);
|
||||||
|
setToUserId(fallback?.id || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setError('');
|
||||||
|
if (!fromUserId || !toUserId || !amount || Number(amount) <= 0) {
|
||||||
|
setError('Wybierz obie osoby i podaj kwotę większą od zera');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await updateSettlement.mutateAsync({ id: settlement.id, fromUserId, toUserId, amount: Number(amount) });
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: 'Usunąć to rozliczenie?',
|
||||||
|
message: 'Saldo zostanie przeliczone tak, jakby ta płatność nigdy nie miała miejsca.',
|
||||||
|
confirmLabel: 'Usuń',
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
await deleteSettlement.mutateAsync(settlement.id);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal-sheet" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>Edytuj rozliczenie</h3>
|
||||||
|
<button className="icon-btn" onClick={onClose}><Icon name="close" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="expense-form">
|
||||||
|
<div className="field">
|
||||||
|
<label>Kto płacił?</label>
|
||||||
|
<PayerToggle members={members} payerId={fromUserId} onChange={handleFromChange} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Komu?</label>
|
||||||
|
<PayerToggle members={toOptions} payerId={toUserId} onChange={setToUserId} />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="amount-input"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
step="0.01"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="btn-danger" onClick={handleDelete} disabled={deleteSettlement.isPending}>
|
||||||
|
<Icon name="delete" /> Usuń
|
||||||
|
</button>
|
||||||
|
<button className="btn-primary" onClick={handleSave} disabled={updateSettlement.isPending}>
|
||||||
|
Zapisz
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
frontend/src/components/SettlementListItem.jsx
Normal file
28
frontend/src/components/SettlementListItem.jsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import Icon from './Icon.jsx';
|
||||||
|
|
||||||
|
function memberName(members, id) {
|
||||||
|
return members.find((m) => m.id === id)?.name || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettlementListItem({ settlement, members, currency, onClick }) {
|
||||||
|
return (
|
||||||
|
<div className="expense-item settlement-item" onClick={onClick}>
|
||||||
|
<div className="cat-icon settlement-icon">
|
||||||
|
<Icon name="sync_alt" />
|
||||||
|
</div>
|
||||||
|
<div className="details">
|
||||||
|
<div className="title">
|
||||||
|
{memberName(members, settlement.from_user_id)} → {memberName(members, settlement.to_user_id)}
|
||||||
|
</div>
|
||||||
|
<div className="meta">
|
||||||
|
<span>{settlement.settled_at.slice(0, 10)}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>Rozliczenie</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="amount-col">
|
||||||
|
<div className="amount">{settlement.amount.toFixed(2)} {currency}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,116 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
const OPTIONS = [
|
const OPTIONS = [
|
||||||
{ value: 'equal', label: 'Po równo (50/50)' },
|
{ value: 'equal', label: 'Po równo (50/50)' },
|
||||||
{ value: 'exact', label: 'Dokładny podział' },
|
{ value: 'exact', label: 'Dokładny podział' },
|
||||||
{ value: 'full', label: 'Całość na jedną osobę' },
|
{ value: 'full', label: 'Całość na jedną osobę' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function round2(n) {
|
||||||
|
return Math.round(n * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function equalSplit(members, amount) {
|
||||||
|
const total = Number(amount) || 0;
|
||||||
|
if (members.length === 0) return {};
|
||||||
|
const base = Math.floor((total / members.length) * 100) / 100;
|
||||||
|
const shares = Object.fromEntries(members.map((m) => [m.id, String(base)]));
|
||||||
|
const distributed = round2(base * members.length);
|
||||||
|
shares[members[members.length - 1].id] = String(round2(base + round2(total - distributed)));
|
||||||
|
return shares;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmptyShares(shares, members) {
|
||||||
|
return members.every((m) => shares[m.id] === undefined || shares[m.id] === '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExactSplitEditor({ members, amount, exactShares, onExactSharesChange }) {
|
||||||
|
const total = Number(amount) || 0;
|
||||||
|
const editableMembers = members.slice(0, -1);
|
||||||
|
const lastMember = members[members.length - 1];
|
||||||
|
|
||||||
|
// Seed a sensible starting point (equal split) the first time "exact" is selected,
|
||||||
|
// so the user adjusts from something reasonable instead of blank/zero fields.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEmptyShares(exactShares, members)) {
|
||||||
|
onExactSharesChange(equalSplit(members, total));
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function handleEditableChange(memberId, rawValue) {
|
||||||
|
const updated = { ...exactShares, [memberId]: rawValue };
|
||||||
|
const sumOthers = editableMembers.reduce((acc, m) => acc + (Number(updated[m.id]) || 0), 0);
|
||||||
|
updated[lastMember.id] = String(round2(total - sumOthers));
|
||||||
|
onExactSharesChange(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastValue = Number(exactShares[lastMember?.id]) || 0;
|
||||||
|
const overAllocated = lastValue < -0.01;
|
||||||
|
|
||||||
|
if (members.length === 2) {
|
||||||
|
const [a, b] = members;
|
||||||
|
const aValue = Number(exactShares[a.id]) || 0;
|
||||||
|
const bValue = Number(exactShares[b.id]) || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="exact-shares">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="split-slider"
|
||||||
|
min={0}
|
||||||
|
max={total || 0}
|
||||||
|
step={0.01}
|
||||||
|
value={aValue}
|
||||||
|
onChange={(e) => handleEditableChange(a.id, e.target.value)}
|
||||||
|
disabled={!total}
|
||||||
|
/>
|
||||||
|
<div className="split-slider-amounts">
|
||||||
|
<div className="field">
|
||||||
|
<label>{a.name}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
step="0.01"
|
||||||
|
value={exactShares[a.id] ?? ''}
|
||||||
|
onChange={(e) => handleEditableChange(a.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>{b.name}</label>
|
||||||
|
<input type="number" value={bValue.toFixed(2)} readOnly disabled />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{overAllocated && <p className="form-error">Suma udziałów przekracza kwotę wydatku</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="exact-shares">
|
||||||
|
{editableMembers.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) => handleEditableChange(m.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{lastMember && (
|
||||||
|
<div className="field">
|
||||||
|
<label>{lastMember.name} (wyliczane automatycznie)</label>
|
||||||
|
<input type="number" value={lastValue.toFixed(2)} readOnly disabled />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{overAllocated && <p className="form-error">Suma udziałów przekracza kwotę wydatku</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function SplitSelector({
|
export default function SplitSelector({
|
||||||
members,
|
members,
|
||||||
amount,
|
amount,
|
||||||
@@ -14,9 +121,6 @@ export default function SplitSelector({
|
|||||||
fullOwedBy,
|
fullOwedBy,
|
||||||
onFullOwedByChange,
|
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 (
|
return (
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Jak dzielimy wydatek?</label>
|
<label>Jak dzielimy wydatek?</label>
|
||||||
@@ -29,22 +133,15 @@ export default function SplitSelector({
|
|||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
|
|
||||||
{opt.value === 'exact' && splitType === 'exact' && (
|
{opt.value === 'exact' && splitType === 'exact' && members.length >= 2 && (
|
||||||
<div className="exact-shares" onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
{members.map((m) => (
|
<ExactSplitEditor
|
||||||
<div className="field" key={m.id}>
|
members={members}
|
||||||
<label>{m.name}</label>
|
amount={amount}
|
||||||
<input
|
exactShares={exactShares}
|
||||||
type="number"
|
onExactSharesChange={onExactSharesChange}
|
||||||
inputMode="decimal"
|
|
||||||
step="0.01"
|
|
||||||
value={exactShares[m.id] ?? ''}
|
|
||||||
onChange={(e) => onExactSharesChange({ ...exactShares, [m.id]: e.target.value })}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
{!exactValid && amount && <p className="form-error">Suma udziałów musi wynosić {amount}</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{opt.value === 'full' && splitType === 'full' && (
|
{opt.value === 'full' && splitType === 'full' && (
|
||||||
|
|||||||
@@ -40,7 +40,11 @@ export default function Dashboard() {
|
|||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button className="btn-primary" disabled={settleUp.isPending} onClick={() => settleUp.mutate()}>
|
<button
|
||||||
|
className="btn-primary btn-center"
|
||||||
|
disabled={settleUp.isPending}
|
||||||
|
onClick={() => settleUp.mutate()}
|
||||||
|
>
|
||||||
{settleUp.isPending ? 'Rozliczanie…' : 'Rozlicz się'}
|
{settleUp.isPending ? 'Rozliczanie…' : 'Rozlicz się'}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useCategories, useExpenses } from '../api/queries.js';
|
import { useCategories, useExpenses, useSettlements } from '../api/queries.js';
|
||||||
import { useHouseholdContext } from '../household/HouseholdContext.jsx';
|
import { useHouseholdContext } from '../household/HouseholdContext.jsx';
|
||||||
import { useAuth } from '../auth/AuthContext.jsx';
|
import { useAuth } from '../auth/AuthContext.jsx';
|
||||||
import ExpenseListItem from '../components/ExpenseListItem.jsx';
|
import ExpenseListItem from '../components/ExpenseListItem.jsx';
|
||||||
import ExpenseEditModal from '../components/ExpenseEditModal.jsx';
|
import ExpenseEditModal from '../components/ExpenseEditModal.jsx';
|
||||||
|
import SettlementListItem from '../components/SettlementListItem.jsx';
|
||||||
|
import SettlementEditModal from '../components/SettlementEditModal.jsx';
|
||||||
|
|
||||||
function monthOptions() {
|
function monthOptions() {
|
||||||
const options = [];
|
const options = [];
|
||||||
@@ -15,6 +17,15 @@ function monthOptions() {
|
|||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortKey(item) {
|
||||||
|
if (item.type === 'expense') {
|
||||||
|
const timePart = item.data.created_at.split(' ')[1] || '00:00:00';
|
||||||
|
return `${item.data.expense_date} ${timePart}`;
|
||||||
|
}
|
||||||
|
const [datePart, timePart] = item.data.settled_at.split(' ');
|
||||||
|
return `${datePart} ${timePart || '00:00:00'}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function History() {
|
export default function History() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { activeHousehold: household } = useHouseholdContext();
|
const { activeHousehold: household } = useHouseholdContext();
|
||||||
@@ -22,7 +33,8 @@ export default function History() {
|
|||||||
const [month, setMonth] = useState('');
|
const [month, setMonth] = useState('');
|
||||||
const [categoryId, setCategoryId] = useState('');
|
const [categoryId, setCategoryId] = useState('');
|
||||||
const [payerId, setPayerId] = useState('');
|
const [payerId, setPayerId] = useState('');
|
||||||
const [editing, setEditing] = useState(null);
|
const [editingExpense, setEditingExpense] = useState(null);
|
||||||
|
const [editingSettlement, setEditingSettlement] = useState(null);
|
||||||
|
|
||||||
const filters = useMemo(() => {
|
const filters = useMemo(() => {
|
||||||
const f = {};
|
const f = {};
|
||||||
@@ -32,11 +44,28 @@ export default function History() {
|
|||||||
return f;
|
return f;
|
||||||
}, [month, categoryId, payerId]);
|
}, [month, categoryId, payerId]);
|
||||||
|
|
||||||
const { data: expenses, isLoading } = useExpenses(filters);
|
const { data: expenses, isLoading: expensesLoading } = useExpenses(filters);
|
||||||
|
const { data: settlements, isLoading: settlementsLoading } = useSettlements();
|
||||||
const members = household?.members || [];
|
const members = household?.members || [];
|
||||||
const currency = household?.currency || 'PLN';
|
const currency = household?.currency || 'PLN';
|
||||||
const categoryById = Object.fromEntries((categories || []).map((c) => [c.id, c]));
|
const categoryById = Object.fromEntries((categories || []).map((c) => [c.id, c]));
|
||||||
const memberById = Object.fromEntries(members.map((m) => [m.id, m]));
|
const memberById = Object.fromEntries(members.map((m) => [m.id, m]));
|
||||||
|
const isLoading = expensesLoading || settlementsLoading;
|
||||||
|
|
||||||
|
const visibleSettlements = useMemo(() => {
|
||||||
|
if (categoryId) return []; // settlements have no category — hide them when filtering by one
|
||||||
|
return (settlements || []).filter((s) => {
|
||||||
|
if (month && !s.settled_at.startsWith(month)) return false;
|
||||||
|
if (payerId && s.from_user_id !== payerId && s.to_user_id !== payerId) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [settlements, month, categoryId, payerId]);
|
||||||
|
|
||||||
|
const items = useMemo(() => {
|
||||||
|
const expenseItems = (expenses || []).map((e) => ({ type: 'expense', data: e }));
|
||||||
|
const settlementItems = visibleSettlements.map((s) => ({ type: 'settlement', data: s }));
|
||||||
|
return [...expenseItems, ...settlementItems].sort((a, b) => (sortKey(a) < sortKey(b) ? 1 : -1));
|
||||||
|
}, [expenses, visibleSettlements]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -52,7 +81,7 @@ export default function History() {
|
|||||||
<select value={categoryId} onChange={(e) => setCategoryId(e.target.value)}>
|
<select value={categoryId} onChange={(e) => setCategoryId(e.target.value)}>
|
||||||
<option value="">Wszystkie kategorie</option>
|
<option value="">Wszystkie kategorie</option>
|
||||||
{(categories || []).map((c) => (
|
{(categories || []).map((c) => (
|
||||||
<option key={c.id} value={c.id}>{c.icon} {c.name}</option>
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<select value={payerId} onChange={(e) => setPayerId(e.target.value)}>
|
<select value={payerId} onChange={(e) => setPayerId(e.target.value)}>
|
||||||
@@ -64,27 +93,46 @@ export default function History() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && <p>Ładowanie…</p>}
|
{isLoading && <p>Ładowanie…</p>}
|
||||||
{!isLoading && (expenses || []).length === 0 && <p className="empty-state">Brak wydatków spełniających filtry</p>}
|
{!isLoading && items.length === 0 && <p className="empty-state">Brak wpisów spełniających filtry</p>}
|
||||||
|
|
||||||
{(expenses || []).map((expense) => (
|
{items.map((item) =>
|
||||||
|
item.type === 'expense' ? (
|
||||||
<ExpenseListItem
|
<ExpenseListItem
|
||||||
key={expense.id}
|
key={item.data.id}
|
||||||
expense={expense}
|
expense={item.data}
|
||||||
category={categoryById[expense.category_id]}
|
category={categoryById[item.data.category_id]}
|
||||||
payer={memberById[expense.payer_id]}
|
payer={memberById[item.data.payer_id]}
|
||||||
currentUserId={user?.id}
|
currentUserId={user?.id}
|
||||||
currency={currency}
|
currency={currency}
|
||||||
onClick={() => setEditing(expense)}
|
onClick={() => setEditingExpense(item.data)}
|
||||||
/>
|
/>
|
||||||
))}
|
) : (
|
||||||
|
<SettlementListItem
|
||||||
|
key={item.data.id}
|
||||||
|
settlement={item.data}
|
||||||
|
members={members}
|
||||||
|
currency={currency}
|
||||||
|
onClick={() => setEditingSettlement(item.data)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
{editing && (
|
{editingExpense && (
|
||||||
<ExpenseEditModal
|
<ExpenseEditModal
|
||||||
expense={editing}
|
expense={editingExpense}
|
||||||
members={members}
|
members={members}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
currency={currency}
|
currency={currency}
|
||||||
onClose={() => setEditing(null)}
|
onClose={() => setEditingExpense(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingSettlement && (
|
||||||
|
<SettlementEditModal
|
||||||
|
settlement={editingSettlement}
|
||||||
|
members={members}
|
||||||
|
currency={currency}
|
||||||
|
onClose={() => setEditingSettlement(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
154
frontend/src/pages/Settlements.jsx
Normal file
154
frontend/src/pages/Settlements.jsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useHouseholdContext } from '../household/HouseholdContext.jsx';
|
||||||
|
import { useBalance, useSettleUp, useSettlements, useCreateManualSettlement } from '../api/queries.js';
|
||||||
|
import PayerToggle from '../components/PayerToggle.jsx';
|
||||||
|
import SettlementListItem from '../components/SettlementListItem.jsx';
|
||||||
|
import SettlementEditModal from '../components/SettlementEditModal.jsx';
|
||||||
|
import Icon from '../components/Icon.jsx';
|
||||||
|
|
||||||
|
function memberName(members, id) {
|
||||||
|
return members.find((m) => m.id === id)?.name || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Settlements() {
|
||||||
|
const { activeHousehold: household } = useHouseholdContext();
|
||||||
|
const { data: balance } = useBalance();
|
||||||
|
const { data: settlements } = useSettlements();
|
||||||
|
const settleUp = useSettleUp();
|
||||||
|
const createManual = useCreateManualSettlement();
|
||||||
|
|
||||||
|
const members = household?.members || [];
|
||||||
|
const currency = household?.currency || 'PLN';
|
||||||
|
|
||||||
|
const [fromUserId, setFromUserId] = useState('');
|
||||||
|
const [toUserId, setToUserId] = useState('');
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [editingSettlement, setEditingSettlement] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fromUserId && members[0]) setFromUserId(members[0].id);
|
||||||
|
if (!toUserId && members[1]) setToUserId(members[1].id);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [members.length]);
|
||||||
|
|
||||||
|
const toOptions = members.filter((m) => m.id !== fromUserId);
|
||||||
|
|
||||||
|
function handleFromChange(id) {
|
||||||
|
setFromUserId(id);
|
||||||
|
if (id === toUserId) {
|
||||||
|
const fallback = members.find((m) => m.id !== id);
|
||||||
|
setToUserId(fallback?.id || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setSuccess(false);
|
||||||
|
if (!fromUserId || !toUserId || !amount || Number(amount) <= 0) {
|
||||||
|
setError('Wybierz obie osoby i podaj kwotę większą od zera');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await createManual.mutateAsync({ fromUserId, toUserId, amount: Number(amount) });
|
||||||
|
setAmount('');
|
||||||
|
setSuccess(true);
|
||||||
|
setTimeout(() => setSuccess(false), 2500);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">Rozliczenia</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>Sugerowane rozliczenie</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 btn-center"
|
||||||
|
disabled={settleUp.isPending}
|
||||||
|
onClick={() => settleUp.mutate()}
|
||||||
|
>
|
||||||
|
{settleUp.isPending ? 'Rozliczanie…' : 'Rozlicz wszystko automatycznie'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3>Zapisz płatność</h3>
|
||||||
|
<form onSubmit={handleSubmit} className="expense-form">
|
||||||
|
<div className="field">
|
||||||
|
<label>Kto płaci?</label>
|
||||||
|
<PayerToggle members={members} payerId={fromUserId} onChange={handleFromChange} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Komu?</label>
|
||||||
|
<PayerToggle members={toOptions} payerId={toUserId} onChange={setToUserId} />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="amount-input"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="0.00"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
/>
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
{success && <p className="invite-code-hint success">Zapisano płatność!</p>}
|
||||||
|
<button type="submit" className="btn-primary btn-full" disabled={createManual.isPending}>
|
||||||
|
{createManual.isPending ? 'Zapisywanie…' : 'Zapisz rozliczenie'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3>Historia rozliczeń</h3>
|
||||||
|
{!settlements && <p>Ładowanie…</p>}
|
||||||
|
{settlements && settlements.length === 0 && (
|
||||||
|
<p className="empty-state">Brak zapisanych rozliczeń</p>
|
||||||
|
)}
|
||||||
|
{(settlements || []).slice(0, 10).map((s) => (
|
||||||
|
<SettlementListItem
|
||||||
|
key={s.id}
|
||||||
|
settlement={s}
|
||||||
|
members={members}
|
||||||
|
currency={currency}
|
||||||
|
onClick={() => setEditingSettlement(s)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editingSettlement && (
|
||||||
|
<SettlementEditModal
|
||||||
|
settlement={editingSettlement}
|
||||||
|
members={members}
|
||||||
|
currency={currency}
|
||||||
|
onClose={() => setEditingSettlement(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -132,6 +132,24 @@ input, select, button, textarea { font-family: inherit; font-size: 1rem; color:
|
|||||||
|
|
||||||
.fab .material-symbols-outlined { font-size: 30px; }
|
.fab .material-symbols-outlined { font-size: 30px; }
|
||||||
|
|
||||||
|
.top-settings-btn {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(12px + env(safe-area-inset-top));
|
||||||
|
right: 12px;
|
||||||
|
z-index: 15;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--text);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.top-settings-btn.active { color: var(--primary); }
|
||||||
|
|
||||||
/* Auth pages */
|
/* Auth pages */
|
||||||
.auth-page {
|
.auth-page {
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
@@ -214,6 +232,7 @@ input, select, button, textarea { font-family: inherit; font-size: 1rem; color:
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-full { width: 100%; display: block; }
|
.btn-full { width: 100%; display: block; }
|
||||||
|
.btn-center { width: fit-content; margin: 0 auto; }
|
||||||
|
|
||||||
.theme-toggle { display: flex; gap: 8px; }
|
.theme-toggle { display: flex; gap: 8px; }
|
||||||
.theme-toggle .tab { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px; }
|
.theme-toggle .tab { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px; }
|
||||||
@@ -330,6 +349,39 @@ input, select, button, textarea { font-family: inherit; font-size: 1rem; color:
|
|||||||
.exact-shares { display: flex; flex-direction: column; gap: 10px; margin-top: 8px; }
|
.exact-shares { display: flex; flex-direction: column; gap: 10px; margin-top: 8px; }
|
||||||
.exact-shares .field { flex: 1; }
|
.exact-shares .field { flex: 1; }
|
||||||
|
|
||||||
|
.split-slider {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--border);
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
outline: none;
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
}
|
||||||
|
.split-slider::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
border: 3px solid var(--card);
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.split-slider::-moz-range-thumb {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
border: 3px solid var(--card);
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.split-slider-amounts { display: flex; gap: 10px; }
|
||||||
|
.split-slider-amounts .field { flex: 1; }
|
||||||
|
|
||||||
/* History */
|
/* History */
|
||||||
.filters { display: flex; flex-direction: column; gap: 10px; margin-bottom: 16px; }
|
.filters { display: flex; flex-direction: column; gap: 10px; margin-bottom: 16px; }
|
||||||
.filters select, .filters input {
|
.filters select, .filters input {
|
||||||
@@ -373,6 +425,10 @@ input, select, button, textarea { font-family: inherit; font-size: 1rem; color:
|
|||||||
.expense-item .amount { font-weight: 700; }
|
.expense-item .amount { font-weight: 700; }
|
||||||
.expense-item .share { font-size: 0.75rem; color: var(--muted); }
|
.expense-item .share { font-size: 0.75rem; color: var(--muted); }
|
||||||
|
|
||||||
|
.settlement-item { cursor: pointer; background: var(--accent-bg); }
|
||||||
|
.settlement-item .settlement-icon { background: transparent; color: var(--accent-text); }
|
||||||
|
.settlement-item .title { color: var(--accent-text); }
|
||||||
|
|
||||||
.empty-state { text-align: center; color: var(--muted); padding: 40px 20px; }
|
.empty-state { text-align: center; color: var(--muted); padding: 40px 20px; }
|
||||||
|
|
||||||
/* Settings */
|
/* Settings */
|
||||||
|
|||||||
Reference in New Issue
Block a user