Bicycle/App/view/Budgets/budgets.html
2026-08-15 10:39:22 +03:00

497 lines
31 KiB
HTML
Raw Permalink 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 budgets.html — план бюджета на год по месяцам. Своя раскладка на каждое
* устройство (см. .claude/memory/бюджет_текущий_план.md → обсуждение дизайна):
* комп/планшет — карточки статьи с 12 месяцами (планшет — сеткой 6×2 вместо
* ряда, через container query); телефон — раскрывающиеся карточки-аккордeоны
* (все 12 месяцев по тапу). Ни один режим не даёт горизонтальный скролл.
* Факт (реальные transactions) здесь не показывается.
*
* @var array<int,object[]> $tree Активные статьи пользователя, сгруппированные по group_id (0 — без группы)
* @var object[] $groups Активные группы пользователя (App\Repositories\CategoryGroupRepository)
* @var array $plan [categorie_id => [month => ['plan_summ'=>float,'formula_pct_used'=>?float]]]
* @var int $year
* @var object[] $archived Архивные статьи пользователя — для модалки «Статьи» → архив
* @var object[] $archived_groups Архивные группы пользователя — туда же
*/
use System\Classes\CSRF;
$months = [
1 => 'Янв', 2 => 'Фев', 3 => 'Мар', 4 => 'Апр', 5 => 'Май', 6 => 'Июн',
7 => 'Июл', 8 => 'Авг', 9 => 'Сен', 10 => 'Окт', 11 => 'Ноя', 12 => 'Дек',
];
$sections = [
'income' => 'Доходы',
'expense' => 'Расходы',
];
// Плоский список активных статей (группа — отдельная сущность, сюда не входит) — для select'а
// формулы в модалке «Статьи» (только статьи расхода могут быть целью формулы, не группы).
$all = [];
foreach ($tree as $items) {
$all = array_merge($all, $items);
}
// Категории, чей план считается автоматически по формуле связанной income-статьи —
// у них своя ячейка readonly (см. CategoriesController::parseFormulaLink()).
$formula_targets = [];
foreach ($tree as $items) {
foreach ($items as $c) {
if ($c->linked_expense_categorie_id) {
$formula_targets[(int)$c->linked_expense_categorie_id] = true;
}
}
}
/**
* Одна ячейка ввода — общая и для карточек (комп/планшет), и для аккордeона (телефон),
* чтобы имя поля/значение/readonly не разъезжались между двумя разметками одних данных.
*/
$planInput = static function (int $categorie_id, int $month, array $plan, bool $readonly) {
$value = $plan[$categorie_id][$month]['plan_summ'] ?? '';
echo '<input type="number" step="0.01" min="0" class="plan-input num" name="plan[' . $categorie_id . '][' . $month . ']"'
. ' value="' . htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8') . '"' . ($readonly ? ' readonly' : '') . '>';
};
$this->setScript('budgets.js');
$this->setScript('categories.js');
?>
<svg width="0" height="0" style="position:absolute">
<defs>
<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-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-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>
</symbol>
<symbol id="ic-folder-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>
<line x1="12" y1="10" x2="12" y2="15"/>
<line x1="9.5" y1="12.5" x2="14.5" y2="12.5"/>
</symbol>
</defs>
</svg>
<div class="top-row">
<div>
<div class="hello">План бюджета на год</div>
</div>
<div style="display:flex;align-items:center;gap:0.75rem;">
<a class="btn btn-outline-light btn-sm" href="?year=<?= $year - 1 ?>">&larr;</a>
<div class="balance num" style="font-size:2rem;"><?= $year ?></div>
<a class="btn btn-outline-light btn-sm" href="?year=<?= $year + 1 ?>">&rarr;</a>
<button type="button" class="btn btn-primary js-categories-open" style="display:inline-flex;align-items:center;gap:6px">
<svg class="ico" style="width:0.95em;height:0.95em"><use href="#ic-gear"/></svg>
Статьи
</button>
</div>
</div>
<form id="budgets-form" method="post" action="/budgets/save">
<?= CSRF::field() ?>
<input type="hidden" name="year" value="<?= $year ?>">
<?php foreach ($sections as $type => $label): ?>
<div class="section-label"><span><?= $label ?></span></div>
<?php
$type_groups = array_filter($groups, static fn ($g) => $g->type === $type);
$ungrouped = array_filter($tree[0] ?? [], static fn ($c) => $c->type === $type);
?>
<?php if (empty($type_groups) && empty($ungrouped)): ?>
<div class="empty-state">Нет статей — сначала добавьте их на странице «Статьи»</div>
<?php else: ?>
<div class="budget-zoom">
<!-- ---------- комп/планшет: карточки, 12 месяцев (планшет — сеткой 6×2) ---------- -->
<div class="budget-cards-wrap">
<?php foreach ($type_groups as $group): ?>
<?php $children = $tree[$group->id] ?? []; ?>
<div class="budget-plan-group">
<div class="budget-group-head" style="--card-accent:var(--blue)">
<span class="budget-group-name"><?= htmlspecialchars($group->title, ENT_QUOTES, 'UTF-8') ?></span>
</div>
<div class="budget-cards">
<?php foreach ($children as $category): ?>
<?php $is_target = isset($formula_targets[(int)$category->id]); ?>
<div class="budget-card">
<div class="budget-card-name"><?= htmlspecialchars($category->title, ENT_QUOTES, 'UTF-8') ?></div>
<div class="budget-months-inline">
<?php foreach ($months as $m => $mlabel): ?>
<div class="budget-cell">
<div class="budget-cell-label"><?= $mlabel ?></div>
<?php $planInput((int)$category->id, $m, $plan, $is_target); ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
<?php foreach ($ungrouped as $category): ?>
<?php $is_target = isset($formula_targets[(int)$category->id]); ?>
<div class="budget-plan-group">
<div class="budget-cards">
<div class="budget-card">
<div class="budget-card-name"><?= htmlspecialchars($category->title, ENT_QUOTES, 'UTF-8') ?></div>
<div class="budget-months-inline">
<?php foreach ($months as $m => $mlabel): ?>
<div class="budget-cell">
<div class="budget-cell-label"><?= $mlabel ?></div>
<?php $planInput((int)$category->id, $m, $plan, $is_target); ?>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<!-- ---------- телефон: один аккордeон на группу или самостоятельную статью ---------- -->
<div class="budget-accordion">
<?php foreach ($type_groups as $group): ?>
<?php
$children = $tree[$group->id] ?? [];
$year_sum = 0.0;
foreach ($children as $row_category) {
foreach ($months as $m => $_) {
$year_sum += (float)($plan[$row_category->id][$m]['plan_summ'] ?? 0);
}
}
?>
<details class="budget-acc-card">
<summary>
<span class="budget-acc-name"><?= htmlspecialchars($group->title, ENT_QUOTES, 'UTF-8') ?></span>
<span class="budget-acc-total num"><?= number_format($year_sum, 0, ',', ' ') ?> ₽/год</span>
</summary>
<?php foreach ($children as $category): ?>
<?php $is_target = isset($formula_targets[(int)$category->id]); ?>
<div class="budget-acc-caption"><?= htmlspecialchars($category->title, ENT_QUOTES, 'UTF-8') ?></div>
<div class="budget-acc-body">
<?php foreach ($months as $m => $mlabel): ?>
<div class="budget-acc-month">
<span><?= $mlabel ?></span>
<?php $planInput((int)$category->id, $m, $plan, $is_target); ?>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</details>
<?php endforeach; ?>
<?php foreach ($ungrouped as $category): ?>
<?php
$is_target = isset($formula_targets[(int)$category->id]);
$year_sum = 0.0;
foreach ($months as $m => $_) {
$year_sum += (float)($plan[$category->id][$m]['plan_summ'] ?? 0);
}
?>
<details class="budget-acc-card">
<summary>
<span class="budget-acc-name"><?= htmlspecialchars($category->title, ENT_QUOTES, 'UTF-8') ?></span>
<span class="budget-acc-total num"><?= number_format($year_sum, 0, ',', ' ') ?> ₽/год</span>
</summary>
<div class="budget-acc-body">
<?php foreach ($months as $m => $mlabel): ?>
<div class="budget-acc-month">
<span><?= $mlabel ?></span>
<?php $planInput((int)$category->id, $m, $plan, $is_target); ?>
</div>
<?php endforeach; ?>
</div>
</details>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php endforeach; ?>
<div id="budgets-form-error" class="alert alert-danger" style="display:none"></div>
</form>
<div class="modal fade" id="categories-modal" tabindex="-1" data-bs-theme="dark">
<div class="modal-dialog modal-dialog-scrollable" style="max-width:640px">
<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)">
<h5 class="modal-title">Статьи доходов и расходов</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body" style="padding:0">
<?php foreach ($sections as $type => $label): ?>
<div class="section-label">
<span><?= $label ?></span>
<div style="display:flex;gap:0.5rem;">
<button type="button" class="btn-add js-category-archive-open" title="Архив"><svg class="ico"><use href="#ic-archive"/></svg></button>
<button type="button" class="btn-add js-group-add" data-type="<?= $type ?>" title="Добавить группу"><svg class="ico"><use href="#ic-folder-plus"/></svg></button>
<button type="button" class="btn-add js-category-add" data-type="<?= $type ?>" title="Добавить статью"><svg class="ico"><use href="#ic-plus"/></svg></button>
</div>
</div>
<?php
$type_groups = array_filter($groups, static fn ($g) => $g->type === $type);
$ungrouped = array_filter($tree[0] ?? [], static fn ($c) => $c->type === $type);
?>
<?php if (empty($type_groups) && empty($ungrouped)): ?>
<div class="empty-state">Пока нет ни одной статьи — нажмите «+», чтобы добавить первую</div>
<?php else: ?>
<div class="category-list">
<?php foreach ($type_groups as $group): ?>
<div class="category-row category-group"
data-id="<?= (int)$group->id ?>"
data-title="<?= htmlspecialchars($group->title, ENT_QUOTES, 'UTF-8') ?>"
data-type="<?= $group->type ?>">
<div class="category-row-main">
<svg class="ico"><use href="#ic-folder"/></svg>
<span class="room-title"><?= htmlspecialchars($group->title, ENT_QUOTES, 'UTF-8') ?></span>
</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-group-edit"><svg class="ico ico-sm"><use href="#ic-pencil"/></svg>Редактировать</a>
<a href="#" class="danger js-group-delete"><svg class="ico ico-sm"><use href="#ic-trash"/></svg>Архивировать</a>
</div>
</div>
</div>
<?php foreach ($tree[$group->id] ?? [] as $sub): ?>
<div class="category-row category-sub"
data-id="<?= (int)$sub->id ?>"
data-title="<?= htmlspecialchars($sub->title, ENT_QUOTES, 'UTF-8') ?>"
data-type="<?= $sub->type ?>"
data-group_id="<?= (int)$sub->group_id ?>"
data-linked="<?= (int)($sub->linked_expense_categorie_id ?? 0) ?>"
data-pct="<?= htmlspecialchars((string)($sub->formula_pct ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<div class="category-row-main">
<span class="room-title"><?= htmlspecialchars($sub->title, ENT_QUOTES, 'UTF-8') ?></span>
<?php if ($sub->linked_expense_categorie_id): ?>
<span class="room-sub">&rarr; формула <?= htmlspecialchars((string)$sub->formula_pct, ENT_QUOTES, 'UTF-8') ?>%</span>
<?php endif; ?>
</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-category-edit"><svg class="ico ico-sm"><use href="#ic-pencil"/></svg>Редактировать</a>
<label class="room-menu-move">
<svg class="ico ico-sm"><use href="#ic-folder"/></svg>
<select class="js-category-move">
<option value="">Перенести в...</option>
<?php foreach ($groups as $move_group): ?>
<?php if ($move_group->type === $sub->type && $move_group->id !== $sub->group_id): ?>
<option value="<?= (int)$move_group->id ?>"><?= htmlspecialchars($move_group->title, ENT_QUOTES, 'UTF-8') ?></option>
<?php endif; ?>
<?php endforeach; ?>
</select>
</label>
<a href="#" class="danger js-category-delete"><svg class="ico ico-sm"><use href="#ic-trash"/></svg>Архивировать</a>
</div>
</div>
</div>
<?php endforeach; ?>
<?php endforeach; ?>
<?php foreach ($ungrouped as $sub): ?>
<div class="category-row"
data-id="<?= (int)$sub->id ?>"
data-title="<?= htmlspecialchars($sub->title, ENT_QUOTES, 'UTF-8') ?>"
data-type="<?= $sub->type ?>"
data-group_id=""
data-linked="<?= (int)($sub->linked_expense_categorie_id ?? 0) ?>"
data-pct="<?= htmlspecialchars((string)($sub->formula_pct ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<div class="category-row-main">
<span class="room-title"><?= htmlspecialchars($sub->title, ENT_QUOTES, 'UTF-8') ?></span>
<?php if ($sub->linked_expense_categorie_id): ?>
<span class="room-sub">&rarr; формула <?= htmlspecialchars((string)$sub->formula_pct, ENT_QUOTES, 'UTF-8') ?>%</span>
<?php endif; ?>
</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-category-edit"><svg class="ico ico-sm"><use href="#ic-pencil"/></svg>Редактировать</a>
<?php if (!empty($groups)): ?>
<label class="room-menu-move">
<svg class="ico ico-sm"><use href="#ic-folder"/></svg>
<select class="js-category-move">
<option value="">Перенести в...</option>
<?php foreach ($groups as $move_group): ?>
<?php if ($move_group->type === $sub->type): ?>
<option value="<?= (int)$move_group->id ?>"><?= htmlspecialchars($move_group->title, ENT_QUOTES, 'UTF-8') ?></option>
<?php endif; ?>
<?php endforeach; ?>
</select>
</label>
<?php endif; ?>
<a href="#" class="danger js-category-delete"><svg class="ico ico-sm"><use href="#ic-trash"/></svg>Архивировать</a>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
<div class="modal fade" id="category-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="category-form" method="post">
<?= CSRF::field() ?>
<input type="hidden" name="id">
<input type="hidden" name="type" id="category-type">
<div class="modal-header" style="border-color:var(--surface-3)">
<h5 class="modal-title" id="category-modal-title">Новая статья</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div id="category-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="group_id" id="category-group">
<option value="">— без группы —</option>
<?php foreach ($groups as $group): ?>
<option value="<?= (int)$group->id ?>" data-type="<?= $group->type ?>"><?= htmlspecialchars($group->title, ENT_QUOTES, 'UTF-8') ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="mb-3" id="category-formula-group" style="display:none">
<label class="form-label">Формула — связанная статья расхода</label>
<select class="form-select mb-2" name="linked_expense_categorie_id" id="category-linked">
<option value="">— не привязано —</option>
<?php foreach ($all as $c): ?>
<?php if ($c->type === 'expense'): ?>
<option value="<?= (int)$c->id ?>"><?= htmlspecialchars($c->title, ENT_QUOTES, 'UTF-8') ?></option>
<?php endif; ?>
<?php endforeach; ?>
</select>
<input type="number" step="0.01" min="0" max="100" class="form-control" name="formula_pct" placeholder="Процент">
</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="group-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="group-form" method="post">
<?= CSRF::field() ?>
<input type="hidden" name="id">
<input type="hidden" name="type" id="group-type">
<div class="modal-header" style="border-color:var(--surface-3)">
<h5 class="modal-title" id="group-modal-title">Новая группа</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div id="group-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>
<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="category-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_groups)): ?>
<div class="card-meta" style="font-size:11px;margin-bottom:6px">Групп в архиве нет</div>
<?php else: ?>
<div class="card-meta" style="font-size:11px;margin-bottom:6px">Группы</div>
<?php foreach ($archived_groups as $group): ?>
<div class="archive-row" data-id="<?= (int)$group->id ?>" data-title="<?= htmlspecialchars($group->title, ENT_QUOTES, 'UTF-8') ?>">
<div>
<div class="room-title"><svg class="ico ico-sm"><use href="#ic-folder"/></svg> <?= htmlspecialchars($group->title, ENT_QUOTES, 'UTF-8') ?></div>
<div class="room-sub"><?= $group->type === 'income' ? 'Доход' : 'Расход' ?></div>
</div>
<button type="button" class="btn btn-sm btn-outline-light js-group-restore" title="Восстановить">
<svg class="ico"><use href="#ic-undo"/></svg>
Восстановить
</button>
</div>
<?php endforeach; ?>
<?php endif; ?>
<?php if (empty($archived)): ?>
<div class="card-meta" style="font-size:11px;margin:14px 0 6px">Статей в архиве нет</div>
<?php else: ?>
<div class="card-meta" style="font-size:11px;margin:14px 0 6px">Статьи</div>
<?php foreach ($archived as $category): ?>
<div class="archive-row" data-id="<?= (int)$category->id ?>" data-title="<?= htmlspecialchars($category->title, ENT_QUOTES, 'UTF-8') ?>">
<div>
<div class="room-title"><?= htmlspecialchars($category->title, ENT_QUOTES, 'UTF-8') ?></div>
<div class="room-sub"><?= $category->type === 'income' ? 'Доход' : 'Расход' ?></div>
</div>
<button type="button" class="btn btn-sm btn-outline-light js-category-restore" title="Восстановить">
<svg class="ico"><use href="#ic-undo"/></svg>
Восстановить
</button>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div>
</div>