386 lines
22 KiB
HTML
386 lines
22 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 object[] $goals Цели пользователя + данные счёта-копилки (account_title/account_summa/account_currency)
|
||
*/
|
||
|
||
use App\Classes\Currency;
|
||
use System\Classes\CSRF;
|
||
|
||
$type_icons = [
|
||
'cash' => 'ic-wallet',
|
||
'bank' => 'ic-card',
|
||
'savings' => 'ic-piggy',
|
||
];
|
||
|
||
$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-piggy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<rect x="3" y="9" width="18" height="9" rx="4.5"/>
|
||
<line x1="9" y1="13.5" x2="14" y2="13.5"/>
|
||
<circle cx="17" cy="6" r="2"/>
|
||
<line x1="17" y1="8" x2="17" y2="9"/>
|
||
</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>
|
||
</defs>
|
||
</svg>
|
||
|
||
<div class="dash-kicker"><?= $weekdays[(int)date('N')] ?>, <?= (int)date('j') ?> <?= $month_names_gen[(int)date('n')] ?></div>
|
||
<div class="dash-tags">
|
||
<span class="tag tag-outline">📅 <?= $ucfirstMb($month_names[(int)date('n')]) ?> <?= date('Y') ?></span>
|
||
<?php if ($pct_expense !== null): ?>
|
||
<?php $over = $pct_expense > 100; ?>
|
||
<span class="tag" style="background:color-mix(in srgb, <?= $over ? 'var(--red)' : 'var(--accent)' ?> 20%, transparent);color:<?= $over ? 'var(--red)' : 'var(--accent-tint)' ?>">Бюджет <?= $pct_expense ?>% потрачено</span>
|
||
<?php endif; ?>
|
||
</div>
|
||
|
||
<div class="top-row">
|
||
<div>
|
||
<div class="hello">Здравствуйте, <?= htmlspecialchars($user['full_name'] ?? $user['login'], ENT_QUOTES, 'UTF-8') ?></div>
|
||
<div class="balance-label">Доступно сейчас</div>
|
||
<div class="balance num"><?= number_format($total, 0, ',', ' ') ?> <span>₽</span></div>
|
||
<div class="balance-total num"><span class="label">Всего по всем счетам</span><?= number_format($total_all, 0, ',', ' ') ?> ₽</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section-label">
|
||
<span>Счета</span>
|
||
<div style="display:flex;gap:0.5rem;">
|
||
<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($accounts)): ?>
|
||
<div class="empty-state">Пока нет ни одного счёта — нажмите «+», чтобы добавить первый</div>
|
||
<?php else: ?>
|
||
<div class="grid-cards">
|
||
<?php foreach ($accounts as $account): ?>
|
||
<div class="room-card"
|
||
style="--card-accent:<?= $type_accents[$account->type_acc] ?? 'var(--blue)' ?>"
|
||
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-include="<?= $account->include_in_total ? '1' : '' ?>">
|
||
<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 class="room-title"><?= htmlspecialchars($account->title, ENT_QUOTES, 'UTF-8') ?></div>
|
||
<div class="room-sub"><?= $type_labels[$account->type_acc] ?? '' ?></div>
|
||
<div class="room-value num">
|
||
<?= number_format((float)$account->summa, 2, ',', ' ') ?>
|
||
<?= htmlspecialchars($account->currency, ENT_QUOTES, 'UTF-8') ?>
|
||
</div>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php if (!empty($goals)): ?>
|
||
<div class="section-label"><span>Копилки и цели</span></div>
|
||
<div class="grid-cards grid-cards-goals">
|
||
<?php foreach ($goals as $goal): ?>
|
||
<?php
|
||
$saved = (float)$goal->account_summa;
|
||
$target = (float)$goal->target_summa;
|
||
$pct = $target > 0 ? min(100, $saved / $target * 100) : 0;
|
||
$r = 22;
|
||
$circumference = 2 * M_PI * $r;
|
||
$offset = $circumference * (1 - $pct / 100);
|
||
?>
|
||
<div class="room-card goal-card" style="--card-accent:var(--purple)">
|
||
<svg width="52" height="52" viewBox="0 0 52 52" class="goal-ring">
|
||
<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="var(--purple)" 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="12" fill="var(--ink)"><?= round($pct) ?>%</text>
|
||
</svg>
|
||
<div>
|
||
<div class="room-title"><?= htmlspecialchars($goal->account_title, ENT_QUOTES, 'UTF-8') ?></div>
|
||
<div class="room-sub num">
|
||
<?= number_format($saved, 0, ',', ' ') ?> из <?= number_format($target, 0, ',', ' ') ?>
|
||
<?= htmlspecialchars($goal->account_currency, ENT_QUOTES, 'UTF-8') ?>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php
|
||
$groups = $tree[0] ?? [];
|
||
|
||
// Строки плана+факта для секции типа $type (income|expense): группа с подкатегориями —
|
||
// заголовком группы + строками по ним; группа без подкатегорий — одной строкой на саму группу
|
||
// (тот же приём, что и в App/view/Budgets/budgets.html). Факт — реальные 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);
|
||
|
||
if ($type_groups === []) {
|
||
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="budget-row">'
|
||
. '<div class="budget-row-main">'
|
||
. '<div class="budget-name">' . htmlspecialchars($category->title, ENT_QUOTES, 'UTF-8') . '</div>'
|
||
. '<div class="budget-bar"><div class="budget-bar-fill" style="width:' . $bar_width . '%;background:' . $bar_color . '"></div></div>'
|
||
. '<div class="budget-row-caption num">' . number_format($fact, 0, ',', ' ') . ' из ' . number_format($plan, 0, ',', ' ') . ' ₽</div>'
|
||
. '</div>'
|
||
. '<div class="budget-row-side"><span class="num" style="color:' . $pct_color . '">' . $pct . '%</span></div>'
|
||
. '</div>';
|
||
};
|
||
|
||
echo '<div class="budget-list">';
|
||
|
||
foreach ($type_groups as $group) {
|
||
$children = $tree[$group->id] ?? [];
|
||
|
||
// Заголовок-плашка — ВСЕГДА, и у настоящей группы, и у самостоятельной статьи (тогда
|
||
// плашка — единственная строка на неё, показывает сразу и название, и сумму). Без этого
|
||
// самостоятельная статья визуально не отличалась от рядового члена предыдущей группы
|
||
// (см. фидбек пользователя — тонкой разницы фона недостаточно, нужна однозначная граница).
|
||
$head_plan = $children !== []
|
||
? array_sum(array_map(static fn ($c) => $plan_month[$c->id] ?? 0.0, $children))
|
||
: ($plan_month[$group->id] ?? 0.0);
|
||
|
||
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>';
|
||
|
||
// Самостоятельная статья (без подкатегорий) — плашка выше уже единственная строка на неё
|
||
// (название+план), отдельного .budget-row для неё не рисуем (см. комментарий выше).
|
||
foreach ($children 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">
|
||
<div class="modal-content" style="background:var(--surface-2);color:var(--ink);">
|
||
<form id="account-form" method="post">
|
||
<?= CSRF::field() ?>
|
||
<input type="hidden" name="id">
|
||
<div class="modal-header" style="border-color:var(--surface-3)">
|
||
<h5 class="modal-title" id="account-modal-title">Новый счёт</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div id="account-form-error" class="alert alert-danger" style="display:none"></div>
|
||
|
||
<div class="mb-3">
|
||
<label class="form-label">Название</label>
|
||
<input type="text" class="form-control" name="title" maxlength="128" required>
|
||
</div>
|
||
|
||
<div class="mb-3">
|
||
<label class="form-label">Тип</label>
|
||
<select class="form-select" name="type_acc">
|
||
<option value="cash">Кошелёк (наличные)</option>
|
||
<option value="bank">Банковский счёт</option>
|
||
<option value="savings">Копилка</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<div class="col-7 mb-3">
|
||
<label class="form-label">Сумма</label>
|
||
<input type="number" step="0.01" min="0" class="form-control" name="summa" required>
|
||
</div>
|
||
<div class="col-5 mb-3">
|
||
<label class="form-label">Валюта</label>
|
||
<select class="form-select" name="currency">
|
||
<?php foreach (Currency::LIST as $code => $name): ?>
|
||
<option value="<?= $code ?>"<?= $code === 'RUB' ? ' selected' : '' ?> title="<?= htmlspecialchars($name, ENT_QUOTES, 'UTF-8') ?>"><?= $code ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mb-3" id="account-rate-group">
|
||
<div id="account-rate-field" style="display:none">
|
||
<label class="form-label">Ставка, % годовых</label>
|
||
<input type="number" step="0.01" min="0" class="form-control" name="rate_percent">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mb-3">
|
||
<label class="form-label">Описание</label>
|
||
<input type="text" class="form-control" name="description" maxlength="255">
|
||
</div>
|
||
|
||
<div class="form-check">
|
||
<input type="checkbox" class="form-check-input" name="include_in_total" value="1" id="account-include" checked>
|
||
<label class="form-check-label" for="account-include">Учитывать в «Доступно сейчас»</label>
|
||
</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="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>
|