675 lines
46 KiB
HTML
675 lines
46 KiB
HTML
<?php
|
||
/**
|
||
* @package Bicycle
|
||
* @description index.html — дашборд/«Обзор» (главная страница после входа). Дизайн — по
|
||
* согласованному макету (тёмная тема, room-card, dock-навбар), рендерится
|
||
* внутри layout.html через Controller::render().
|
||
*
|
||
* @var array $user Текущий авторизованный пользователь (без пароля)
|
||
* @var object[] $accounts Счета пользователя (accounts, не архивные)
|
||
* @var object[] $archived_accounts Архивные счета пользователя (is_delete=1)
|
||
* @var float $total Сумма счетов с include_in_total=1 в рублях
|
||
* @var float $total_all Сумма всех (не архивных) счетов в рублях, без фильтра include_in_total
|
||
* @var array $fact_month [categorie_id => сумма] — факт за текущий месяц (реальные transactions)
|
||
* @var int|null $pct_expense % бюджета по расходам за месяц (факт/план), null — если план не задан
|
||
* @var float $month_delta Дельта за месяц: доход − расход (может быть 0)
|
||
* @var object[] $goals Цели пользователя + данные счёта-копилки (account_title/account_summa/account_currency)
|
||
* @var array $rates [currency => float|null] курс ЦБ РФ по не-RUB валютам, встреченным среди счетов
|
||
*/
|
||
|
||
use App\Classes\Currency;
|
||
use System\Classes\CSRF;
|
||
|
||
// Цель savings-счёта по account_id — для предзаполнения полей цели в модалке редактирования
|
||
// (см. App/media/js/accounts.js) и для рабочего "Редактировать" в ⋮-меню карточки цели.
|
||
$goalsByAccount = [];
|
||
foreach ($goals as $g) {
|
||
$goalsByAccount[(int)$g->account_id] = $g;
|
||
}
|
||
|
||
// Тип счёта — граница между «Счета» и «Копилки и цели», а не наличие цели: savings — это
|
||
// копилка по определению (см. бюджет_текущий_план.md), с целью или без, поэтому в «Счета»
|
||
// (обычные cash/bank счета) не попадает вообще ни один savings.
|
||
$gridAccounts = array_filter($accounts, static fn ($a) => $a->type_acc !== 'savings');
|
||
$savingsAccounts = array_filter($accounts, static fn ($a) => $a->type_acc === 'savings');
|
||
|
||
// Полные данные счёта по id — карточка копилки (цикл по $savingsAccounts ниже) несёт те же
|
||
// data-* атрибуты, что и room-card, чтобы «Редактировать» работало без поиска несуществующего
|
||
// (скрытого из сетки) room-card в DOM.
|
||
$accountsById = [];
|
||
foreach ($accounts as $a) {
|
||
$accountsById[(int)$a->id] = $a;
|
||
}
|
||
|
||
// 'savings' тут нет намеренно — savings-счета больше не попадают в эту сетку (см. $gridAccounts
|
||
// ниже, они целиком переехали в «Копилки и цели» с другой иконкой, bi-piggy-bank).
|
||
$type_icons = [
|
||
'cash' => 'ic-wallet',
|
||
'bank' => 'ic-card',
|
||
];
|
||
|
||
$type_accents = [
|
||
'cash' => 'var(--teal)',
|
||
'bank' => 'var(--blue)',
|
||
'savings' => 'var(--purple)',
|
||
];
|
||
|
||
$type_labels = [
|
||
'cash' => 'Наличные',
|
||
'bank' => 'Банковский счёт',
|
||
'savings' => 'Копилка',
|
||
];
|
||
|
||
$weekdays = [1 => 'Понедельник', 2 => 'Вторник', 3 => 'Среда', 4 => 'Четверг', 5 => 'Пятница', 6 => 'Суббота', 7 => 'Воскресенье'];
|
||
|
||
$month_names = [
|
||
1 => 'январь', 2 => 'февраль', 3 => 'март', 4 => 'апрель', 5 => 'май', 6 => 'июнь',
|
||
7 => 'июль', 8 => 'август', 9 => 'сентябрь', 10 => 'октябрь', 11 => 'ноябрь', 12 => 'декабрь',
|
||
];
|
||
|
||
$month_names_gen = [
|
||
1 => 'января', 2 => 'февраля', 3 => 'марта', 4 => 'апреля', 5 => 'мая', 6 => 'июня',
|
||
7 => 'июля', 8 => 'августа', 9 => 'сентября', 10 => 'октября', 11 => 'ноября', 12 => 'декабря',
|
||
];
|
||
|
||
$ucfirstMb = static fn (string $s): string => mb_strtoupper(mb_substr($s, 0, 1)) . mb_substr($s, 1);
|
||
|
||
$this->setScript('accounts.js');
|
||
|
||
?>
|
||
|
||
<svg width="0" height="0" style="position:absolute">
|
||
<defs>
|
||
<symbol id="ic-wallet" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<rect x="2" y="6" width="20" height="14" rx="2"/>
|
||
<path d="M16 12h4v4h-4a2 2 0 0 1 0-4Z"/>
|
||
</symbol>
|
||
<symbol id="ic-card" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<rect x="2" y="5" width="20" height="14" rx="2"/>
|
||
<line x1="2" y1="10" x2="22" y2="10"/>
|
||
</symbol>
|
||
<symbol id="ic-move" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<polyline points="5 9 2 12 5 15"/>
|
||
<polyline points="9 5 12 2 15 5"/>
|
||
<polyline points="15 19 12 22 9 19"/>
|
||
<polyline points="19 9 22 12 19 15"/>
|
||
<line x1="2" y1="12" x2="22" y2="12"/>
|
||
<line x1="12" y1="2" x2="12" y2="22"/>
|
||
</symbol>
|
||
<symbol id="ic-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||
</symbol>
|
||
<symbol id="ic-dots" viewBox="0 0 24 24" fill="currentColor">
|
||
<circle cx="12" cy="5" r="1.6"/>
|
||
<circle cx="12" cy="12" r="1.6"/>
|
||
<circle cx="12" cy="19" r="1.6"/>
|
||
</symbol>
|
||
<symbol id="ic-pencil" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M12 20h9"/>
|
||
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>
|
||
</symbol>
|
||
<symbol id="ic-trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M3 6h18"/>
|
||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
|
||
</symbol>
|
||
<symbol id="ic-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M3 10.5 12 3l9 7.5"/>
|
||
<path d="M5 9.5V20a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1V9.5"/>
|
||
</symbol>
|
||
<symbol id="ic-tag" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M12 2 2 12l9 9 11-11V2Z"/>
|
||
<circle cx="7.5" cy="7.5" r="1.5"/>
|
||
</symbol>
|
||
<symbol id="ic-swap" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M7 7h11l-3-3"/>
|
||
<path d="M17 17H6l3 3"/>
|
||
</symbol>
|
||
<symbol id="ic-target" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<circle cx="12" cy="12" r="9"/>
|
||
<circle cx="12" cy="12" r="5"/>
|
||
<circle cx="12" cy="12" r="1.5"/>
|
||
</symbol>
|
||
<symbol id="ic-archive" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<rect x="2" y="4" width="20" height="5" rx="1"/>
|
||
<path d="M4 9v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9"/>
|
||
<line x1="10" y1="13" x2="14" y2="13"/>
|
||
</symbol>
|
||
<symbol id="ic-undo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M3 10h10a5 5 0 0 1 0 10H8"/>
|
||
<path d="M8 5 3 10l5 5"/>
|
||
</symbol>
|
||
<symbol id="ic-bag" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<rect x="2" y="7" width="20" height="14" rx="2"/>
|
||
<path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||
<line x1="2" y1="13" x2="22" y2="13"/>
|
||
</symbol>
|
||
<symbol id="ic-bolt" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M13 2 3 14h9l-1 8 10-12h-9l1-8Z"/>
|
||
</symbol>
|
||
<symbol id="ic-bank" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M3 10 12 4l9 6"/>
|
||
<path d="M5 10v9"/>
|
||
<path d="M10 10v9"/>
|
||
<path d="M14 10v9"/>
|
||
<path d="M19 10v9"/>
|
||
<path d="M3 21h18"/>
|
||
</symbol>
|
||
<symbol id="ic-car" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M5 17V12l2-5h10l2 5v5"/>
|
||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||
<circle cx="7" cy="17" r="2"/>
|
||
<circle cx="17" cy="17" r="2"/>
|
||
</symbol>
|
||
<symbol id="ic-coin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<circle cx="12" cy="12" r="9"/>
|
||
<line x1="12" y1="6" x2="12" y2="18"/>
|
||
<path d="M9 15c0 1.1 1.3 2 3 2s3-.9 3-2-1.3-1.5-3-2-3-.9-3-2 1.3-2 3-2 3 .9 3 2"/>
|
||
</symbol>
|
||
</defs>
|
||
</svg>
|
||
|
||
<div class="dash-header">
|
||
<div class="dash-kicker"><?= $weekdays[(int)date('N')] ?>, <?= (int)date('j') ?> <?= $month_names_gen[(int)date('n')] ?></div>
|
||
<div class="dash-tags">
|
||
<?php $over = ($pct_expense ?? 0) > 100; ?>
|
||
<span class="tag" style="background:color-mix(in srgb, <?= $over ? 'var(--red)' : 'oklch(0.80 0.18 90)' ?> 20%, transparent);color:<?= $over ? 'var(--red)' : 'oklch(0.85 0.14 90)' ?>">Бюджет <?= $pct_expense ?? 0 ?>% потрачено</span>
|
||
<?php $delta_pos = $month_delta >= 0; ?>
|
||
<span class="tag" style="background:color-mix(in srgb, <?= $delta_pos ? 'var(--positive)' : 'var(--red)' ?> 20%, transparent);color:<?= $delta_pos ? 'var(--positive-text)' : 'var(--red)' ?>"><?= $delta_pos ? '↗' : '↘' ?> <?= ($delta_pos && $month_delta > 0 ? '+' : '') . number_format($month_delta, 0, ',', ' ') ?> ₽ за месяц</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="balance-block">
|
||
<div class="balance-label">Доступно сейчас</div>
|
||
<div class="balance num"><?= number_format($total, 0, ',', ' ') ?> ₽</div>
|
||
<div class="balance-total num">Всего по всем счетам <span><?= number_format($total_all, 0, ',', ' ') ?> ₽</span></div>
|
||
</div>
|
||
|
||
<div class="section-label">
|
||
<span>Счета</span>
|
||
<div style="display:flex;gap:0.5rem">
|
||
<button type="button" class="btn-add js-account-reorder-toggle" title="Изменить порядок счетов"><svg class="ico"><use href="#ic-move"/></svg></button>
|
||
<button type="button" class="btn-add js-account-archive-open" title="Архив счетов"><svg class="ico"><use href="#ic-archive"/></svg></button>
|
||
<button type="button" class="btn-add js-account-add" title="Добавить счёт"><svg class="ico"><use href="#ic-plus"/></svg></button>
|
||
</div>
|
||
</div>
|
||
|
||
<?php if (empty($gridAccounts)): ?>
|
||
<div class="empty-state">Пока нет ни одного счёта — нажмите «+», чтобы добавить первый</div>
|
||
<?php else: ?>
|
||
<div class="grid-cards">
|
||
<?php foreach ($gridAccounts as $account): ?>
|
||
<?php
|
||
$accent = $account->color ?: ($type_accents[$account->type_acc] ?? 'var(--blue)');
|
||
$goal = $goalsByAccount[(int)$account->id] ?? null;
|
||
$fx_rate = $account->currency !== 'RUB' ? ($rates[$account->currency] ?? null) : null;
|
||
?>
|
||
<div class="room-card"
|
||
style="--card-accent:<?= htmlspecialchars($accent, ENT_QUOTES, 'UTF-8') ?>"
|
||
data-id="<?= (int)$account->id ?>"
|
||
data-title="<?= htmlspecialchars($account->title, ENT_QUOTES, 'UTF-8') ?>"
|
||
data-type="<?= htmlspecialchars($account->type_acc, ENT_QUOTES, 'UTF-8') ?>"
|
||
data-summa="<?= htmlspecialchars((string)$account->summa, ENT_QUOTES, 'UTF-8') ?>"
|
||
data-currency="<?= htmlspecialchars($account->currency, ENT_QUOTES, 'UTF-8') ?>"
|
||
data-description="<?= htmlspecialchars((string)($account->description ?? ''), ENT_QUOTES, 'UTF-8') ?>"
|
||
data-color="<?= htmlspecialchars((string)($account->color ?? ''), ENT_QUOTES, 'UTF-8') ?>"
|
||
data-privacy="<?= htmlspecialchars($account->privacy ?? 'shared', ENT_QUOTES, 'UTF-8') ?>"
|
||
data-include="<?= $account->include_in_total ? '1' : '' ?>"
|
||
data-goal-target="<?= $goal ? htmlspecialchars((string)$goal->target_summa, ENT_QUOTES, 'UTF-8') : '' ?>"
|
||
data-goal-deadline="<?= $goal ? htmlspecialchars((string)$goal->deadline, ENT_QUOTES, 'UTF-8') : '' ?>">
|
||
<div class="room-card-head">
|
||
<span class="room-icon"><svg class="ico"><use href="#<?= $type_icons[$account->type_acc] ?? 'ic-wallet' ?>"/></svg></span>
|
||
<div class="room-menu">
|
||
<button type="button" class="room-menu-btn">
|
||
<svg class="ico"><use href="#ic-dots"/></svg>
|
||
</button>
|
||
<div class="room-menu-list">
|
||
<a href="#" class="js-account-edit"><svg class="ico ico-sm"><use href="#ic-pencil"/></svg>Редактировать</a>
|
||
<a href="#" class="danger js-account-delete"><svg class="ico ico-sm"><use href="#ic-trash"/></svg>Удалить</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div class="room-title"><?= htmlspecialchars($account->title, ENT_QUOTES, 'UTF-8') ?></div>
|
||
<div class="room-sub"><?= $type_labels[$account->type_acc] ?? '' ?></div>
|
||
</div>
|
||
<div class="room-value num">
|
||
<?= number_format((float)$account->summa, 2, ',', ' ') ?>
|
||
<?= htmlspecialchars($account->currency, ENT_QUOTES, 'UTF-8') ?>
|
||
</div>
|
||
<?php if ($fx_rate !== null): ?>
|
||
<div class="card-meta" style="font-size:11px">1 <?= htmlspecialchars($account->currency, ENT_QUOTES, 'UTF-8') ?> = <?= number_format($fx_rate, 2, ',', ' ') ?> ₽</div>
|
||
<div class="card-meta" style="font-size:11px">≈<?= number_format((float)$account->summa * $fx_rate, 0, ',', ' ') ?> ₽</div>
|
||
<?php endif; ?>
|
||
<div class="acc-dot"><span></span>Обновлён сегодня</div>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php if (!empty($savingsAccounts)): ?>
|
||
<div class="section-label"><span>Копилки и цели</span></div>
|
||
<div class="grid-cards-goals" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:14px;margin-bottom:2rem">
|
||
<?php foreach ($savingsAccounts as $account): ?>
|
||
<?php
|
||
$goal = $goalsByAccount[(int)$account->id] ?? null;
|
||
$accent = $account->color ?: 'var(--purple)';
|
||
$r = 22;
|
||
$circumference = 2 * M_PI * $r;
|
||
|
||
if ($goal) {
|
||
$saved = (float)$account->summa;
|
||
$target = (float)$goal->target_summa;
|
||
$pct = $target > 0 ? min(100, $saved / $target * 100) : 0;
|
||
$offset = $circumference * (1 - $pct / 100);
|
||
}
|
||
?>
|
||
<div class="room-card goal-card" style="padding:16px 18px;flex-direction:row;align-items:center;gap:14px;--card-accent:<?= htmlspecialchars($accent, ENT_QUOTES, 'UTF-8') ?>;position:relative"
|
||
data-id="<?= (int)$account->id ?>"
|
||
<?php if ($goal): ?>data-goal-id="<?= (int)$goal->id ?>"<?php endif; ?>
|
||
data-title="<?= htmlspecialchars($account->title, ENT_QUOTES, 'UTF-8') ?>"
|
||
data-type="<?= htmlspecialchars($account->type_acc, ENT_QUOTES, 'UTF-8') ?>"
|
||
data-summa="<?= htmlspecialchars((string)$account->summa, ENT_QUOTES, 'UTF-8') ?>"
|
||
data-currency="<?= htmlspecialchars($account->currency, ENT_QUOTES, 'UTF-8') ?>"
|
||
data-description="<?= htmlspecialchars((string)($account->description ?? ''), ENT_QUOTES, 'UTF-8') ?>"
|
||
data-color="<?= htmlspecialchars((string)($account->color ?? ''), ENT_QUOTES, 'UTF-8') ?>"
|
||
data-privacy="<?= htmlspecialchars($account->privacy ?? 'shared', ENT_QUOTES, 'UTF-8') ?>"
|
||
data-include="<?= $account->include_in_total ? '1' : '' ?>"
|
||
data-goal-target="<?= $goal ? htmlspecialchars((string)$goal->target_summa, ENT_QUOTES, 'UTF-8') : '' ?>"
|
||
data-goal-deadline="<?= $goal ? htmlspecialchars((string)$goal->deadline, ENT_QUOTES, 'UTF-8') : '' ?>">
|
||
<?php if ($goal): ?>
|
||
<svg width="52" height="52" viewBox="0 0 52 52" style="flex-shrink:0">
|
||
<circle cx="26" cy="26" r="<?= $r ?>" fill="none" stroke="var(--surface-3)" stroke-width="5"/>
|
||
<circle cx="26" cy="26" r="<?= $r ?>" fill="none" stroke="<?= htmlspecialchars($accent, ENT_QUOTES, 'UTF-8') ?>" stroke-width="5" stroke-linecap="round"
|
||
stroke-dasharray="<?= round($circumference, 2) ?>" stroke-dashoffset="<?= round($offset, 2) ?>"
|
||
transform="rotate(-90 26 26)"/>
|
||
<text x="26" y="30" text-anchor="middle" font-size="13" fill="var(--ink)"><?= round($pct) ?>%</text>
|
||
</svg>
|
||
<?php else: ?>
|
||
<span class="room-icon" style="width:52px;height:52px;flex-shrink:0"><i class="bi bi-piggy-bank" style="font-size:1.4em"></i></span>
|
||
<?php endif; ?>
|
||
<div style="flex:1">
|
||
<div class="card-title" style="font-size:15px"><?= htmlspecialchars($account->title, ENT_QUOTES, 'UTF-8') ?></div>
|
||
<div class="card-meta" style="font-size:12px">
|
||
<?php if ($goal): ?>
|
||
<span class="num" style="color:var(--ink)"><?= number_format($saved, 0, ',', ' ') ?></span>
|
||
из
|
||
<span class="num"><?= number_format($target, 0, ',', ' ') ?> <?= htmlspecialchars($account->currency, ENT_QUOTES, 'UTF-8') ?></span>
|
||
<?php else: ?>
|
||
<span class="num" style="color:var(--ink)"><?= number_format((float)$account->summa, 0, ',', ' ') ?> <?= htmlspecialchars($account->currency, ENT_QUOTES, 'UTF-8') ?></span>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
<div class="room-menu">
|
||
<button type="button" class="room-menu-btn"><svg class="ico"><use href="#ic-dots"/></svg></button>
|
||
<div class="room-menu-list">
|
||
<a href="#" class="js-account-edit"><svg class="ico ico-sm"><use href="#ic-pencil"/></svg>Редактировать</a>
|
||
<?php if ($goal): ?>
|
||
<a href="#" class="danger js-goal-delete"><svg class="ico ico-sm"><use href="#ic-trash"/></svg>Удалить цель</a>
|
||
<?php else: ?>
|
||
<a href="#" class="danger js-account-delete"><svg class="ico ico-sm"><use href="#ic-trash"/></svg>Удалить</a>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php
|
||
// Строки плана+факта для секции типа $type (income|expense): у каждой настоящей группы
|
||
// (App\Repositories\CategoryGroupRepository — самостоятельная сущность, не статья с детьми) —
|
||
// заголовок-плашка + строки по её статьям; статьи без группы ($tree[0]) — сразу строками, без
|
||
// плашки. Факт — реальные transactions за месяц ($fact_month, 0 пока операций нет — это
|
||
// корректное значение, не заглушка). Замыкание, не именованная функция — шаблон может быть
|
||
// include'ится не один раз за процесс (например в тестах), именованная функция дала бы
|
||
// "Cannot redeclare".
|
||
$renderBudgetList = static function (array $groups, array $tree, array $plan_month, array $fact_month, string $type): void {
|
||
$type_groups = array_filter($groups, static fn ($g) => $g->type === $type);
|
||
$ungrouped = array_filter($tree[0] ?? [], static fn ($c) => $c->type === $type);
|
||
|
||
if ($type_groups === [] && $ungrouped === []) {
|
||
echo '<div class="empty-state">Нет статей — добавьте их на странице «Статьи»</div>';
|
||
return;
|
||
}
|
||
|
||
// Строка план+факт+прогресс-бар — и для статьи внутри группы, и для статьи без группы.
|
||
// Перерасход (факт > плана) для расходов подсвечивается красным — для доходов это, наоборот,
|
||
// хорошо (получено больше плана), там всегда акцентный цвет.
|
||
$renderRow = static function (object $category, float $plan, float $fact, string $type): void {
|
||
$pct = $plan > 0 ? round($fact / $plan * 100) : ($fact > 0 ? 100 : 0);
|
||
$bar_width = min(100, $pct);
|
||
$over = $type === 'expense' && $fact > $plan && $plan > 0;
|
||
$pct_color = $over ? 'var(--red)' : 'var(--accent-tint)';
|
||
$bar_color = $over ? 'var(--red)' : 'var(--accent)';
|
||
|
||
echo '<div class="stat-row">'
|
||
. '<span class="stat-name">' . htmlspecialchars($category->title, ENT_QUOTES, 'UTF-8') . '</span>'
|
||
. '<div>'
|
||
. '<div class="budget-bar"><div class="budget-bar-fill" style="width:' . $bar_width . '%;background:' . $bar_color . '"></div></div>'
|
||
. '<div class="stat-caption num">' . number_format($fact, 0, ',', ' ') . ' из ' . number_format($plan, 0, ',', ' ') . ' ₽</div>'
|
||
. '</div>'
|
||
. '<div style="text-align:right"><span class="num" style="color:' . $pct_color . '">' . $pct . '%</span></div>'
|
||
. '</div>';
|
||
};
|
||
|
||
echo '<div class="card elev-md pm-wrap">';
|
||
|
||
foreach ($type_groups as $group) {
|
||
$children = $tree[$group->id] ?? [];
|
||
$head_plan = array_sum(array_map(static fn ($c) => $plan_month[$c->id] ?? 0.0, $children));
|
||
|
||
echo '<div class="budget-group-head" style="--card-accent:var(--blue)">'
|
||
. '<span class="budget-group-name">' . htmlspecialchars($group->title, ENT_QUOTES, 'UTF-8') . '</span>'
|
||
. '<span class="budget-group-sum num">' . number_format($head_plan, 0, ',', ' ') . ' ₽</span>'
|
||
. '</div>';
|
||
|
||
foreach ($children as $category) {
|
||
$renderRow($category, $plan_month[$category->id] ?? 0.0, $fact_month[$category->id] ?? 0.0, $type);
|
||
}
|
||
}
|
||
|
||
foreach ($ungrouped as $category) {
|
||
$renderRow($category, $plan_month[$category->id] ?? 0.0, $fact_month[$category->id] ?? 0.0, $type);
|
||
}
|
||
|
||
echo '</div>';
|
||
};
|
||
?>
|
||
|
||
<div class="section-label"><span>Доходы</span></div>
|
||
<?php $renderBudgetList($groups, $tree, $plan_month, $fact_month, 'income'); ?>
|
||
|
||
<div class="section-label"><span>Бюджет на <?= $month_names[$month] ?? '' ?></span></div>
|
||
<?php $renderBudgetList($groups, $tree, $plan_month, $fact_month, 'expense'); ?>
|
||
|
||
<div class="modal fade" id="account-modal" tabindex="-1" data-bs-theme="dark">
|
||
<div class="modal-dialog modal-dialog-scrollable" style="max-width:520px">
|
||
<div class="modal-content" style="background:var(--surface);color:var(--ink);border-radius:var(--radius-lg)">
|
||
<div class="modal-header" style="border-color:var(--surface-3);align-items:flex-start">
|
||
<div style="display:flex;gap:12px;align-items:flex-start">
|
||
<span class="dialog-icon"><svg class="ico" style="width:1.3em;height:1.3em"><use href="#ic-wallet"/></svg></span>
|
||
<div>
|
||
<h5 class="modal-title" id="account-modal-title">Новый счёт</h5>
|
||
<div class="dialog-sub">Расчётный, накопительный, наличные или кредит</div>
|
||
</div>
|
||
</div>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||
</div>
|
||
|
||
<form id="account-form" method="post">
|
||
<?= CSRF::field() ?>
|
||
<input type="hidden" name="id">
|
||
<div class="modal-body">
|
||
<div id="account-form-error" class="alert-error" style="display:none"></div>
|
||
|
||
<div class="dialog-section-label" style="margin-top:0">Тип счёта <span id="acc-type-lock-hint" style="display:none;font-weight:400;text-transform:none;letter-spacing:normal;color:color-mix(in srgb, var(--ink) 55%, transparent)">— нельзя изменить после создания</span></div>
|
||
<div class="pm-tiles-grid" id="acc-type-tiles" style="grid-template-columns:1fr 1fr">
|
||
<div class="pm-tile selected" data-type="bank" data-color="oklch(0.80 0.18 90)" data-icon="ic-card">
|
||
<span class="pm-check">✓</span>
|
||
<span class="pm-tile-icon" style="background:color-mix(in srgb,oklch(0.80 0.18 90) 20%,transparent);color:oklch(0.80 0.18 90)"><svg class="ico" style="width:1.1em;height:1.1em"><use href="#ic-card"/></svg></span>
|
||
<div><div class="pm-tile-title">Расчётный</div><div class="pm-tile-desc">Повседневные траты и доходы</div></div>
|
||
</div>
|
||
<div class="pm-tile" data-type="cash" data-color="var(--positive)" data-icon="ic-wallet">
|
||
<span class="pm-check">✓</span>
|
||
<span class="pm-tile-icon" style="background:color-mix(in srgb,var(--positive) 20%,transparent);color:var(--positive)"><svg class="ico" style="width:1.1em;height:1.1em"><use href="#ic-wallet"/></svg></span>
|
||
<div><div class="pm-tile-title">Наличные</div><div class="pm-tile-desc">Деньги на руках</div></div>
|
||
</div>
|
||
<div class="pm-tile" data-type="savings" data-color="oklch(0.78 0.17 230)" data-icon="ic-bank">
|
||
<span class="pm-check">✓</span>
|
||
<span class="pm-tile-icon" style="background:color-mix(in srgb,oklch(0.78 0.17 230) 20%,transparent);color:oklch(0.78 0.17 230)"><svg class="ico" style="width:1.1em;height:1.1em"><use href="#ic-bank"/></svg></span>
|
||
<div><div class="pm-tile-title">Накопительный</div><div class="pm-tile-desc">Копилка с целью</div></div>
|
||
</div>
|
||
<div class="pm-tile" data-type="credit" data-color="oklch(0.72 0.19 25)" data-icon="ic-card">
|
||
<span class="pm-check">✓</span>
|
||
<span class="pm-tile-icon" style="background:color-mix(in srgb,oklch(0.72 0.19 25) 20%,transparent);color:oklch(0.72 0.19 25)"><svg class="ico" style="width:1.1em;height:1.1em"><use href="#ic-card"/></svg></span>
|
||
<div><div class="pm-tile-title">Кредит / Карта</div><div class="pm-tile-desc">Кредит, ипотека, кредитка</div></div>
|
||
</div>
|
||
</div>
|
||
<input type="hidden" name="type_acc" id="acc-type-hidden" value="bank">
|
||
|
||
<div class="dialog-section-label">Параметры</div>
|
||
<div style="display:flex;gap:10px;align-items:center;position:relative">
|
||
<div style="position:relative">
|
||
<button type="button" id="acc-color-swatch" style="background:oklch(0.80 0.18 90);width:44px;height:44px;border-radius:50%;border:2px solid var(--divider);cursor:pointer;flex-shrink:0"></button>
|
||
<div id="acc-color-picker" class="card elev-lg" style="display:none;position:absolute;top:52px;left:0;z-index:20;padding:12px;gap:8px;width:170px">
|
||
<div style="font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:color-mix(in srgb, var(--ink) 55%, transparent)">Цвет</div>
|
||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px" id="acc-color-swatches">
|
||
<?php foreach ([
|
||
'oklch(0.72 0.19 25)', 'var(--positive)', 'oklch(0.78 0.17 230)', 'oklch(0.80 0.18 90)',
|
||
'oklch(0.72 0.19 330)', 'oklch(0.75 0.16 200)', 'oklch(0.74 0.18 55)', 'oklch(0.76 0.17 290)',
|
||
] as $swatch): ?>
|
||
<button type="button" class="acc-color-swatch-opt" data-color="<?= htmlspecialchars($swatch, ENT_QUOTES, 'UTF-8') ?>"
|
||
style="width:28px;height:28px;border-radius:50%;background:<?= htmlspecialchars($swatch, ENT_QUOTES, 'UTF-8') ?>;border:2px solid transparent;cursor:pointer;padding:0"></button>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="field" style="flex:1;margin:0">
|
||
<label>Название</label>
|
||
<input class="input" type="text" name="title" maxlength="128" placeholder="Напр. Основная карта" required>
|
||
</div>
|
||
<span id="acc-icon-preview" style="width:36px;height:36px;border-radius:8px;background:color-mix(in srgb,oklch(0.80 0.18 90) 20%,transparent);color:oklch(0.80 0.18 90);display:inline-flex;align-items:center;justify-content:center;flex-shrink:0">
|
||
<svg class="ico" style="width:1.05em;height:1.05em"><use href="#ic-card" id="acc-icon-preview-use"/></svg>
|
||
</span>
|
||
<input type="hidden" name="color" id="acc-color-hidden" value="">
|
||
</div>
|
||
|
||
<div style="display:flex;gap:10px;margin-top:10px">
|
||
<div class="field" style="flex:1;margin:0">
|
||
<label>Сумма</label>
|
||
<input class="input" type="number" step="0.01" min="0" name="summa" placeholder="0" required>
|
||
</div>
|
||
<div style="width:110px">
|
||
<div class="field" style="margin:0">
|
||
<label>Валюта</label>
|
||
<select class="input" name="currency">
|
||
<?php foreach (Currency::LIST as $code => $name): ?>
|
||
<option value="<?= $code ?>"<?= $code === 'RUB' ? ' selected' : '' ?>><?= $code ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="account-rate-field" style="display:none;margin-top:10px">
|
||
<div class="field" style="margin:0">
|
||
<label>Ставка, % годовых *</label>
|
||
<input class="input" type="number" step="0.01" min="0" name="rate_percent" placeholder="0">
|
||
</div>
|
||
</div>
|
||
|
||
<div id="account-goal-fields" style="display:none;margin-top:10px">
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||
<div class="field" style="margin:0">
|
||
<label>Цель накопления</label>
|
||
<input class="input" type="number" step="0.01" min="0" name="goal_target_summa" placeholder="100 000">
|
||
</div>
|
||
<div class="field" style="margin:0">
|
||
<label>Дата цели</label>
|
||
<input class="input" type="date" name="goal_deadline">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="field" style="margin-top:10px">
|
||
<label>Описание (необязательно)</label>
|
||
<input class="input" type="text" name="description" maxlength="255">
|
||
</div>
|
||
|
||
<div style="margin-top:16px">
|
||
<div class="dialog-section-label" style="margin:0 0 8px">Общий баланс</div>
|
||
<div class="seg">
|
||
<label class="seg-opt"><input type="radio" name="include_in_total" value="1" checked>Учитывать</label>
|
||
<label class="seg-opt"><input type="radio" name="include_in_total" value="0">Не учитывать</label>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
<div class="modal-footer" style="border-color:var(--surface-3)">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal fade" id="credit-modal" tabindex="-1" data-bs-theme="dark">
|
||
<div class="modal-dialog modal-dialog-scrollable" style="max-width:560px">
|
||
<div class="modal-content" style="background:var(--surface);color:var(--ink);border-radius:var(--radius-lg)">
|
||
<div class="modal-header" style="border-color:var(--surface-3);align-items:flex-start">
|
||
<div style="display:flex;gap:12px;align-items:flex-start">
|
||
<span class="dialog-icon"><svg class="ico" style="width:1.3em;height:1.3em"><use href="#ic-bolt"/></svg></span>
|
||
<div>
|
||
<h5 class="modal-title">Новый кредит</h5>
|
||
<div class="dialog-sub">Раздел «Кредиты» · банковский кредит или карта</div>
|
||
</div>
|
||
</div>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||
</div>
|
||
|
||
<div class="modal-body">
|
||
<div class="alert-error" style="background:color-mix(in srgb, var(--accent) 12%, transparent);border-color:color-mix(in srgb, var(--accent) 30%, transparent);color:var(--accent-tint);margin-bottom:14px">
|
||
Раздел «Кредиты» пока в разработке — форма показывает, как это будет выглядеть, но
|
||
ничего не сохраняет.
|
||
</div>
|
||
|
||
<div class="dialog-section-label" style="margin-top:0">Что добавляем</div>
|
||
<div class="pm-tiles-grid" id="credit-mode-tiles" style="grid-template-columns:1fr 1fr">
|
||
<div class="pm-tile selected" data-mode="new">
|
||
<span class="pm-check">✓</span>
|
||
<div><div class="pm-tile-title">Новый кредит</div><div class="pm-tile-desc">Только что оформил</div></div>
|
||
</div>
|
||
<div class="pm-tile" data-mode="existing">
|
||
<span class="pm-check">✓</span>
|
||
<div><div class="pm-tile-title">Уже выплачиваю</div><div class="pm-tile-desc">Завести по остатку</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="dialog-section-label">Тип кредита</div>
|
||
<div class="pm-tiles-grid" id="credit-type-tiles" style="grid-template-columns:1fr 1fr">
|
||
<div class="pm-tile selected" data-credit-type="consumer">
|
||
<span class="pm-check">✓</span>
|
||
<span class="pm-tile-icon" style="background:color-mix(in srgb,oklch(0.80 0.18 90) 20%,transparent);color:oklch(0.80 0.18 90)"><svg class="ico" style="width:1.1em;height:1.1em"><use href="#ic-card"/></svg></span>
|
||
<div><div class="pm-tile-title">Потребительский</div><div class="pm-tile-desc">Обычный кредит с графиком платежей</div></div>
|
||
</div>
|
||
<div class="pm-tile" data-credit-type="mortgage">
|
||
<span class="pm-check">✓</span>
|
||
<span class="pm-tile-icon" style="background:color-mix(in srgb,oklch(0.72 0.19 25) 20%,transparent);color:oklch(0.72 0.19 25)"><svg class="ico" style="width:1.1em;height:1.1em"><use href="#ic-home"/></svg></span>
|
||
<div><div class="pm-tile-title">Ипотека</div><div class="pm-tile-desc">Долгий кредит под залог жилья</div></div>
|
||
</div>
|
||
<div class="pm-tile" data-credit-type="auto">
|
||
<span class="pm-check">✓</span>
|
||
<span class="pm-tile-icon" style="background:color-mix(in srgb,oklch(0.72 0.19 25) 20%,transparent);color:oklch(0.72 0.19 25)"><svg class="ico" style="width:1.1em;height:1.1em"><use href="#ic-car"/></svg></span>
|
||
<div><div class="pm-tile-title">Автокредит</div><div class="pm-tile-desc">Кредит на покупку автомобиля</div></div>
|
||
</div>
|
||
<div class="pm-tile" data-credit-type="card">
|
||
<span class="pm-check">✓</span>
|
||
<span class="pm-tile-icon" style="background:color-mix(in srgb,oklch(0.80 0.18 90) 20%,transparent);color:oklch(0.80 0.18 90)"><svg class="ico" style="width:1.1em;height:1.1em"><use href="#ic-coin"/></svg></span>
|
||
<div><div class="pm-tile-title">Кредитная карта</div><div class="pm-tile-desc">Лимит, льготный период, мин. платёж</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="credit-existing-hint" style="display:none;font-size:12px;color:var(--muted);margin:8px 0 0;padding:10px 12px;background:var(--surface-2);border-radius:var(--radius-md)">
|
||
Заводим уже идущий кредит — укажите остаток долга и дату, на которую он верен.
|
||
Историю пересчитывать не нужно.
|
||
</div>
|
||
|
||
<div class="dialog-section-label">Основные параметры</div>
|
||
<div class="field"><label>Название</label><input class="input" type="text" placeholder="Напр. Тачка"></div>
|
||
|
||
<div id="credit-card-fields" style="display:none">
|
||
<div style="display:flex;gap:10px;margin-top:10px">
|
||
<div class="field" style="flex:1;margin:0"><label>Кредитный лимит *</label><input class="input" type="number" min="0"></div>
|
||
<div style="width:110px"><div class="field" style="margin:0"><label>Валюта</label>
|
||
<select class="input"><?php foreach (Currency::LIST as $code => $name): ?><option value="<?= $code ?>"<?= $code === 'RUB' ? ' selected' : '' ?>><?= $code ?></option><?php endforeach; ?></select>
|
||
</div></div>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:10px">
|
||
<div class="field" style="margin:0"><label>Льготный период, дней</label><input class="input" type="number" min="0"></div>
|
||
<div class="field" style="margin:0"><label>Ставка годовая, % *</label><input class="input" type="number" min="0" step="0.01"></div>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:10px">
|
||
<div class="field" style="margin:0"><label>Мин. платёж</label><input class="input" type="number" min="0"></div>
|
||
<div class="field" style="margin:0"><label>Текущий баланс карты</label><input class="input" type="number" min="0"></div>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:10px">
|
||
<div class="field" style="margin:0"><label>День платежа</label><input class="input" type="number" min="1" max="31"></div>
|
||
<div class="field" style="margin:0"><label>Следующий платёж</label><input class="input" type="date"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="credit-noncard-fields">
|
||
<div style="display:flex;gap:10px;margin-top:10px">
|
||
<div class="field" style="flex:1;margin:0"><label>Сумма кредита *</label><input class="input" type="number" min="0"></div>
|
||
<div style="width:110px"><div class="field" style="margin:0"><label>Валюта</label>
|
||
<select class="input"><?php foreach (Currency::LIST as $code => $name): ?><option value="<?= $code ?>"<?= $code === 'RUB' ? ' selected' : '' ?>><?= $code ?></option><?php endforeach; ?></select>
|
||
</div></div>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-top:10px">
|
||
<div class="field" style="margin:0"><label>Ставка годовая, % *</label><input class="input" type="number" min="0" step="0.01"></div>
|
||
<div class="field" style="margin:0"><label>Срок, мес.</label><input class="input" type="number" min="0"></div>
|
||
<div class="field" style="margin:0"><label>Ежемесячный платёж</label><input class="input" type="number" min="0"></div>
|
||
</div>
|
||
<div id="credit-existing-fields" style="display:none;grid-template-columns:1fr 1fr;gap:12px;margin-top:10px">
|
||
<div class="field" style="margin:0"><label>Остаток долга *</label><input class="input" type="number" min="0"></div>
|
||
<div class="field" style="margin:0"><label>Остаток актуален на *</label><input class="input" type="date"></div>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:10px">
|
||
<div class="field" style="margin:0"><label>День платежа</label><input class="input" type="number" min="1" max="31"></div>
|
||
<div class="field" style="margin:0"><label>Следующий платёж</label><input class="input" type="date"></div>
|
||
</div>
|
||
<div class="field" style="margin-top:10px">
|
||
<label>Способ расчёта</label>
|
||
<select class="input"><option>Аннуитет</option><option>Дифференцированный</option></select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="field" style="margin-top:10px"><label>Дата выдачи</label><input class="input" type="date"></div>
|
||
|
||
</div>
|
||
<div class="modal-footer" style="border-color:var(--surface-3);align-items:center;justify-content:space-between">
|
||
<div class="card-meta" style="font-size:11px" id="credit-min-hint">Минимум нужно: <strong style="color:var(--ink)">название, сумма, ставка и срок или платёж</strong></div>
|
||
<div style="display:flex;gap:8px">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Создать кредит</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal fade" id="archive-modal" tabindex="-1" data-bs-theme="dark">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content" style="background:var(--surface-2);color:var(--ink);">
|
||
<div class="modal-header" style="border-color:var(--surface-3)">
|
||
<h5 class="modal-title">Архив счетов</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<?php if (empty($archived_accounts)): ?>
|
||
<div class="empty-state">Архив пуст</div>
|
||
<?php else: ?>
|
||
<?php foreach ($archived_accounts as $account): ?>
|
||
<div class="archive-row" data-id="<?= (int)$account->id ?>" data-title="<?= htmlspecialchars($account->title, ENT_QUOTES, 'UTF-8') ?>">
|
||
<div>
|
||
<div class="room-title"><?= htmlspecialchars($account->title, ENT_QUOTES, 'UTF-8') ?></div>
|
||
<div class="room-sub num">
|
||
<?= number_format((float)$account->summa, 2, ',', ' ') ?>
|
||
<?= htmlspecialchars($account->currency, ENT_QUOTES, 'UTF-8') ?>
|
||
</div>
|
||
</div>
|
||
<button type="button" class="btn btn-sm btn-outline-light js-account-restore" title="Восстановить">
|
||
<svg class="ico"><use href="#ic-undo"/></svg>
|
||
Восстановить
|
||
</button>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|