Bicycle/App/view/Index/index.html
Egor Isaev ac1334c784 dev
2026-08-14 14:05:41 +03:00

386 lines
22 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.

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