Rozliczenia
This commit is contained in:
2026-07-11 00:05:44 +02:00
parent c4a692b5d2
commit af44af5027
12 changed files with 648 additions and 39 deletions

View File

@@ -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 };

View File

@@ -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>

View File

@@ -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'] });
},
});
}

View File

@@ -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() {

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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,22 +133,15 @@ 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 })}
{opt.value === 'exact' && splitType === 'exact' && members.length >= 2 && (
<div onClick={(e) => e.stopPropagation()}>
<ExactSplitEditor
members={members}
amount={amount}
exactShares={exactShares}
onExactSharesChange={onExactSharesChange}
/>
</div>
))}
{!exactValid && amount && <p className="form-error">Suma udziałów musi wynosić {amount}</p>}
</div>
)}
{opt.value === 'full' && splitType === 'full' && (

View File

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

View File

@@ -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) => (
{items.map((item) =>
item.type === 'expense' ? (
<ExpenseListItem
key={expense.id}
expense={expense}
category={categoryById[expense.category_id]}
payer={memberById[expense.payer_id]}
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={() => 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
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>

View 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>
);
}

View File

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