Files
income_calculator/frontend/dist/index.html
2026-02-23 16:49:24 +03:00

580 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Личные финансы</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
:root {
--bg: #0f0f12;
--card: #1a1a1f;
--text: #e4e4e7;
--muted: #71717a;
--accent: #22c55e;
--negative: #ef4444;
--border: #27272a;
}
* { box-sizing: border-box; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
margin: 0;
padding: 1.5rem;
line-height: 1.5;
}
h1 { font-size: 1.5rem; margin: 0 0 1.5rem; font-weight: 600; }
h2 { font-size: 1.1rem; margin: 0 0 0.75rem; color: var(--muted); font-weight: 500; }
.nav { display: flex; gap: 1rem; margin-bottom: 1.5rem; }
.nav a { color: var(--muted); text-decoration: none; }
.nav a.active { color: var(--accent); }
.nav a:hover { color: var(--text); }
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.25rem;
margin-bottom: 1rem;
}
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 2rem; }
.metric { text-align: center; min-width: 0; padding: 0 0.5rem; }
.metric .value { font-size: 1.5rem; font-weight: 700; }
.metric .value.positive { color: var(--accent); }
.metric .value.negative { color: var(--negative); }
.metric .label { font-size: 0.8rem; color: var(--muted); margin-top: 0.25rem; }
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
th, td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid var(--border); }
th { color: var(--muted); font-weight: 500; }
.amount.in { color: var(--accent); }
.amount.out { color: var(--negative); }
.page { display: none; }
.page.active { display: block; }
button, .btn {
background: var(--accent);
color: var(--bg);
border: none;
padding: 0.5rem 1rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
}
button.secondary { background: var(--border); color: var(--text); }
button:hover { opacity: 0.9; }
input[type="file"] { margin: 0.5rem 0; }
.banks-list { list-style: none; padding: 0; margin: 0; }
.banks-list li {
display: flex; align-items: center; justify-content: space-between;
padding: 0.5rem 0; border-bottom: 1px solid var(--border);
}
.charts-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 700px) { .charts-row { grid-template-columns: 1fr; } }
.chart-wrap { height: 220px; }
.error { color: var(--negative); font-size: 0.9rem; margin-top: 0.5rem; }
.tx-excluded { opacity: 0.6; }
input[type="checkbox"] { cursor: pointer; accent-color: var(--accent); }
</style>
</head>
<body>
<h1>Личные финансы</h1>
<nav class="nav">
<a href="#" data-page="dashboard" class="active">Дашборд</a>
<a href="#" data-page="settings">Настройки</a>
</nav>
<div id="page-dashboard" class="page active">
<div class="card filters-card">
<h2>Фильтры</h2>
<div class="filters-row" style="display:flex; flex-wrap:wrap; gap:1.5rem; align-items:flex-end;">
<div>
<label style="display:block; font-size:0.85rem; color:var(--muted); margin-bottom:0.25rem;">Период с</label>
<input type="date" id="filter-period-start" style="background:var(--bg); color:var(--text); border:1px solid var(--border); padding:0.4rem; border-radius:6px;" />
</div>
<div>
<label style="display:block; font-size:0.85rem; color:var(--muted); margin-bottom:0.25rem;">по</label>
<input type="date" id="filter-period-end" style="background:var(--bg); color:var(--text); border:1px solid var(--border); padding:0.4rem; border-radius:6px;" />
</div>
<div>
<span style="font-size:0.85rem; color:var(--muted); margin-right:0.5rem;">Банки:</span>
<span id="filter-banks"></span>
</div>
<label style="display:flex; align-items:center; gap:0.5rem; white-space:nowrap;">
<input type="checkbox" id="exclude-transfers-cb" />
<span>Не учитывать переводы</span>
</label>
<button type="button" id="filter-apply" class="secondary" style="padding:0.4rem 0.75rem;">Применить</button>
<button type="button" id="filter-reset" class="secondary" style="padding:0.4rem 0.75rem;">Сбросить</button>
</div>
</div>
<div class="card">
<h2>Сводка</h2>
<div class="grid" id="summary-metrics"></div>
</div>
<div class="charts-row">
<div class="card">
<h2>Доход и расход по месяцам</h2>
<div class="chart-wrap"><canvas id="chart-income-expense"></canvas></div>
</div>
<div class="card">
<h2>Динамика остатка</h2>
<div class="chart-wrap"><canvas id="chart-balance"></canvas></div>
</div>
<div class="card">
<h2>Динамика копилки</h2>
<div class="chart-wrap"><canvas id="chart-savings"></canvas></div>
</div>
</div>
<div class="card">
<h2>Все операции</h2>
<div class="pagination-controls" style="display:flex; align-items:center; gap:1rem; margin-bottom:0.75rem; flex-wrap:wrap;">
<span>На странице:</span>
<select id="page-size" style="background:var(--card); color:var(--text); border:1px solid var(--border); padding:0.35rem 0.5rem; border-radius:6px;">
<option value="50">50</option>
<option value="100">100</option>
</select>
<span id="pagination-info" style="color:var(--muted); font-size:0.9rem;"></span>
<button type="button" id="btn-prev" class="secondary" style="padding:0.35rem 0.75rem;">Назад</button>
<button type="button" id="btn-next" class="secondary" style="padding:0.35rem 0.75rem;">Вперёд</button>
</div>
<div id="transactions-table"></div>
</div>
</div>
<div id="page-settings" class="page">
<div class="card">
<h2>Зарплатный банк</h2>
<p class="muted" style="font-size:0.9rem; color: var(--muted);">Входящие на выбранный банк считаются доходом.</p>
<ul class="banks-list" id="banks-list"></ul>
</div>
<div class="card">
<h2>Доходы</h2>
<p class="muted" style="font-size:0.9rem; color: var(--muted);">Учёт дохода в сводке и на графиках.</p>
<label style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.75rem;">
<input type="checkbox" id="income-only-salary-card" />
<span>Считать доходы только по зарплатной карте</span>
</label>
<div id="income-salary-card-wrap" style="display:none; margin-top:0.5rem;">
<label style="font-size:0.9rem; color:var(--muted);">Зарплатная карта:</label>
<select id="income-salary-account" style="background:var(--bg); color:var(--text); border:1px solid var(--border); padding:0.4rem; border-radius:6px; margin-left:0.5rem; min-width:200px;">
<option value="">— выбрать карту —</option>
</select>
</div>
<button type="button" id="btn-save-income-settings" class="secondary" style="margin-top:0.75rem;">Сохранить</button>
</div>
<div class="card">
<h2>Импорт выписок</h2>
<p>Загрузите PDF файл (Т-MM-YY.pdf) или импортируйте из папки «Выписки банков».</p>
<input type="file" id="file-input" accept=".pdf" />
<button type="button" id="btn-upload">Загрузить файл</button>
<span class="error" id="upload-error"></span>
<div style="margin-top:1rem;">
<button type="button" id="btn-import-folder" class="secondary">Импорт из папки</button>
<span id="import-result"></span>
<p style="font-size:0.85rem; color:var(--muted); margin-top:0.5rem;">Если добавлено 0 при повторном нажатии — перезапустите сервер (uvicorn) и нажмите снова.</p>
</div>
</div>
</div>
<script>
const API = '';
async function get(url) {
const r = await fetch(API + url);
if (!r.ok) throw new Error(await r.text());
return r.json();
}
async function put(url) {
const r = await fetch(API + url, { method: 'PUT' });
if (!r.ok) throw new Error(await r.text());
return r.json();
}
async function patch(url, body) {
const r = await fetch(API + url, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!r.ok) throw new Error(await r.text());
return r.json();
}
function fmtMoney(n) {
return new Intl.NumberFormat('ru-RU', { style: 'decimal', minimumFractionDigits: 2 }).format(n);
}
function fmtDate(s) {
if (!s) return '';
return s.slice(0, 10).split('-').reverse().join('.');
}
let chartIncomeExpense, chartBalance, chartSavings;
let filterPeriodStart = '';
let filterPeriodEnd = '';
let filterBankIds = [];
function buildFilterQuery() {
const p = [];
if (filterPeriodStart) p.push('period_start=' + encodeURIComponent(filterPeriodStart));
if (filterPeriodEnd) p.push('period_end=' + encodeURIComponent(filterPeriodEnd));
if (filterBankIds.length) p.push('bank_ids=' + filterBankIds.join(','));
return p.length ? '?' + p.join('&') : '';
}
async function loadSummary() {
const data = await get('/api/balance/summary' + buildFilterQuery());
const el = document.getElementById('summary-metrics');
el.innerHTML = `
<div class="metric">
<div class="value ${data.total_balance >= 0 ? 'positive' : 'negative'}">${fmtMoney(data.total_balance)} ₽</div>
<div class="label">Суммарный баланс</div>
</div>
<div class="metric">
<div class="value positive">${fmtMoney(data.income)} ₽</div>
<div class="label">Доход (зарплатный счёт)</div>
</div>
<div class="metric">
<div class="value positive">${fmtMoney(data.debit)} ₽</div>
<div class="label">Дебет (приход)</div>
</div>
<div class="metric">
<div class="value negative">${fmtMoney(data.credit)} ₽</div>
<div class="label">Кредит (расход)</div>
</div>
<div class="metric">
<div class="value positive">${fmtMoney(data.savings_balance ?? 0)} ₽</div>
<div class="label">Копилка (накопления)</div>
</div>
<div class="metric">
<div class="value ${(data.net_flow ?? 0) >= 0 ? 'positive' : 'negative'}">${fmtMoney(data.net_flow ?? 0)} ₽</div>
<div class="label">За период: ${(data.net_flow ?? 0) >= 0 ? 'накопление' : 'траты'}</div>
</div>
`;
}
let transactionsPage = 0;
let transactionsPageSize = 50;
async function loadTransactions() {
const offset = transactionsPage * transactionsPageSize;
const base = buildFilterQuery();
const data = await get('/api/transactions' + (base ? base + '&' : '?') + 'limit=' + transactionsPageSize + '&offset=' + offset);
const items = data.items || [];
const total = data.total ?? 0;
const el = document.getElementById('transactions-table');
const infoEl = document.getElementById('pagination-info');
if (!items.length && total === 0) {
el.innerHTML = '<p style="color:var(--muted)">Нет операций. Импортируйте выписки в Настройках.</p>';
infoEl.textContent = '';
document.getElementById('btn-prev').disabled = true;
document.getElementById('btn-next').disabled = true;
return;
}
const from = offset + 1;
const to = Math.min(offset + transactionsPageSize, total);
infoEl.textContent = 'Показано ' + from + '' + to + ' из ' + total;
document.getElementById('btn-prev').disabled = transactionsPage === 0;
document.getElementById('btn-next').disabled = offset + items.length >= total;
el.innerHTML = `
<table>
<thead><tr><th title="Учитывать в расчёте баланса"><input type="checkbox" id="th-include-all" title="В расчёте" /></th><th>Банк</th><th>Дата</th><th>Счёт</th><th>Описание</th><th>Сумма</th></tr></thead>
<tbody>
${items.map(t => {
const included = !t.excluded_from_balance;
return `
<tr class="${included ? '' : 'tx-excluded'}" data-id="${t.id}">
<td><input type="checkbox" class="tx-include-cb" data-id="${t.id}" ${included ? 'checked' : ''} title="Учитывать в расчёте баланса" /></td>
<td>${t.bank_name || t.bank_code || ''}</td>
<td>${fmtDate(t.operation_date)}</td>
<td>***${t.external_id}</td>
<td>${(t.description || '').slice(0, 40)}${(t.description||'').length > 40 ? '…' : ''}</td>
<td class="amount ${t.amount >= 0 ? 'in' : 'out'}">${t.amount >= 0 ? '+' : ''}${fmtMoney(t.amount)} ₽</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`;
el.querySelectorAll('.tx-include-cb').forEach(cb => {
cb.onclick = async () => {
const id = parseInt(cb.dataset.id, 10);
const excluded = !cb.checked;
try {
await patch('/api/transactions/' + id, { excluded_from_balance: excluded });
cb.closest('tr').classList.toggle('tx-excluded', excluded);
loadSummary();
loadCharts();
} catch (e) {
cb.checked = !cb.checked;
console.error(e);
}
};
});
const thAll = document.getElementById('th-include-all');
if (thAll) {
const includedCount = items.filter(t => !t.excluded_from_balance).length;
thAll.checked = includedCount === items.length;
thAll.indeterminate = includedCount > 0 && includedCount < items.length;
thAll.onclick = async () => {
const wantInclude = thAll.checked;
for (const row of el.querySelectorAll('tbody tr')) {
const id = parseInt(row.dataset.id, 10);
const cb = row.querySelector('.tx-include-cb');
if (cb.checked !== wantInclude) {
try {
await patch('/api/transactions/' + id, { excluded_from_balance: !wantInclude });
cb.checked = wantInclude;
row.classList.toggle('tx-excluded', !wantInclude);
} catch (e) { console.error(e); }
}
}
loadSummary();
loadCharts();
};
}
}
function initPagination() {
document.getElementById('page-size').onchange = () => {
transactionsPageSize = parseInt(document.getElementById('page-size').value, 10);
transactionsPage = 0;
loadTransactions();
};
document.getElementById('btn-prev').onclick = () => {
if (transactionsPage > 0) { transactionsPage--; loadTransactions(); }
};
document.getElementById('btn-next').onclick = () => {
transactionsPage++; loadTransactions();
};
}
async function loadCharts() {
const chartQ = buildFilterQuery();
const [byMonth, dynamics, savingsDynamics] = await Promise.all([
get('/api/charts/income-expense' + chartQ),
get('/api/charts/balance-dynamics' + chartQ),
get('/api/charts/savings-dynamics' + chartQ)
]);
const ctx1 = document.getElementById('chart-income-expense').getContext('2d');
if (chartIncomeExpense) chartIncomeExpense.destroy();
chartIncomeExpense = new Chart(ctx1, {
type: 'bar',
data: {
labels: (byMonth || []).map(x => x.month),
datasets: [
{ label: 'Доход', data: (byMonth || []).map(x => x.income), backgroundColor: 'rgba(34, 197, 94, 0.7)' },
{ label: 'Расход', data: (byMonth || []).map(x => x.credit), backgroundColor: 'rgba(239, 68, 68, 0.7)' }
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: { x: { grid: { color: 'rgba(255,255,255,0.06)' } }, y: { grid: { color: 'rgba(255,255,255,0.06)' } } },
plugins: { legend: { labels: { color: '#e4e4e7' } } }
}
});
const ctx2 = document.getElementById('chart-balance').getContext('2d');
if (chartBalance) chartBalance.destroy();
chartBalance = new Chart(ctx2, {
type: 'line',
data: {
labels: (dynamics || []).map(x => x.date),
datasets: [{ label: 'Остаток (накопл.)', data: (dynamics || []).map(x => x.balance), borderColor: '#22c55e', fill: true, tension: 0.2 }]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: { x: { grid: { color: 'rgba(255,255,255,0.06)' } }, y: { grid: { color: 'rgba(255,255,255,0.06)' } } },
plugins: { legend: { labels: { color: '#e4e4e7' } } }
}
});
const ctx3 = document.getElementById('chart-savings').getContext('2d');
if (chartSavings) chartSavings.destroy();
chartSavings = new Chart(ctx3, {
type: 'line',
data: {
labels: (savingsDynamics || []).map(x => x.date),
datasets: [{ label: 'Копилка (накопл.)', data: (savingsDynamics || []).map(x => x.savings), borderColor: '#eab308', fill: true, tension: 0.2 }]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: { x: { grid: { color: 'rgba(255,255,255,0.06)' } }, y: { grid: { color: 'rgba(255,255,255,0.06)' } } },
plugins: { legend: { labels: { color: '#e4e4e7' } } }
}
});
}
async function ensureFilterBanks() {
if (document.querySelector('.filter-bank-cb')) return;
const banks = await get('/api/banks');
const el = document.getElementById('filter-banks');
if (!el || !banks.length) return;
el.innerHTML = banks.map(b => `<label style="margin-right:0.75rem; white-space:nowrap;"><input type="checkbox" class="filter-bank-cb" data-id="${b.id}" checked /> ${b.name}</label>`).join('');
}
function applyFilters() {
filterPeriodStart = document.getElementById('filter-period-start').value || '';
filterPeriodEnd = document.getElementById('filter-period-end').value || '';
const all = document.querySelectorAll('.filter-bank-cb');
const checked = document.querySelectorAll('.filter-bank-cb:checked');
filterBankIds = (all.length && checked.length < all.length) ? Array.from(checked).map(cb => parseInt(cb.dataset.id, 10)) : [];
transactionsPage = 0;
loadDashboard();
}
function resetFilters() {
document.getElementById('filter-period-start').value = '';
document.getElementById('filter-period-end').value = '';
document.querySelectorAll('.filter-bank-cb').forEach(cb => { cb.checked = true; });
filterPeriodStart = '';
filterPeriodEnd = '';
filterBankIds = [];
transactionsPage = 0;
loadDashboard();
}
async function loadExcludeTransfersCheckbox() {
try {
const data = await get('/api/settings/exclude-transfers');
const cb = document.getElementById('exclude-transfers-cb');
if (cb) cb.checked = data.exclude_transfers || false;
} catch (e) {
console.error(e);
}
}
async function loadDashboard() {
try {
await ensureFilterBanks();
await loadExcludeTransfersCheckbox();
await loadSummary();
await loadTransactions();
await loadCharts();
} catch (e) {
console.error(e);
document.getElementById('summary-metrics').innerHTML = '<p class="error">Ошибка загрузки: ' + e.message + '</p>';
}
}
async function loadIncomeSettings() {
const data = await get('/api/settings/income');
const cb = document.getElementById('income-only-salary-card');
const wrapDiv = document.getElementById('income-salary-card-wrap');
const sel = document.getElementById('income-salary-account');
cb.checked = data.count_income_only_salary_card || false;
if (wrapDiv) wrapDiv.style.display = cb.checked ? 'block' : 'none';
sel.innerHTML = '<option value="">— выбрать карту —</option>' +
(data.salary_accounts || []).map(a => `<option value="${a.id}" ${a.id === data.salary_account_id ? 'selected' : ''}>***${a.external_id} ${a.name || ''}</option>`).join('');
if (cb.checked && data.salary_account_id && !sel.querySelector('option[value="' + data.salary_account_id + '"]')) {
const opt = document.createElement('option');
opt.value = data.salary_account_id;
opt.textContent = '*** (id ' + data.salary_account_id + ')';
opt.selected = true;
sel.appendChild(opt);
}
}
async function loadBanks() {
const banks = await get('/api/banks');
const el = document.getElementById('banks-list');
if (!banks.length) {
el.innerHTML = '<li style="color:var(--muted)">Нет банков. Импортируйте выписки Т-банка.</li>';
return;
}
el.innerHTML = banks.map(b => `
<li>
<span>${b.name} (${b.code}) ${b.is_salary ? ' ★ зарплатный' : ''}</span>
${!b.is_salary ? `<button type="button" data-bank-id="${b.id}" class="btn-set-salary secondary">Сделать зарплатным</button>` : ''}
</li>
`).join('');
el.querySelectorAll('.btn-set-salary').forEach(btn => {
btn.onclick = async () => {
await put('/api/banks/salary/' + btn.dataset.bankId);
loadBanks();
loadDashboard();
};
});
}
document.querySelectorAll('.nav a').forEach(a => {
a.onclick = (e) => {
e.preventDefault();
document.querySelectorAll('.nav a').forEach(x => x.classList.remove('active'));
document.querySelectorAll('.page').forEach(x => x.classList.remove('active'));
a.classList.add('active');
document.getElementById('page-' + a.dataset.page).classList.add('active');
if (a.dataset.page === 'settings') { loadBanks(); loadIncomeSettings(); }
else loadDashboard();
};
});
document.getElementById('btn-upload').onclick = async () => {
const input = document.getElementById('file-input');
const err = document.getElementById('upload-error');
if (!input.files?.length) { err.textContent = 'Выберите файл'; return; }
err.textContent = '';
const form = new FormData();
form.append('file', input.files[0]);
try {
const r = await fetch(API + '/api/import/upload', { method: 'POST', body: form });
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.detail || r.statusText);
err.style.color = 'var(--accent)';
err.textContent = data.parsed != null ? `Распознано: ${data.parsed}, добавлено: ${data.added}, дубликатов: ${data.skipped_duplicates}` : `Добавлено: ${data.added}, дубликатов: ${data.skipped_duplicates}`;
input.value = '';
loadDashboard();
} catch (e) {
err.style.color = 'var(--negative)';
err.textContent = e.message;
}
};
document.getElementById('btn-import-folder').onclick = async () => {
const res = document.getElementById('import-result');
res.textContent = ' Загрузка…';
try {
const r = await fetch(API + '/api/import/from-folder', { method: 'POST' });
const d = await r.json();
if (!r.ok) throw new Error(d.detail || r.statusText);
res.style.color = 'var(--accent)';
res.textContent = d.parsed != null ? ` Распознано: ${d.parsed}, добавлено: ${d.added}, дубликатов: ${d.skipped_duplicates}` : ` Добавлено: ${d.added}, дубликатов: ${d.skipped_duplicates}`;
loadDashboard();
loadBanks();
} catch (e) {
res.style.color = 'var(--negative)';
res.textContent = ' ' + e.message;
}
};
document.getElementById('filter-apply').onclick = applyFilters;
document.getElementById('filter-reset').onclick = resetFilters;
document.getElementById('exclude-transfers-cb').onchange = async function() {
try {
await fetch(API + '/api/settings/exclude-transfers', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ exclude_transfers: this.checked })
});
await loadSummary();
await loadCharts();
} catch (e) { console.error(e); }
};
document.getElementById('income-only-salary-card').onchange = function() {
document.getElementById('income-salary-card-wrap').style.display = this.checked ? 'block' : 'none';
};
document.getElementById('btn-save-income-settings').onclick = async function() {
const cb = document.getElementById('income-only-salary-card');
const sel = document.getElementById('income-salary-account');
const accountId = sel.value ? parseInt(sel.value, 10) : null;
try {
const r = await fetch(API + '/api/settings/income', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ count_income_only_salary_card: cb.checked, salary_account_id: accountId })
});
if (!r.ok) throw new Error('Ошибка сохранения');
await loadIncomeSettings();
loadDashboard();
} catch (e) {
console.error(e);
}
};
initPagination();
loadDashboard();
</script>
</body>
</html>