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} + /> +
+
+ + handleEditableChange(a.id, e.target.value)} + /> +
+
+ + +
+
+ {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/History.jsx b/frontend/src/pages/History.jsx index 0cf75fb..8ebfbd0 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -1,9 +1,11 @@ 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 { useAuth } from '../auth/AuthContext.jsx'; import ExpenseListItem from '../components/ExpenseListItem.jsx'; import ExpenseEditModal from '../components/ExpenseEditModal.jsx'; +import SettlementListItem from '../components/SettlementListItem.jsx'; +import SettlementEditModal from '../components/SettlementEditModal.jsx'; function monthOptions() { const options = []; @@ -15,6 +17,15 @@ function monthOptions() { 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() { const { user } = useAuth(); const { activeHousehold: household } = useHouseholdContext(); @@ -22,7 +33,8 @@ export default function History() { const [month, setMonth] = useState(''); const [categoryId, setCategoryId] = useState(''); const [payerId, setPayerId] = useState(''); - const [editing, setEditing] = useState(null); + const [editingExpense, setEditingExpense] = useState(null); + const [editingSettlement, setEditingSettlement] = useState(null); const filters = useMemo(() => { const f = {}; @@ -32,11 +44,28 @@ export default function History() { return f; }, [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 currency = household?.currency || 'PLN'; const categoryById = Object.fromEntries((categories || []).map((c) => [c.id, c])); 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 (
@@ -52,7 +81,7 @@ export default function History() { setAmount(e.target.value)} + /> + {error &&

{error}

} + {success &&

Zapisano płatność!

} + + +
+ +
+

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 */