diff --git a/backend/src/routes/settlements.js b/backend/src/routes/settlements.js
index d07a3c3..89a910e 100644
--- a/backend/src/routes/settlements.js
+++ b/backend/src/routes/settlements.js
@@ -35,4 +35,76 @@ router.post('/', (req, res) => {
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 };
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 871cb31..460fb8b 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -10,11 +10,13 @@ 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 Settlements from './pages/Settlements.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';
+import SettingsButton from './components/SettingsButton.jsx';
function RequireAuth() {
const { isAuthenticated } = useAuth();
@@ -34,6 +36,7 @@ function Layout() {
+
@@ -57,6 +60,7 @@ export default function App() {
} />
} />
} />
+
} />
} />
} />
diff --git a/frontend/src/api/queries.js b/frontend/src/api/queries.js
index 9b55869..e41eb96 100644
--- a/frontend/src/api/queries.js
+++ b/frontend/src/api/queries.js
@@ -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'] });
+ },
+ });
+}
diff --git a/frontend/src/components/BottomNav.jsx b/frontend/src/components/BottomNav.jsx
index b52d048..996b177 100644
--- a/frontend/src/components/BottomNav.jsx
+++ b/frontend/src/components/BottomNav.jsx
@@ -7,8 +7,8 @@ const items = [
];
const rightItems = [
+ { to: '/settlements', label: 'Rozliczenia', icon: 'payments' },
{ to: '/stats', label: 'Statystyki', icon: 'bar_chart' },
- { to: '/settings', label: 'Ustawienia', icon: 'settings' },
];
export default function BottomNav() {
diff --git a/frontend/src/components/SettingsButton.jsx b/frontend/src/components/SettingsButton.jsx
new file mode 100644
index 0000000..d109998
--- /dev/null
+++ b/frontend/src/components/SettingsButton.jsx
@@ -0,0 +1,10 @@
+import { NavLink } from 'react-router-dom';
+import Icon from './Icon.jsx';
+
+export default function SettingsButton() {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/components/SettlementEditModal.jsx b/frontend/src/components/SettlementEditModal.jsx
new file mode 100644
index 0000000..b7f9277
--- /dev/null
+++ b/frontend/src/components/SettlementEditModal.jsx
@@ -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 (
+
+
e.stopPropagation()}>
+
+
Edytuj rozliczenie
+
+
+
+
+
+
+
setAmount(e.target.value)}
+ />
+
+ {error &&
{error}
}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/SettlementListItem.jsx b/frontend/src/components/SettlementListItem.jsx
new file mode 100644
index 0000000..d5cb648
--- /dev/null
+++ b/frontend/src/components/SettlementListItem.jsx
@@ -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 (
+
+
+
+
+
+
+ {memberName(members, settlement.from_user_id)} → {memberName(members, settlement.to_user_id)}
+
+
+ {settlement.settled_at.slice(0, 10)}
+ ·
+ Rozliczenie
+
+
+
+
{settlement.amount.toFixed(2)} {currency}
+
+
+ );
+}
diff --git a/frontend/src/components/SplitSelector.jsx b/frontend/src/components/SplitSelector.jsx
index 1669ae0..e24aab5 100644
--- a/frontend/src/components/SplitSelector.jsx
+++ b/frontend/src/components/SplitSelector.jsx
@@ -1,9 +1,116 @@
+import { useEffect } from 'react';
+
const OPTIONS = [
{ value: 'equal', label: 'Po równo (50/50)' },
{ value: 'exact', label: 'Dokładny podział' },
{ 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 (
+
+
handleEditableChange(a.id, e.target.value)}
+ disabled={!total}
+ />
+
+ {overAllocated &&
Suma udziałów przekracza kwotę wydatku
}
+
+ );
+ }
+
+ return (
+
+ {editableMembers.map((m) => (
+
+
+ handleEditableChange(m.id, e.target.value)}
+ />
+
+ ))}
+ {lastMember && (
+
+
+
+
+ )}
+ {overAllocated &&
Suma udziałów przekracza kwotę wydatku
}
+
+ );
+}
+
export default function SplitSelector({
members,
amount,
@@ -14,9 +121,6 @@ export default function SplitSelector({
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 (
@@ -29,21 +133,14 @@ export default function SplitSelector({
>
{opt.label}
- {opt.value === 'exact' && splitType === 'exact' && (
-
e.stopPropagation()}>
- {members.map((m) => (
-
-
- onExactSharesChange({ ...exactShares, [m.id]: e.target.value })}
- />
-
- ))}
- {!exactValid && amount &&
Suma udziałów musi wynosić {amount}
}
+ {opt.value === 'exact' && splitType === 'exact' && members.length >= 2 && (
+
e.stopPropagation()}>
+
)}
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
index 6102982..f898a78 100644
--- a/frontend/src/pages/Dashboard.jsx
+++ b/frontend/src/pages/Dashboard.jsx
@@ -40,7 +40,11 @@ export default function Dashboard() {
))}
-
diff --git a/frontend/src/pages/Settlements.jsx b/frontend/src/pages/Settlements.jsx
new file mode 100644
index 0000000..a584c39
--- /dev/null
+++ b/frontend/src/pages/Settlements.jsx
@@ -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 (
+
+
Rozliczenia
+
+
+ {!balance ? (
+
Ładowanie…
+ ) : balance.settled ? (
+ <>
+
Rozliczenia
+
+ Jesteście na czysto!
+
+ >
+ ) : (
+ <>
+
Sugerowane rozliczenie
+
+ {balance.transactions.map((tx, i) => (
+
+ {memberName(members, tx.from)} → {memberName(members, tx.to)}: {tx.amount.toFixed(2)} {currency}
+
+ ))}
+
+
settleUp.mutate()}
+ >
+ {settleUp.isPending ? 'Rozliczanie…' : 'Rozlicz wszystko automatycznie'}
+
+ >
+ )}
+
+
+
+
+
+
Historia rozliczeń
+ {!settlements &&
Ładowanie…
}
+ {settlements && settlements.length === 0 && (
+
Brak zapisanych rozliczeń
+ )}
+ {(settlements || []).slice(0, 10).map((s) => (
+
setEditingSettlement(s)}
+ />
+ ))}
+
+
+ {editingSettlement && (
+
setEditingSettlement(null)}
+ />
+ )}
+
+ );
+}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 8935220..892ef1c 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -132,6 +132,24 @@ input, select, button, textarea { font-family: inherit; font-size: 1rem; color:
.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-page {
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-center { width: fit-content; margin: 0 auto; }
.theme-toggle { display: flex; gap: 8px; }
.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 .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 */
.filters { display: flex; flex-direction: column; gap: 10px; margin-bottom: 16px; }
.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 .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; }
/* Settings */