v1.0.1
Rozliczenia
This commit is contained in:
@@ -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() {
|
||||
<div className="app-shell">
|
||||
<OfflineBanner />
|
||||
<InstallBanner />
|
||||
<SettingsButton />
|
||||
<main className="app-content">
|
||||
<Outlet />
|
||||
</main>
|
||||
@@ -57,6 +60,7 @@ export default function App() {
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/add" element={<AddExpense />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/settlements" element={<Settlements />} />
|
||||
<Route path="/stats" element={<Stats />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</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 = [
|
||||
{ to: '/settlements', label: 'Rozliczenia', icon: 'payments' },
|
||||
{ to: '/stats', label: 'Statystyki', icon: 'bar_chart' },
|
||||
{ to: '/settings', label: 'Ustawienia', icon: 'settings' },
|
||||
];
|
||||
|
||||
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 = [
|
||||
{ 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 (
|
||||
<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({
|
||||
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 (
|
||||
<div className="field">
|
||||
<label>Jak dzielimy wydatek?</label>
|
||||
@@ -29,21 +133,14 @@ export default function SplitSelector({
|
||||
>
|
||||
{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>}
|
||||
{opt.value === 'exact' && splitType === 'exact' && members.length >= 2 && (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ExactSplitEditor
|
||||
members={members}
|
||||
amount={amount}
|
||||
exactShares={exactShares}
|
||||
onExactSharesChange={onExactSharesChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -40,7 +40,11 @@ export default function Dashboard() {
|
||||
</p>
|
||||
))}
|
||||
</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ę'}
|
||||
</button>
|
||||
</>
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
@@ -52,7 +81,7 @@ export default function History() {
|
||||
<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>
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={payerId} onChange={(e) => setPayerId(e.target.value)}>
|
||||
@@ -64,27 +93,46 @@ export default function History() {
|
||||
</div>
|
||||
|
||||
{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) => (
|
||||
<ExpenseListItem
|
||||
key={expense.id}
|
||||
expense={expense}
|
||||
category={categoryById[expense.category_id]}
|
||||
payer={memberById[expense.payer_id]}
|
||||
currentUserId={user?.id}
|
||||
currency={currency}
|
||||
onClick={() => setEditing(expense)}
|
||||
/>
|
||||
))}
|
||||
{items.map((item) =>
|
||||
item.type === 'expense' ? (
|
||||
<ExpenseListItem
|
||||
key={item.data.id}
|
||||
expense={item.data}
|
||||
category={categoryById[item.data.category_id]}
|
||||
payer={memberById[item.data.payer_id]}
|
||||
currentUserId={user?.id}
|
||||
currency={currency}
|
||||
onClick={() => setEditingExpense(item.data)}
|
||||
/>
|
||||
) : (
|
||||
<SettlementListItem
|
||||
key={item.data.id}
|
||||
settlement={item.data}
|
||||
members={members}
|
||||
currency={currency}
|
||||
onClick={() => setEditingSettlement(item.data)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
{editingExpense && (
|
||||
<ExpenseEditModal
|
||||
expense={editing}
|
||||
expense={editingExpense}
|
||||
members={members}
|
||||
categories={categories}
|
||||
currency={currency}
|
||||
onClose={() => setEditing(null)}
|
||||
onClose={() => setEditingExpense(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingSettlement && (
|
||||
<SettlementEditModal
|
||||
settlement={editingSettlement}
|
||||
members={members}
|
||||
currency={currency}
|
||||
onClose={() => setEditingSettlement(null)}
|
||||
/>
|
||||
)}
|
||||
</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; }
|
||||
|
||||
.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 */
|
||||
|
||||
Reference in New Issue
Block a user