This commit is contained in:
Egor Isaev 2026-08-14 13:09:54 +03:00
parent cff643ebc5
commit c9b81cdcd1
27 changed files with 2239 additions and 37 deletions

View File

@ -550,15 +550,21 @@ ALTER TABLE categories ADD CONSTRAINT fk_categories_linked_expense
2. **Инвестиции** (вклады/акции/облигации) — следующий слой, схему делать с запасом.
3. **Кредиты** — отложены, но `account_rates` намеренно универсальна для переиспользования позже.
**Статьи (categories) + годовой план (budgets)** — план утверждён (2026-08-12), реализация ещё не
начата. Полный план — `/home/isaevea/.claude/plans/rippling-hatching-rabbit.md` (Часть A —
`CategoriesController`/`categories.js`/`categories.html`, отдельная страница `/categories`, защита
от дублей активных/архивных статей; Часть B — `BudgetsController`/`budgets.js`/`budgets.html`, сетка
статьи×12 месяцев сохраняется одной кнопкой одним POST, автозаполнение связанной expense-статьи по
`formula_pct`). `CategoryRepository`/`BudgetRepository` пока пустые обёртки — методы
(`findActiveDuplicate`/`findArchivedDuplicate`/`getTree`, `getYear`/`upsert`) ещё не добавлены.
Начинать реализацию — с `CategoryRepository`, затем `CategoriesController` (по образцу
`AccountsController`).
**Статьи (categories) + годовой план (budgets)** — реализовано (2026-08-13), план
`/home/isaevea/.claude/plans/rippling-hatching-rabbit.md` выполнен целиком: `CategoryRepository`
(`findActiveDuplicate`/`findArchivedDuplicate`/`getTree`), `CategoriesController`
(index/create/edit/delete/restore, защита от дублей активных/архивных статей), `categories.html` +
`categories.js` (страница `/categories`, группы+подкатегории, формула income→expense в модалке);
`BudgetRepository` (`getYear`/`upsert`, JOIN-таблица categories вынесена в свойство
`$categories_table` — ради тестируемости без риска для боевой таблицы), `BudgetsController`
(сетка статьи×12 месяцев, один POST, автопересчёт формульной expense-статьи отдельным проходом
после прямых значений — не зависит от порядка обхода), `budgets.html` + `budgets.js`. Ссылка
«Категории» в доке (`layout.html`) — теперь реальная, с подсветкой активного пункта по
`Request::$current->controller()`. Тесты — `tests/Unit/CategoryRepositoryTest.php`,
`tests/Unit/BudgetRepositoryTest.php` (реальная MariaDB, временные таблицы с отдельными именами —
не боевые `categories`/`budgets`). 259 тестов, всё зелено (8 skip — Elasticsearch/Mongo).
Живой CRUD-флоу под `mikrit` в браузере — не проверен мной (нет учётных данных), маршруты
проверены curl (302 на /login без сессии, без 500).
**Реальная БД и пользователь** (2026-08-12): `schema.sql` применена к боевой MariaDB
(`192.168.11.247:3306`, база `budget`, все таблицы были пустые на момент применения — пересоздавались

View File

@ -0,0 +1,149 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description BudgetsController.php
* @copyright (c) 13/08/2026
*/
namespace App\Controller;
use App\Repositories\BudgetRepository;
use App\Repositories\CategoryRepository;
use App\Repositories\UserRepository;
use Services\Auth;
use System\Classes\Controller;
use System\Classes\HTTP\HTTPException;
use System\Classes\MyException;
use System\Classes\Request;
/**
* Годовой план бюджета по месяцам — сетка «статья × 12 месяцев», сохраняется одной кнопкой
* (см. App/view/Budgets/budgets.html, App/media/js/budgets.js). Факт (реальные transactions)
* здесь не показывается — это отдельная фича, см. бюджет_текущий_план.md.
*/
class BudgetsController extends Controller
{
protected bool $_auth_protection = true;
/**
* @return string
* @throws MyException
*/
public function indexAction(): string
{
$user = $this->currentUser();
$year = (int)(Request::$current->query('year') ?: (int)date('Y'));
$category_repo = new CategoryRepository();
$budget_repo = new BudgetRepository();
$tree = $category_repo->getTree($user->id);
$plan = $budget_repo->getYear($user->id, $year);
return $this->render('budgets', [
'tree' => $tree,
'groups' => $tree[0] ?? [],
'plan' => $plan,
'year' => $year,
]);
}
/**
* Сохраняет всю сетку одним запросом: plan[categorie_id][month] = сумма. Для статей дохода
* со связанной статьёй расхода (formula_pct) — автоматически пересчитывает и перезаписывает
* план связанной статьи расхода тем же процентом (см. бюджет_текущий_план.md → Categories).
*
* @return string JSON
* @throws HTTPException|MyException
*/
public function saveAction(): string
{
$request = Request::$current;
$user = $this->currentUser();
$year = (int)$request->post('year');
$plan_input = $request->post('plan');
if ($year < 2000 || $year > 2100 || !is_array($plan_input)) {
return $this->json(['success' => false, 'errors' => ['year' => 'Некорректный год']], 422);
}
$category_repo = new CategoryRepository();
$budget_repo = new BudgetRepository();
$categories = [];
foreach (array_keys($plan_input) as $categorie_id) {
$category = $category_repo->get((int)$categorie_id);
if (!$category || (int)$category->user_id !== (int)$user->id) {
throw HTTPException::factory(404);
}
$categories[(int)$categorie_id] = $category;
}
// Пересчитанные формулой ячейки связанной статьи расхода — возвращаются клиенту, чтобы
// JS обновил их в сетке сразу, без перезагрузки страницы (см. budgets.js).
$recalculated = [];
$budget_repo->transaction(static function () use ($categories, $plan_input, $year, $budget_repo, &$recalculated) {
// 1. Прямые значения из формы — как есть.
foreach ($categories as $categorie_id => $category) {
foreach ($plan_input[$categorie_id] ?? [] as $month => $summ) {
$month = (int)$month;
if ($month < 1 || $month > 12) {
continue;
}
$budget_repo->upsert($categorie_id, $year, $month, (float)$summ, $category->type);
}
}
// 2. Формульные статьи дохода — пересчитывают связанную статью расхода поверх,
// последним шагом, чтобы формула всегда побеждала независимо от порядка обхода.
foreach ($categories as $category) {
if ($category->linked_expense_categorie_id === null || $category->formula_pct === null) {
continue;
}
foreach ($plan_input[(int)$category->id] ?? [] as $month => $summ) {
$month = (int)$month;
if ($month < 1 || $month > 12) {
continue;
}
$expense_summ = round((float)$summ * (float)$category->formula_pct / 100, 2);
$target_id = (int)$category->linked_expense_categorie_id;
$budget_repo->upsert($target_id, $year, $month, $expense_summ, 'expense', (float)$category->formula_pct);
$recalculated[$target_id][$month] = $expense_summ;
}
}
});
return $this->json(['success' => true, 'recalculated' => $recalculated]);
}
/**
* Бюджетный пользователь, связанный с текущим логином Services\Auth.
*
* @return object
* @throws HTTPException|MyException
*/
protected function currentUser(): object
{
$auth_user = Auth::instance()->getUser();
$user = (new UserRepository())->getByLogin($auth_user['login']);
if (!$user) {
throw HTTPException::factory(404);
}
return $user;
}
}

View File

@ -0,0 +1,321 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description CategoriesController.php
* @copyright (c) 13/08/2026
*/
namespace App\Controller;
use App\Repositories\CategoryRepository;
use App\Repositories\UserRepository;
use Services\Auth;
use System\Classes\Controller;
use System\Classes\HTTP\HTTPException;
use System\Classes\MyException;
use System\Classes\Request;
use System\Classes\Validation;
/**
* CRUD статей (categories) — доходы/расходы с группами (parent_id). Своя страница /categories
* (в отличие от счетов, для которых достаточно модалки на дашборде — см. AccountsController).
*/
class CategoriesController extends Controller
{
protected bool $_auth_protection = true;
/**
* @return string
* @throws MyException
*/
public function indexAction(): string
{
$user = $this->currentUser();
$category_repo = new CategoryRepository();
$tree = $category_repo->getTree($user->id);
$archived = $category_repo->getList('*', ['user_id' => $user->id, 'is_delete' => 1], 'title');
return $this->render('categories', [
'tree' => $tree,
'archived' => $archived ?: [],
]);
}
/**
* @return string JSON
* @throws MyException
*/
public function createAction(): string
{
$request = Request::$current;
$user = $this->currentUser();
$category_repo = new CategoryRepository();
$validation = $this->buildValidation($request->post());
if (!$validation->check()) {
return $this->json(['success' => false, 'errors' => $validation->errors()], 422);
}
$type = $request->post('type');
$title = $request->post('title');
$parent_id = $this->parseParentId($category_repo, $user, $type, $request->post('parent_id'));
if ($parent_id === false) {
return $this->json(['success' => false, 'errors' => ['parent_id' => 'Родительская статья недоступна']], 422);
}
[$linked_id, $formula_pct, $link_error] = $this->parseFormulaLink(
$category_repo, $user, $type, $request->post('linked_expense_categorie_id'), $request->post('formula_pct')
);
if ($link_error !== null) {
return $this->json(['success' => false, 'errors' => ['linked_expense_categorie_id' => $link_error]], 422);
}
if ($category_repo->findActiveDuplicate($user->id, $type, $parent_id, $title)) {
return $this->json(['success' => false, 'errors' => ['title' => 'Такая статья уже есть']], 422);
}
$archived_duplicate = $category_repo->findArchivedDuplicate($user->id, $type, $parent_id, $title);
if ($archived_duplicate) {
return $this->json([
'success' => false,
'archived' => ['id' => $archived_duplicate->id, 'title' => $archived_duplicate->title],
], 422);
}
$category_repo->create([
'user_id' => $user->id,
'parent_id' => $parent_id,
'title' => $title,
'type' => $type,
'is_delete' => false,
'order' => 0,
'linked_expense_categorie_id' => $linked_id,
'formula_pct' => $formula_pct,
]);
return $this->json(['success' => true]);
}
/**
* @return string JSON
* @throws HTTPException|MyException
*/
public function editAction(): string
{
$request = Request::$current;
$user = $this->currentUser();
$category_repo = new CategoryRepository();
$category = $this->ownCategory($category_repo, $user, (int)$request->param(0));
$validation = $this->buildValidation($request->post());
if (!$validation->check()) {
return $this->json(['success' => false, 'errors' => $validation->errors()], 422);
}
$type = $request->post('type');
$title = $request->post('title');
$parent_id = $this->parseParentId($category_repo, $user, $type, $request->post('parent_id'));
if ($parent_id === false || $parent_id === (int)$category->id) {
return $this->json(['success' => false, 'errors' => ['parent_id' => 'Родительская статья недоступна']], 422);
}
[$linked_id, $formula_pct, $link_error] = $this->parseFormulaLink(
$category_repo, $user, $type, $request->post('linked_expense_categorie_id'), $request->post('formula_pct')
);
if ($link_error !== null) {
return $this->json(['success' => false, 'errors' => ['linked_expense_categorie_id' => $link_error]], 422);
}
if ($category_repo->findActiveDuplicate($user->id, $type, $parent_id, $title, (int)$category->id)) {
return $this->json(['success' => false, 'errors' => ['title' => 'Такая статья уже есть']], 422);
}
$archived_duplicate = $category_repo->findArchivedDuplicate($user->id, $type, $parent_id, $title, (int)$category->id);
if ($archived_duplicate) {
return $this->json([
'success' => false,
'archived' => ['id' => $archived_duplicate->id, 'title' => $archived_duplicate->title],
], 422);
}
$category_repo->update([
'id' => $category->id,
'parent_id' => $parent_id,
'title' => $title,
'type' => $type,
'linked_expense_categorie_id' => $linked_id,
'formula_pct' => $formula_pct,
]);
return $this->json(['success' => true]);
}
/**
* Архивация (is_delete = 1), не физическое удаление — история транзакций может ссылаться на статью.
*
* @return string JSON
* @throws HTTPException|MyException
*/
public function deleteAction(): string
{
$request = Request::$current;
$user = $this->currentUser();
$category_repo = new CategoryRepository();
$category = $this->ownCategory($category_repo, $user, (int)$request->param(0));
$category_repo->update(['id' => $category->id, 'is_delete' => true]);
return $this->json(['success' => true]);
}
/**
* Возврат из архива (is_delete = 0).
*
* @return string JSON
* @throws HTTPException|MyException
*/
public function restoreAction(): string
{
$request = Request::$current;
$user = $this->currentUser();
$category_repo = new CategoryRepository();
$category = $this->ownCategory($category_repo, $user, (int)$request->param(0));
$category_repo->update(['id' => $category->id, 'is_delete' => false]);
return $this->json(['success' => true]);
}
/**
* Общие правила валидации формы статьи (create/edit).
*
* @param array $data
* @return Validation
*/
protected function buildValidation(array $data): Validation
{
return Validation::factory($data)
->label('title', 'Название')
->label('type', 'Тип')
->rule('title', 'required')
->rule('title', 'max_length', [128])
->rule('type', 'required')
->rule('type', 'in', [['income', 'expense']])
->rule('formula_pct', 'numeric');
}
/**
* Родительская статья (группа) из сырого значения формы — с проверкой владения/уровня/типа.
*
* @param CategoryRepository $category_repo
* @param object $user
* @param string $type Тип создаваемой/правимой статьи — родитель должен совпадать
* @param mixed $raw Сырое значение поля parent_id
* @return int|false|null null — верхний уровень (родителя нет); false — родитель недопустим
*/
protected function parseParentId(CategoryRepository $category_repo, object $user, string $type, mixed $raw): int|false|null
{
if ($raw === null || $raw === '') {
return null;
}
$parent = $category_repo->get((int)$raw);
if (!$parent || (int)$parent->user_id !== (int)$user->id || $parent->parent_id !== null || $parent->type !== $type) {
return false;
}
return (int)$parent->id;
}
/**
* Формульная связь income-статьи со статьёй расхода — оба поля (linked+pct) заполняются вместе,
* только для type=income, с проверкой владения и типа связанной статьи.
*
* @param CategoryRepository $category_repo
* @param object $user
* @param string $type
* @param mixed $raw_linked
* @param mixed $raw_pct
* @return array{0:int|null,1:float|null,2:string|null} [linked_id, formula_pct, error]
*/
protected function parseFormulaLink(CategoryRepository $category_repo, object $user, string $type, mixed $raw_linked, mixed $raw_pct): array
{
$has_linked = $raw_linked !== null && $raw_linked !== '';
$has_pct = $raw_pct !== null && $raw_pct !== '';
if (!$has_linked && !$has_pct) {
return [null, null, null];
}
if ($type !== 'income') {
return [null, null, 'Формула доступна только для статей дохода'];
}
if ($has_linked !== $has_pct) {
return [null, null, 'Укажите и связанную статью, и процент'];
}
if (!is_numeric($raw_pct) || (float)$raw_pct < 0 || (float)$raw_pct > 100) {
return [null, null, 'Процент должен быть от 0 до 100'];
}
$linked = $category_repo->get((int)$raw_linked);
if (!$linked || (int)$linked->user_id !== (int)$user->id || $linked->type !== 'expense') {
return [null, null, 'Связанная статья недоступна'];
}
return [(int)$linked->id, (float)$raw_pct, null];
}
/**
* Бюджетный пользователь, связанный с текущим логином Services\Auth.
*
* @return object
* @throws HTTPException|MyException
*/
protected function currentUser(): object
{
$auth_user = Auth::instance()->getUser();
$user = (new UserRepository())->getByLogin($auth_user['login']);
if (!$user) {
throw HTTPException::factory(404);
}
return $user;
}
/**
* Статья по id, принадлежащая текущему пользователю — иначе 404 (не 403, как и у счетов).
*
* @param CategoryRepository $category_repo
* @param object $user
* @param int $id
* @return object
* @throws HTTPException|MyException
*/
protected function ownCategory(CategoryRepository $category_repo, object $user, int $id): object
{
$category = $category_repo->get($id);
if (!$category || (int)$category->user_id !== (int)$user->id) {
throw HTTPException::factory(404);
}
return $category;
}
}

View File

@ -9,6 +9,8 @@
namespace App\Controller;
use App\Repositories\AccountRepository;
use App\Repositories\BudgetRepository;
use App\Repositories\CategoryRepository;
use App\Repositories\UserRepository;
use Services\Auth;
use System\Classes\Controller;
@ -40,11 +42,29 @@ class IndexController extends Controller
$accounts ?: []
));
$month = (int)date('n');
$year = (int)date('Y');
$category_repo = new CategoryRepository();
$budget_repo = new BudgetRepository();
// Факта (реальных transactions) в проекте ещё нет — это отдельная будущая фича
// (см. .claude/memory/бюджет_текущий_план.md), поэтому fact = 0 везде; прогресс-бары
// покажут 0%, пока операции не появятся, и заполнятся сами без правки этого кода.
$tree = $user ? $category_repo->getTree($user->id) : [];
$plan_month = $user ? array_map(
static fn ($months) => $months[$month]['plan_summ'] ?? 0.0,
$budget_repo->getYear($user->id, $year)
) : [];
return $this->render('index', [
'user' => $auth_user,
'accounts' => $accounts ?: [],
'archived_accounts' => $archived_accounts ?: [],
'total' => $total,
'tree' => $tree,
'plan_month' => $plan_month,
'month' => $month,
]);
}
}

View File

@ -8,6 +8,7 @@
namespace App\Repositories;
use PDO;
use System\Classes\Repository;
/**
@ -15,6 +16,12 @@ use System\Classes\Repository;
*/
class BudgetRepository extends Repository
{
/**
* @var string Таблица categories для JOIN в getYear() — вынесена в свойство (не хардкод в SQL),
* чтобы тесты могли подменить её на временную, не трогая боевую таблицу categories.
*/
protected string $categories_table = 'categories';
/**
* @param string|null $connection Имя подключения (Database::instance($connection)); null — по умолчанию
*/
@ -22,4 +29,65 @@ class BudgetRepository extends Repository
{
parent::__construct('budgets', connection: $connection);
}
/**
* План на год пользователя, сгруппированный по статье и месяцу — для сетки на странице плана.
*
* @param int $user_id
* @param int $year
* @return array<int,array<int,array{plan_summ:float,formula_pct_used:?float}>>
*/
public function getYear(int $user_id, int $year): array
{
$sql = "SELECT b.categorie_id, b.month, b.plan_summ, b.formula_pct_used
FROM $this->table b
INNER JOIN $this->categories_table c ON c.id = b.categorie_id
WHERE c.user_id = :user_id AND b.year = :year";
$stmt = $this->pdo->prepare($sql);
$stmt->execute(['user_id' => $user_id, 'year' => $year]);
$result = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$result[(int)$row['categorie_id']][(int)$row['month']] = [
'plan_summ' => (float)$row['plan_summ'],
'formula_pct_used' => $row['formula_pct_used'] !== null ? (float)$row['formula_pct_used'] : null,
];
}
return $result;
}
/**
* Создать или обновить план статьи на конкретный месяц (по UNIQUE categorie_id+year+month).
*
* @param int $categorie_id
* @param int $year
* @param int $month
* @param float $plan_summ
* @param string $type income|expense
* @param float|null $formula_pct_used Только для строк, посчитанных по формуле связанной статьи
* @return void
*/
public function upsert(int $categorie_id, int $year, int $month, float $plan_summ, string $type, ?float $formula_pct_used = null): void
{
$existing = $this->getItemWhere("categorie_id = $categorie_id AND year = $year AND month = $month");
$data = [
'categorie_id' => $categorie_id,
'plan_summ' => $plan_summ,
'year' => $year,
'month' => $month,
'type' => $type,
'formula_pct_used' => $formula_pct_used,
];
if ($existing) {
$data['id'] = $existing->id;
$this->update($data);
} else {
$this->create($data);
}
}
}

View File

@ -22,4 +22,80 @@ class CategoryRepository extends Repository
{
parent::__construct('categories', connection: $connection);
}
/**
* Активная (is_delete=0) статья с тем же названием у пользователя, в рамках type+parent_id.
*
* @param int $user_id
* @param string $type income|expense
* @param int|null $parent_id null — верхний уровень
* @param string $title
* @param int|null $exclude_id Исключить этот id (правка своего же названия — не дубль)
* @return object|false
*/
public function findActiveDuplicate(int $user_id, string $type, ?int $parent_id, string $title, ?int $exclude_id = null): mixed
{
return $this->findDuplicate($user_id, $type, $parent_id, $title, false, $exclude_id);
}
/**
* Архивная (is_delete=1) статья с тем же названием у пользователя.
*
* @param int $user_id
* @param string $type income|expense
* @param int|null $parent_id
* @param string $title
* @param int|null $exclude_id
* @return object|false
*/
public function findArchivedDuplicate(int $user_id, string $type, ?int $parent_id, string $title, ?int $exclude_id = null): mixed
{
return $this->findDuplicate($user_id, $type, $parent_id, $title, true, $exclude_id);
}
/**
* @param int $user_id
* @param string $type
* @param int|null $parent_id
* @param string $title
* @param bool $is_delete
* @param int|null $exclude_id
* @return object|false
*/
protected function findDuplicate(int $user_id, string $type, ?int $parent_id, string $title, bool $is_delete, ?int $exclude_id): mixed
{
$pdo = $this->pdo->pdo();
$where = "user_id = $user_id"
. ' AND type = ' . $pdo->quote($type)
. ' AND is_delete = ' . ($is_delete ? 1 : 0)
. ' AND title = ' . $pdo->quote($title)
. ' AND parent_id ' . ($parent_id === null ? 'IS NULL' : "= $parent_id");
if ($exclude_id !== null) {
$where .= " AND id != $exclude_id";
}
return $this->getItemWhere($where);
}
/**
* Активные статьи пользователя, сгруппированные по parent_id (0 — верхний уровень, вместо null,
* чтобы использовать как ключ массива) — для рендера дерева на странице статей.
*
* @param int $user_id
* @return array<int,object[]>
*/
public function getTree(int $user_id): array
{
$categories = $this->getList('*', ['user_id' => $user_id, 'is_delete' => 0], '`order`');
$tree = [];
foreach ($categories ?: [] as $category) {
$key = $category->parent_id === null ? 0 : (int)$category->parent_id;
$tree[$key][] = $category;
}
return $tree;
}
}

View File

@ -208,6 +208,73 @@ main {
.room-menu-list a:hover { background: var(--surface-3); color: var(--ink); }
.room-menu-list a.danger { color: var(--red); }
.room-menu-move {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.6rem;
border-radius: 8px;
font-size: 0.82rem;
color: var(--ink-soft);
cursor: pointer;
}
.room-menu-move:hover { background: var(--surface-3); color: var(--ink); }
.room-menu-move select {
flex: 1;
background: transparent;
border: none;
color: inherit;
font-size: inherit;
padding: 0;
}
/* ---------- бюджет по категориям — компактный список (дашборд, макет "Бюджет — обзор") ---------- */
.budget-list {
background: var(--surface);
border-radius: 16px;
box-shadow: 0 2px 10px var(--shadow);
overflow: hidden;
margin-bottom: 1.5rem;
}
.budget-row {
padding: 0.65rem 1.1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.budget-row + .budget-row { border-top: 1px solid rgba(255, 255, 255, 0.06); }
.budget-group-head {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 0.55rem 1.1rem;
background: var(--surface-2);
}
.budget-group-head + .budget-row { border-top: none; }
.budget-row + .budget-group-head { border-top: 1px solid rgba(255, 255, 255, 0.06); }
.budget-group-name {
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--card-accent, var(--ink-soft));
}
.budget-group-sum { font-size: 0.72rem; color: var(--muted); }
.budget-name { font-size: 0.84rem; font-weight: 600; }
.budget-plan-amount { font-size: 0.84rem; font-weight: 600; color: var(--ink-soft); white-space: nowrap; }
/* пустое состояние + кнопка добавить */
.empty-state {
@ -246,6 +313,163 @@ main {
.archive-row:last-child { border-bottom: none; }
/* ---------- список статей (категории) ---------- */
.category-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 1.5rem;
}
.category-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
background: var(--surface);
border-radius: 14px;
padding: 0.7rem 0.9rem;
}
.category-row-main {
display: flex;
align-items: center;
gap: 0.6rem;
min-width: 0;
}
.category-group { font-weight: 600; }
.category-sub {
margin-left: 1.6rem;
background: var(--surface-2);
}
/* ---------- план на год: карточки (комп/планшет) + аккордeон (телефон) ----------
Своя раскладка на каждую ширину, ни одна не даёт горизонтальный скролл (см. обсуждение
дизайна в .claude/memory/бюджет_текущий_план.md):
- комп: карточка статьи — все 12 месяцев в один ряд;
- планшет: та же карточка, но сеткой 6×2 — через container query, ничего не жмётся;
- телефон: карточки-таблицы пропадают совсем, вместо них — раскрывающиеся аккордeоны
(тап по статье показывает все 12 месяцев в два столбика).
.budget-zoom — container для container query (не завязано на реальный viewport, а на
ширину самого блока — так раскладка одинаково работает и в узком, и в широком main). */
.budget-zoom { container-type: inline-size; }
.budget-plan-group { margin-bottom: 0.9rem; }
.budget-cards-wrap { display: block; }
.budget-accordion { display: none; }
.budget-cards { display: flex; flex-direction: column; gap: 0.5rem; }
.budget-card {
background: var(--surface);
border-radius: 14px;
padding: 0.75rem 0.85rem;
box-shadow: 0 2px 8px var(--shadow);
}
.budget-card-name { font-size: 0.84rem; font-weight: 600; margin-bottom: 0.5rem; }
.budget-months-inline { display: flex; gap: 0.35rem; }
.budget-cell {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
background: var(--surface-2);
border-radius: 8px;
padding: 0.35rem 0.15rem;
min-width: 0;
}
.budget-cell-label { font-size: 0.6rem; color: var(--muted); text-transform: uppercase; }
.plan-input {
width: 100%;
background: transparent;
border: none;
color: var(--ink);
text-align: center;
font-size: 0.72rem;
font-family: inherit;
padding: 0;
}
.plan-input:focus-visible { outline: 1px solid var(--teal); border-radius: 3px; }
.plan-input::-webkit-outer-spin-button, .plan-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.plan-input { -moz-appearance: textfield; }
/* планшет: та же карточка, но 12 месяцев — сеткой 6×2, не в один сжатый ряд */
@container (max-width: 740px) {
.budget-months-inline { display: grid; grid-template-columns: repeat(6, 1fr); gap: 0.4rem; }
}
/* телефон: карточки меняются на другой компонент — раскрывающиеся аккордeоны */
@container (max-width: 420px) {
.budget-cards-wrap { display: none; }
.budget-accordion { display: block; }
}
.budget-acc-card {
background: var(--surface);
border-radius: 12px;
margin-bottom: 0.5rem;
overflow: hidden;
}
.budget-acc-card summary {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.7rem 0.9rem;
font-size: 0.84rem;
font-weight: 600;
cursor: pointer;
list-style: none;
}
.budget-acc-name { flex: 1; min-width: 0; }
.budget-acc-card summary::-webkit-details-marker { display: none; }
.budget-acc-card summary::after { content: "▾"; color: var(--muted); font-size: 0.7rem; margin-left: 0.3rem; }
.budget-acc-card details[open] summary::after { content: "▴"; }
.budget-acc-total { font-size: 0.76rem; color: var(--muted); font-weight: 500; white-space: nowrap; }
.budget-acc-caption {
padding: 0 0.9rem;
margin: 0.5rem 0 0.3rem;
font-size: 0.7rem;
color: var(--muted);
}
.budget-acc-body {
padding: 0 0.9rem 0.8rem;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.4rem 0.7rem;
}
.budget-acc-month {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
background: var(--surface-2);
border-radius: 8px;
padding: 0.3rem 0.5rem;
}
.budget-acc-month span { font-size: 0.68rem; color: var(--muted); }
.budget-acc-month .plan-input { width: 4rem; flex-shrink: 0; text-align: right; }
/* ---------- нижний dock-навбар ---------- */
.dock {
@ -254,14 +478,15 @@ main {
bottom: 1.1rem;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 0.15rem;
background: rgba(23, 23, 29, 0.92);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 999px;
padding: 0.4rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
align-items: stretch;
gap: 0.2rem;
background: rgba(23, 23, 29, 0.86);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 26px;
padding: 0.5rem;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.04);
z-index: 30;
max-width: calc(100vw - 2rem);
overflow-x: auto;
@ -269,19 +494,30 @@ main {
.dock-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
padding: 0.45rem 0.9rem;
border-radius: 999px;
font-size: 0.68rem;
justify-content: center;
padding: 0.7rem;
border-radius: 18px;
color: var(--muted);
white-space: nowrap;
text-decoration: none;
flex-shrink: 0;
transition: color 0.15s ease, background 0.15s ease;
}
.dock-item.active { background: var(--ink); color: #0b0b0f; font-weight: 700; }
.dock-item .ico { width: 1.35rem; height: 1.35rem; transition: transform 0.15s ease; }
a.dock-item:hover { color: var(--ink-soft); }
.dock-item.active {
color: var(--teal);
background: color-mix(in srgb, var(--teal) 16%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--teal) 35%, transparent);
}
.dock-item.active .ico { transform: translateY(-1px); }
@media (max-width: 640px) {
.top-row { flex-direction: column; }
.balance { font-size: 2.4rem; }
.dock-item { padding: 0.5rem 0.65rem; }
}

314
App/media/css/fonts.css Normal file
View File

@ -0,0 +1,314 @@
@font-face {
font-family: 'SF Pro Display';
font-style: normal;
font-weight: 100;
src: url('../fonts/sf-pro-display_ultralight.woff2') format('woff2'), url('../fonts/sf-pro-display_ultralight.woff') format('woff'), url('../fonts/sf-pro-display_ultralight.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Display';
font-style: italic;
font-weight: 100;
src: url('../fonts/sf-pro-display_ultralight-italic.woff2') format('woff2'), url('../fonts/sf-pro-display_ultralight-italic.woff') format('woff'), url('../fonts/sf-pro-display_ultralight-italic.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Display';
font-style: normal;
font-weight: 200;
src: url('../fonts/sf-pro-display_thin.woff2') format('woff2'), url('../fonts/sf-pro-display_thin.woff') format('woff'), url('../fonts/sf-pro-display_thin.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Display';
font-style: italic;
font-weight: 200;
src: url('../fonts/sf-pro-display_thin-italic.woff2') format('woff2'), url('../fonts/sf-pro-display_thin-italic.woff') format('woff'), url('../fonts/sf-pro-display_thin-italic.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Display';
font-style: normal;
font-weight: 300;
src: url('../fonts/sf-pro-display_light.woff2') format('woff2'), url('../fonts/sf-pro-display_light.woff') format('woff'), url('../fonts/sf-pro-display_light.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Display';
font-style: italic;
font-weight: 300;
src: url('../fonts/sf-pro-display_light-italic.woff2') format('woff2'), url('../fonts/sf-pro-display_light-italic.woff') format('woff'), url('../fonts/sf-pro-display_light-italic.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Display';
font-style: normal;
font-weight: 400;
src: url('../fonts/sf-pro-display_regular.woff2') format('woff2'), url('../fonts/sf-pro-display_regular.woff') format('woff'), url('../fonts/sf-pro-display_regular.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Display';
font-style: italic;
font-weight: 400;
src: url('../fonts/sf-pro-display_regular-italic.woff2') format('woff2'), url('../fonts/sf-pro-display_regular-italic.woff') format('woff'), url('../fonts/sf-pro-display_regular-italic.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Display';
font-style: normal;
font-weight: 500;
src: url('../fonts/sf-pro-display_medium.woff2') format('woff2'), url('../fonts/sf-pro-display_medium.woff') format('woff'), url('../fonts/sf-pro-display_medium.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Display';
font-style: italic;
font-weight: 500;
src: url('../fonts/sf-pro-display_medium-italic.woff2') format('woff2'), url('../fonts/sf-pro-display_medium-italic.woff') format('woff'), url('../fonts/sf-pro-display_medium-italic.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Display';
font-style: normal;
font-weight: 600;
src: url('../fonts/sf-pro-display_semibold.woff2') format('woff2'), url('../fonts/sf-pro-display_semibold.woff') format('woff'), url('../fonts/sf-pro-display_semibold.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Display';
font-style: italic;
font-weight: 600;
src: url('../fonts/sf-pro-display_semibold-italic.woff2') format('woff2'), url('../fonts/sf-pro-display_semibold-italic.woff') format('woff'), url('../fonts/sf-pro-display_semibold-italic.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Display';
font-style: normal;
font-weight: 700;
src: url('../fonts/sf-pro-display_bold.woff2') format('woff2'), url('../fonts/sf-pro-display_bold.woff') format('woff'), url('../fonts/sf-pro-display_bold.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Display';
font-style: italic;
font-weight: 700;
src: url('../fonts/sf-pro-display_bold-italic.woff2') format('woff2'), url('../fonts/sf-pro-display_bold-italic.woff') format('woff'), url('../fonts/sf-pro-display_bold-italic.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Display';
font-style: normal;
font-weight: 800;
src: url('../fonts/sf-pro-display_heavy.woff2') format('woff2'), url('../fonts/sf-pro-display_heavy.woff') format('woff'), url('../fonts/sf-pro-display_heavy.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Display';
font-style: italic;
font-weight: 800;
src: url('../fonts/sf-pro-display_heavy-italic.woff2') format('woff2'), url('../fonts/sf-pro-display_heavy-italic.woff') format('woff'), url('../fonts/sf-pro-display_heavy-italic.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Display';
font-style: normal;
font-weight: 900;
src: url('../fonts/sf-pro-display_black.woff2') format('woff2'), url('../fonts/sf-pro-display_black.woff') format('woff'), url('../fonts/sf-pro-display_black.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Display';
font-style: italic;
font-weight: 900;
src: url('../fonts/sf-pro-display_black-italic.woff2') format('woff2'), url('../fonts/sf-pro-display_black-italic.woff') format('woff'), url('../fonts/sf-pro-display_black-italic.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Text';
font-style: normal;
font-weight: 100;
src: url('../fonts/sf-pro-text_ultralight.woff2') format('woff2'), url('../fonts/sf-pro-text_ultralight.woff') format('woff'), url('../fonts/sf-pro-text_ultralight.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Text';
font-style: italic;
font-weight: 100;
src: url('../fonts/sf-pro-text_ultralight-italic.woff2') format('woff2'), url('../fonts/sf-pro-text_ultralight-italic.woff') format('woff'), url('../fonts/sf-pro-text_ultralight-italic.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Text';
font-style: normal;
font-weight: 200;
src: url('../fonts/sf-pro-text_thin.woff2') format('woff2'), url('../fonts/sf-pro-text_thin.woff') format('woff'), url('../fonts/sf-pro-text_thin.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Text';
font-style: italic;
font-weight: 200;
src: url('../fonts/sf-pro-text_thin-italic.woff2') format('woff2'), url('../fonts/sf-pro-text_thin-italic.woff') format('woff'), url('../fonts/sf-pro-text_thin-italic.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Text';
font-style: normal;
font-weight: 300;
src: url('../fonts/sf-pro-text_light.woff2') format('woff2'), url('../fonts/sf-pro-text_light.woff') format('woff'), url('../fonts/sf-pro-text_light.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Text';
font-style: italic;
font-weight: 300;
src: url('../fonts/sf-pro-text_light-italic.woff2') format('woff2'), url('../fonts/sf-pro-text_light-italic.woff') format('woff'), url('../fonts/sf-pro-text_light-italic.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Text';
font-style: normal;
font-weight: 400;
src: url('../fonts/sf-pro-text_regular.woff2') format('woff2'), url('../fonts/sf-pro-text_regular.woff') format('woff'), url('../fonts/sf-pro-text_regular.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Text';
font-style: italic;
font-weight: 400;
src: url('../fonts/sf-pro-text_regular-italic.woff2') format('woff2'), url('../fonts/sf-pro-text_regular-italic.woff') format('woff'), url('../fonts/sf-pro-text_regular-italic.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Text';
font-style: normal;
font-weight: 500;
src: url('../fonts/sf-pro-text_medium.woff2') format('woff2'), url('../fonts/sf-pro-text_medium.woff') format('woff'), url('../fonts/sf-pro-text_medium.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Text';
font-style: italic;
font-weight: 500;
src: url('../fonts/sf-pro-text_medium-italic.woff2') format('woff2'), url('../fonts/sf-pro-text_medium-italic.woff') format('woff'), url('../fonts/sf-pro-text_medium-italic.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Text';
font-style: normal;
font-weight: 600;
src: url('../fonts/sf-pro-text_semibold.woff2') format('woff2'), url('../fonts/sf-pro-text_semibold.woff') format('woff'), url('../fonts/sf-pro-text_semibold.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Text';
font-style: italic;
font-weight: 600;
src: url('../fonts/sf-pro-text_semibold-italic.woff2') format('woff2'), url('../fonts/sf-pro-text_semibold-italic.woff') format('woff'), url('../fonts/sf-pro-text_semibold-italic.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Text';
font-style: normal;
font-weight: 700;
src: url('../fonts/sf-pro-text_bold.woff2') format('woff2'), url('../fonts/sf-pro-text_bold.woff') format('woff'), url('../fonts/sf-pro-text_bold.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Text';
font-style: italic;
font-weight: 700;
src: url('../fonts/sf-pro-text_bold-italic.woff2') format('woff2'), url('../fonts/sf-pro-text_bold-italic.woff') format('woff'), url('../fonts/sf-pro-text_bold-italic.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Text';
font-style: normal;
font-weight: 800;
src: url('../fonts/sf-pro-text_heavy.woff2') format('woff2'), url('../fonts/sf-pro-text_heavy.woff') format('woff'), url('../fonts/sf-pro-text_heavy.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Text';
font-style: italic;
font-weight: 800;
src: url('../fonts/sf-pro-text_heavy-italic.woff2') format('woff2'), url('../fonts/sf-pro-text_heavy-italic.woff') format('woff'), url('../fonts/sf-pro-text_heavy-italic.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Text';
font-style: normal;
font-weight: 900;
src: url('../fonts/sf-pro-text_black.woff2') format('woff2'), url('../fonts/sf-pro-text_black.woff') format('woff'), url('../fonts/sf-pro-text_black.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Text';
font-style: italic;
font-weight: 900;
src: url('../fonts/sf-pro-text_black-italic.woff2') format('woff2'), url('../fonts/sf-pro-text_black-italic.woff') format('woff'), url('../fonts/sf-pro-text_black-italic.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Icons';
font-style: normal;
font-weight: 100;
src: url('../fonts/sf-pro-icons_ultralight.woff2') format('woff2'), url('../fonts/sf-pro-icons_ultralight.woff') format('woff'), url('../fonts/sf-pro-icons_ultralight.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Icons';
font-style: normal;
font-weight: 200;
src: url('../fonts/sf-pro-icons_thin.woff2') format('woff2'), url('../fonts/sf-pro-icons_thin.woff') format('woff'), url('../fonts/sf-pro-icons_thin.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Icons';
font-style: normal;
font-weight: 300;
src: url('../fonts/sf-pro-icons_light.woff2') format('woff2'), url('../fonts/sf-pro-icons_light.woff') format('woff'), url('../fonts/sf-pro-icons_light.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Icons';
font-style: normal;
font-weight: 400;
src: url('../fonts/sf-pro-icons_regular.woff2') format('woff2'), url('../fonts/sf-pro-icons_regular.woff') format('woff'), url('../fonts/sf-pro-icons_regular.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Icons';
font-style: normal;
font-weight: 500;
src: url('../fonts/sf-pro-icons_medium.woff2') format('woff2'), url('../fonts/sf-pro-icons_medium.woff') format('woff'), url('../fonts/sf-pro-icons_medium.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Icons';
font-style: normal;
font-weight: 600;
src: url('../fonts/sf-pro-icons_semibold.woff2') format('woff2'), url('../fonts/sf-pro-icons_semibold.woff') format('woff'), url('../fonts/sf-pro-icons_semibold.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Icons';
font-style: normal;
font-weight: 700;
src: url('../fonts/sf-pro-icons_bold.woff2') format('woff2'), url('../fonts/sf-pro-icons_bold.woff') format('woff'), url('../fonts/sf-pro-icons_bold.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Icons';
font-style: normal;
font-weight: 800;
src: url('../fonts/sf-pro-icons_heavy.woff2') format('woff2'), url('../fonts/sf-pro-icons_heavy.woff') format('woff'), url('../fonts/sf-pro-icons_heavy.ttf') format('truetype');
}
@font-face {
font-family: 'SF Pro Icons';
font-style: normal;
font-weight: 900;
src: url('../fonts/sf-pro-icons_black.woff2') format('woff2'), url('../fonts/sf-pro-icons_black.woff') format('woff'), url('../fonts/sf-pro-icons_black.ttf') format('truetype');
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

51
App/media/js/budgets.js Normal file
View File

@ -0,0 +1,51 @@
$(function () {
var $form = $('#budgets-form');
if (!$form.length) {
return;
}
var $error = $('#budgets-form-error');
var csrf = $form.find('[name=csrf_token]').val();
var year = $form.find('[name=year]').val();
// Кнопки «Сохранить» больше нет — каждая ячейка сохраняется сама по себе при потере
// фокуса (change), формульная связанная статья пересчитывается на сервере и подставляется
// в свою (readonly) ячейку сразу, без перезагрузки страницы.
$(document).on('submit', '#budgets-form', function (e) {
e.preventDefault();
});
$(document).on('change', '.plan-input:not([readonly])', function () {
var $input = $(this);
var match = ($input.attr('name') || '').match(/^plan\[(\d+)\]\[(\d+)\]$/);
if (!match) {
return;
}
$error.hide().text('');
var data = {csrf_token: csrf, year: year};
data['plan[' + match[1] + '][' + match[2] + ']'] = $input.val();
$.post($form.attr('action'), data)
.done(function (response) {
if (!response.recalculated) {
return;
}
$.each(response.recalculated, function (categorieId, months) {
$.each(months, function (month, summ) {
$('input[name="plan[' + categorieId + '][' + month + ']"]').val(summ);
});
});
})
.fail(function (xhr) {
var errors = xhr.responseJSON && xhr.responseJSON.errors;
var message = errors ? Object.values(errors).join(' ') : 'Не удалось сохранить значение';
$error.text(message).show();
});
});
});

179
App/media/js/categories.js Normal file
View File

@ -0,0 +1,179 @@
$(function () {
var modalEl = document.getElementById('category-modal');
if (!modalEl) {
return;
}
var modal = new bootstrap.Modal(modalEl);
var $form = $('#category-form');
var $error = $('#category-form-error');
var archiveEl = document.getElementById('category-archive-modal');
var archiveModal = archiveEl ? new bootstrap.Modal(archiveEl) : null;
function filterParentOptions(type) {
$('#category-parent option[data-type]').each(function () {
var match = $(this).data('type') === type;
$(this).prop('hidden', !match).prop('disabled', !match);
});
var $selected = $('#category-parent option:selected');
if ($selected.data('type') && $selected.data('type') !== type) {
$('#category-parent').val('');
}
}
function toggleFormulaGroup(type) {
$('#category-formula-group').toggle(type === 'income');
}
function resetForm() {
$form[0].reset();
$form.find('[name=id]').val('');
$error.hide().text('');
}
$(document).on('click', '.js-category-add', function () {
var type = $(this).data('type');
resetForm();
$form.data('mode', 'create').attr('action', '/categories/create');
$('#category-modal-title').text(type === 'income' ? 'Новая статья дохода' : 'Новая статья расхода');
$('#category-type').val(type);
filterParentOptions(type);
toggleFormulaGroup(type);
modal.show();
});
$(document).on('click', '.js-category-edit', function (e) {
e.preventDefault();
var $row = $(this).closest('.category-row');
var type = $row.data('type');
resetForm();
$form.data('mode', 'edit').attr('action', '/categories/edit/' + $row.data('id'));
$form.find('[name=title]').val($row.data('title'));
$('#category-type').val(type);
$('#category-modal-title').text('Редактировать статью');
filterParentOptions(type);
$('#category-parent').val($row.data('parent_id') || '');
toggleFormulaGroup(type);
$('#category-linked').val($row.data('linked') || '');
$form.find('[name=formula_pct]').val($row.data('pct') || '');
modal.show();
});
$(document).on('click', '.js-category-archive-open', function () {
if (archiveModal) {
archiveModal.show();
}
});
$(document).on('click', '.js-category-restore', function () {
var $row = $(this).closest('.archive-row');
var csrf = $form.find('[name=csrf_token]').val();
$.post('/categories/restore/' + $row.data('id'), {csrf_token: csrf})
.done(function () {
window.location.reload();
})
.fail(function () {
window.alert('Не удалось восстановить статью');
});
});
$(document).on('click', '.js-category-delete', function (e) {
e.preventDefault();
var $row = $(this).closest('.category-row');
var title = $row.data('title');
var csrf = $form.find('[name=csrf_token]').val();
if (!window.confirm('Архивировать статью «' + title + '»?')) {
return;
}
$.post('/categories/delete/' + $row.data('id'), {csrf_token: csrf})
.done(function () {
window.location.reload();
})
.fail(function () {
window.alert('Не удалось архивировать статью');
});
});
$(document).on('submit', '#category-form', function (e) {
e.preventDefault();
$error.hide().text('');
$.post($form.attr('action'), $form.serialize())
.done(function () {
window.location.reload();
})
.fail(function (xhr) {
var data = xhr.responseJSON;
if (data && data.archived) {
if (window.confirm('Статья «' + data.archived.title + '» уже есть в архиве. Восстановить её вместо создания новой?')) {
var csrf = $form.find('[name=csrf_token]').val();
$.post('/categories/restore/' + data.archived.id, {csrf_token: csrf})
.done(function () {
window.location.reload();
})
.fail(function () {
window.alert('Не удалось восстановить статью');
});
}
return;
}
var errors = data && data.errors;
var message = errors ? Object.values(errors).join(' ') : 'Не удалось сохранить статью';
$error.text(message).show();
});
});
$(document).on('change', '.js-category-move', function () {
var $select = $(this);
var newParent = $select.val();
if (newParent === '') {
return;
}
var $row = $select.closest('.category-row');
var csrf = $form.find('[name=csrf_token]').val();
$.post('/categories/edit/' + $row.data('id'), {
csrf_token: csrf,
title: $row.data('title'),
type: $row.data('type'),
parent_id: newParent,
linked_expense_categorie_id: $row.data('linked') || '',
formula_pct: $row.data('pct') || ''
})
.done(function () {
window.location.reload();
})
.fail(function (xhr) {
var errors = xhr.responseJSON && xhr.responseJSON.errors;
var message = errors ? Object.values(errors).join(' ') : 'Не удалось перенести статью';
window.alert(message);
$select.val('');
});
});
$(document).on('click', '.room-menu-btn', function () {
$(this).closest('.room-menu').toggleClass('open');
});
$(document).on('click', function (e) {
if (!$(e.target).closest('.room-menu').length) {
$('.room-menu.open').removeClass('open');
}
});
});

View File

@ -0,0 +1,150 @@
<?php
/**
* @package Bicycle
* @description budgets.html — план бюджета на год по месяцам. Своя раскладка на каждое
* устройство (см. .claude/memory/бюджет_текущий_план.md → обсуждение дизайна):
* комп/планшет — карточки статьи с 12 месяцами (планшет — сеткой 6×2 вместо
* ряда, через container query); телефон — раскрывающиеся карточки-аккордeоны
* (все 12 месяцев по тапу). Ни один режим не даёт горизонтальный скролл.
* Факт (реальные transactions) здесь не показывается.
*
* @var array<int,object[]> $tree Активные статьи пользователя, сгруппированные по parent_id
* @var object[] $groups Верхнеуровневые группы (tree[0])
* @var array $plan [categorie_id => [month => ['plan_summ'=>float,'formula_pct_used'=>?float]]]
* @var int $year
*/
use System\Classes\CSRF;
$months = [
1 => 'Янв', 2 => 'Фев', 3 => 'Мар', 4 => 'Апр', 5 => 'Май', 6 => 'Июн',
7 => 'Июл', 8 => 'Авг', 9 => 'Сен', 10 => 'Окт', 11 => 'Ноя', 12 => 'Дек',
];
$sections = [
'income' => 'Доходы',
'expense' => 'Расходы',
];
// Категории, чей план считается автоматически по формуле связанной 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');
?>
<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>
</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); ?>
<?php if (empty($type_groups)): ?>
<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 !== [] ? $children : [$group]) as $category): ?>
<?php $is_target = isset($formula_targets[(int)$category->id]); ?>
<div class="budget-card">
<?php if ($children !== []): ?>
<div class="budget-card-name"><?= htmlspecialchars($category->title, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<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; ?>
</div>
<!-- ---------- телефон: один аккордeон на статью верхнего уровня (группу или
самостоятельную статью), дети — подписями внутри того же аккордeона ---------- -->
<div class="budget-accordion">
<?php foreach ($type_groups as $group): ?>
<?php
$children = $tree[$group->id] ?? [];
$rows = $children !== [] ? $children : [$group];
$year_sum = 0.0;
foreach ($rows 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 ($rows as $category): ?>
<?php $is_target = isset($formula_targets[(int)$category->id]); ?>
<?php if ($children !== []): ?>
<div class="budget-acc-caption"><?= htmlspecialchars($category->title, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<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; ?>
</div>
</div>
<?php endif; ?>
<?php endforeach; ?>
<div id="budgets-form-error" class="alert alert-danger" style="display:none"></div>
</form>

View File

@ -0,0 +1,219 @@
<?php
/**
* @package Bicycle
* @description categories.html — страница статей (доходы/расходы), группы + подкатегории.
*
* @var array<int,object[]> $tree Активные статьи пользователя, сгруппированные по parent_id (0 — верхний уровень)
* @var object[] $archived Архивные статьи пользователя
*/
use System\Classes\CSRF;
$sections = [
'income' => 'Доходы',
'expense' => 'Расходы',
];
$groups = $tree[0] ?? [];
// Плоский список активных статей (группы + подкатегории) — для select'ов в модалке.
$all = $groups;
foreach ($tree as $parent_id => $items) {
if ($parent_id !== 0) {
$all = array_merge($all, $items);
}
}
$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>
</defs>
</svg>
<?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-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); ?>
<?php if (empty($type_groups)): ?>
<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 ?>"
data-parent_id=""
data-linked="<?= (int)($group->linked_expense_categorie_id ?? 0) ?>"
data-pct="<?= htmlspecialchars((string)($group->formula_pct ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<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-category-edit"><svg class="ico ico-sm"><use href="#ic-pencil"/></svg>Редактировать</a>
<a href="#" class="danger js-category-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-parent_id="<?= (int)$sub->parent_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->parent_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; ?>
</div>
<?php endif; ?>
<?php endforeach; ?>
<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="parent_id" id="category-parent">
<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="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)): ?>
<div class="empty-state">Архив пуст</div>
<?php else: ?>
<?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>

View File

@ -145,6 +145,67 @@ $this->setScript('accounts.js');
</div>
<?php endif; ?>
<?php
$month_names = [
1 => 'январь', 2 => 'февраль', 3 => 'март', 4 => 'апрель', 5 => 'май', 6 => 'июнь',
7 => 'июль', 8 => 'август', 9 => 'сентябрь', 10 => 'октябрь', 11 => 'ноябрь', 12 => 'декабрь',
];
$groups = $tree[0] ?? [];
// Строки плана для секции типа $type (income|expense): группа с подкатегориями — заголовком
// группы + строками по ним; группа без подкатегорий — одной строкой на саму группу (тот же
// приём, что и в App/view/Budgets/budgets.html). Показываем только план — факта (реальных
// transactions) в проекте ещё нет, а прогресс-бар, который всегда 0%, только запутывает
// (см. фидбек пользователя); добавим план/факт, когда появятся операции. Замыкание, не
// именованная функция — шаблон может быть include'ится не один раз за процесс (например
// в тестах), именованная функция дала бы "Cannot redeclare".
$renderBudgetList = static function (array $groups, array $tree, array $plan_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;
}
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>';
foreach ($children as $category) {
$plan = $plan_month[$category->id] ?? 0.0;
echo '<div class="budget-row">'
. '<div class="budget-name">' . htmlspecialchars($category->title, ENT_QUOTES, 'UTF-8') . '</div>'
. '<div class="budget-plan-amount num">' . number_format($plan, 0, ',', ' ') . ' ₽</div>'
. '</div>';
}
}
echo '</div>';
};
?>
<div class="section-label"><span>Доходы</span></div>
<?php $renderBudgetList($groups, $tree, $plan_month, 'income'); ?>
<div class="section-label"><span>Бюджет на <?= $month_names[$month] ?? '' ?></span></div>
<?php $renderBudgetList($groups, $tree, $plan_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);">

View File

@ -65,16 +65,18 @@ $auth_user = Auth::instance()->getUser();
</main>
<?php if ($auth_user !== null): ?>
<nav class="dock">
<a href="/" class="dock-item active"><span class="ic"><svg class="ico"><use href="#ic-home"/></svg></span>Обзор</a>
<div class="dock-item"><span class="ic"><svg class="ico"><use href="#ic-card"/></svg></span>Счета</div>
<div class="dock-item"><span class="ic"><svg class="ico"><use href="#ic-tag"/></svg></span>Категории</div>
<div class="dock-item"><span class="ic"><svg class="ico"><use href="#ic-swap"/></svg></span>Операции</div>
<div class="dock-item"><span class="ic"><svg class="ico"><use href="#ic-target"/></svg></span>Цели</div>
<a href="/" class="dock-item active" title="Обзор"><svg class="ico"><use href="#ic-home"/></svg></a>
<div class="dock-item" title="Счета"><svg class="ico"><use href="#ic-card"/></svg></div>
<div class="dock-item" title="Категории"><svg class="ico"><use href="#ic-tag"/></svg></div>
<div class="dock-item" title="Операции"><svg class="ico"><use href="#ic-swap"/></svg></div>
<div class="dock-item" title="Цели"><svg class="ico"><use href="#ic-target"/></svg></div>
</nav>
<?php endif; ?>
<script src="/vendor/components/jquery/jquery.min.js"></script>
<script src="/vendor/twbs/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<?php $this->getScripts(); ?>
<?= ProfilerToolbar::render() ?>
<?php if($auth_user['login'] === 'mikrit1'){ ?>
<?= ProfilerToolbar::render() ?>
<?php } ?>
</body>
</html>

View File

@ -81,6 +81,9 @@ class Controller extends BaseController
* @return void
* @throws \JsonException
*/
/** @var int Безопасный предел размера X-Profiler в байтах (см. after()) */
protected const PROFILER_HEADER_LIMIT = 3000;
protected function after(): void
{
$user = Auth::instance()->getUser();
@ -102,6 +105,17 @@ class Controller extends BaseController
),
];
// Заголовок ограничен буфером прокси перед приложением (nginx proxy_buffer_size —
// обычно 4-8Кб). При запросе с большим числом SQL за раз (пример — сохранение годового
// плана бюджета: десятки upsert) полный список легко превышает лимит, и nginx рвёт весь
// ответ 502 ("upstream sent too big header") ещё до тела — уже закоммиченный к этому
// моменту результат запроса клиент не увидит. sql_count/sql_time_ms — агрегаты из
// Profiler, не из этого массива, поэтому обрезка списка их не искажает.
while ($data['sql'] !== [] && strlen(json_encode($data, JSON_UNESCAPED_UNICODE)) > self::PROFILER_HEADER_LIMIT) {
array_pop($data['sql']);
$data['sql_truncated'] = true;
}
header('X-Profiler: ' . json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
}

View File

@ -122,15 +122,18 @@ class View
public function setStyle(string $file): void
{
$name = pathinfo($file, PATHINFO_FILENAME);
$path = Core::findFile('media/css', $name, 'css');
if (Core::findFile('media/css', $name, 'css') === false) {
if ($path === false) {
echo '<div class="alert alert-danger" role="alert">Файл \'' .
htmlspecialchars($file, ENT_QUOTES, 'UTF-8') . '\' в media/css не найден</div>';
return;
}
self::$_styles[] = "/App/media/css/$name.css";
// ?v=mtime — иначе браузер может годами отдавать закэшированную старую версию файла
// после правки, без единого признака ошибки на сервере (см. feedback в памяти проекта).
self::$_styles[] = "/App/media/css/$name.css?v=" . filemtime($path);
}
/**
@ -144,15 +147,17 @@ class View
public function setScript(string $file, string $type = 'text/javascript'): void
{
$name = pathinfo($file, PATHINFO_FILENAME);
$path = Core::findFile('media/js', $name, 'js');
if (Core::findFile('media/js', $name, 'js') === false) {
if ($path === false) {
echo '<div class="alert alert-danger" role="alert">Файл \'' .
htmlspecialchars($file, ENT_QUOTES, 'UTF-8') . '\' в media/js не найден</div>';
return;
}
self::$_scripts[] = ['src' => "/App/media/js/$name.js", 'type' => $type];
// ?v=mtime — та же причина, что и в setStyle().
self::$_scripts[] = ['src' => "/App/media/js/$name.js?v=" . filemtime($path), 'type' => $type];
}
/**

BIN
qwe.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

View File

@ -0,0 +1,159 @@
<?php
namespace Tests\Unit;
use App\Repositories\BudgetRepository;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use Services\Database;
use Services\DataBase\Classes\PdoConnection;
use Services\DataBase\Classes\Profiler;
use System\Classes\Config;
use System\Classes\MyException;
use System\Classes\Repository;
/**
* BudgetRepository жёстко привязан к боевой таблице budgets конструктором, а getYear() делает
* JOIN к categories — для тестов подменяем обе таблицы на временные (тот же приём, что и в
* CategoryRepositoryTest: прямой вызов Repository::__construct(), минуя BudgetRepository::__construct()).
*/
class BudgetRepositoryTestDouble extends BudgetRepository
{
public function __construct(?string $connection = null)
{
Repository::__construct('budget_repository_test_budgets', connection: $connection);
$this->categories_table = 'budget_repository_test_categories';
}
}
class BudgetRepositoryTest extends TestCase
{
private PdoConnection $connection;
private BudgetRepositoryTestDouble $repository;
protected function setUp(): void
{
$config = Config::get('db', 'default') ?? [];
$this->connection = new PdoConnection($config);
try {
$this->connection->pdo();
} catch (MyException $e) {
$this->markTestSkipped('Нет живого подключения к MariaDB (заполните App/config/config.local.php): ' . $e->getMessage());
}
$this->connection->exec('DROP TABLE IF EXISTS budget_repository_test_budgets');
$this->connection->exec('DROP TABLE IF EXISTS budget_repository_test_categories');
$this->connection->exec(
'CREATE TABLE budget_repository_test_categories (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL
)'
);
$this->connection->exec(
'CREATE TABLE budget_repository_test_budgets (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
categorie_id INT UNSIGNED NOT NULL,
plan_summ DECIMAL(15,2) NOT NULL,
year SMALLINT UNSIGNED NOT NULL,
month TINYINT UNSIGNED NOT NULL,
type ENUM(\'income\',\'expense\') NOT NULL,
formula_pct_used DECIMAL(5,2) NULL,
UNIQUE KEY uq_period (categorie_id, year, month)
)'
);
$this->repository = new BudgetRepositoryTestDouble();
}
protected function tearDown(): void
{
Profiler::reset();
if (isset($this->connection)) {
try {
$this->connection->exec('DROP TABLE IF EXISTS budget_repository_test_budgets');
$this->connection->exec('DROP TABLE IF EXISTS budget_repository_test_categories');
} catch (MyException) {
}
}
$ref = new ReflectionClass(Database::class);
$prop = $ref->getProperty('_instances');
$prop->setAccessible(true);
$prop->setValue(null, []);
}
private function createCategory(int $user_id): int
{
$stmt = $this->connection->prepare('INSERT INTO budget_repository_test_categories (user_id) VALUES (:user_id)');
$stmt->execute(['user_id' => $user_id]);
return (int)$this->connection->lastInsertId();
}
public function testUpsertCreatesRow(): void
{
$categorie_id = $this->createCategory(1);
$this->repository->upsert($categorie_id, 2027, 3, 1000.50, 'expense');
$row = $this->repository->getItemWhere("categorie_id = $categorie_id AND year = 2027 AND month = 3");
$this->assertSame('1000.50', $row->plan_summ);
}
public function testUpsertUpdatesExistingRow(): void
{
$categorie_id = $this->createCategory(1);
$this->repository->upsert($categorie_id, 2027, 3, 1000, 'expense');
$this->repository->upsert($categorie_id, 2027, 3, 2500, 'expense');
$rows = $this->repository->getList('*', ['categorie_id' => $categorie_id]);
$this->assertCount(1, $rows);
$this->assertSame('2500.00', $rows[0]->plan_summ);
}
public function testUpsertStoresFormulaPctUsed(): void
{
$categorie_id = $this->createCategory(1);
$this->repository->upsert($categorie_id, 2027, 5, 300, 'expense', 30.5);
$row = $this->repository->getItemWhere("categorie_id = $categorie_id AND year = 2027 AND month = 5");
$this->assertSame('30.50', $row->formula_pct_used);
}
public function testGetYearReturnsOnlyUsersCategories(): void
{
$my_categorie_id = $this->createCategory(1);
$foreign_categorie_id = $this->createCategory(2);
$this->repository->upsert($my_categorie_id, 2027, 1, 100, 'income');
$this->repository->upsert($foreign_categorie_id, 2027, 1, 999, 'income');
$plan = $this->repository->getYear(1, 2027);
$this->assertArrayHasKey($my_categorie_id, $plan);
$this->assertArrayNotHasKey($foreign_categorie_id, $plan);
$this->assertSame(100.0, $plan[$my_categorie_id][1]['plan_summ']);
}
public function testGetYearFiltersByYear(): void
{
$categorie_id = $this->createCategory(1);
$this->repository->upsert($categorie_id, 2026, 1, 100, 'income');
$this->repository->upsert($categorie_id, 2027, 1, 200, 'income');
$plan = $this->repository->getYear(1, 2027);
$this->assertSame(200.0, $plan[$categorie_id][1]['plan_summ']);
$this->assertCount(1, $plan[$categorie_id]);
}
}

View File

@ -0,0 +1,170 @@
<?php
namespace Tests\Unit;
use App\Repositories\CategoryRepository;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use Services\Database;
use Services\DataBase\Classes\PdoConnection;
use Services\DataBase\Classes\Profiler;
use System\Classes\Config;
use System\Classes\MyException;
use System\Classes\Repository;
/**
* CategoryRepository жёстко привязан к боевой таблице categories конструктором — для тестов
* подменяем таблицу на временную через прямой вызов Repository::__construct(), минуя
* CategoryRepository::__construct() (тот же приём, что и RepositoryTestItemRepository
* в RepositoryTest.php, только поверх реального класса, а не заглушки).
*/
class CategoryRepositoryTestDouble extends CategoryRepository
{
public function __construct(?string $connection = null)
{
Repository::__construct('category_repository_test_categories', connection: $connection);
}
}
class CategoryRepositoryTest extends TestCase
{
private PdoConnection $connection;
private CategoryRepositoryTestDouble $repository;
protected function setUp(): void
{
$config = Config::get('db', 'default') ?? [];
$this->connection = new PdoConnection($config);
try {
$this->connection->pdo();
} catch (MyException $e) {
$this->markTestSkipped('Нет живого подключения к MariaDB (заполните App/config/config.local.php): ' . $e->getMessage());
}
$this->connection->exec('DROP TABLE IF EXISTS category_repository_test_categories');
$this->connection->exec(
'CREATE TABLE category_repository_test_categories (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
parent_id INT UNSIGNED NULL,
title VARCHAR(128) NOT NULL,
type ENUM(\'income\',\'expense\') NOT NULL,
is_delete TINYINT(1) NOT NULL DEFAULT 0,
`order` INT NOT NULL DEFAULT 0,
linked_expense_categorie_id INT UNSIGNED NULL,
formula_pct DECIMAL(5,2) NULL
)'
);
$this->repository = new CategoryRepositoryTestDouble();
}
protected function tearDown(): void
{
Profiler::reset();
if (isset($this->connection)) {
try {
$this->connection->exec('DROP TABLE IF EXISTS category_repository_test_categories');
} catch (MyException) {
}
}
$ref = new ReflectionClass(Database::class);
$prop = $ref->getProperty('_instances');
$prop->setAccessible(true);
$prop->setValue(null, []);
}
public function testFindActiveDuplicateFindsSameTitleTypeParent(): void
{
$this->repository->create([
'user_id' => 1, 'parent_id' => null, 'title' => 'Зарплата', 'type' => 'income',
'is_delete' => false, 'order' => 0,
]);
$duplicate = $this->repository->findActiveDuplicate(1, 'income', null, 'Зарплата');
$this->assertNotFalse($duplicate);
$this->assertSame('Зарплата', $duplicate->title);
}
public function testFindActiveDuplicateIgnoresDifferentType(): void
{
$this->repository->create([
'user_id' => 1, 'parent_id' => null, 'title' => 'Прочее', 'type' => 'income',
'is_delete' => false, 'order' => 0,
]);
$this->assertFalse($this->repository->findActiveDuplicate(1, 'expense', null, 'Прочее'));
}
public function testFindActiveDuplicateExcludesGivenId(): void
{
$id = $this->repository->create([
'user_id' => 1, 'parent_id' => null, 'title' => 'Транспорт', 'type' => 'expense',
'is_delete' => false, 'order' => 0,
]);
$this->assertFalse($this->repository->findActiveDuplicate(1, 'expense', null, 'Транспорт', $id));
}
public function testFindArchivedDuplicateOnlyMatchesArchived(): void
{
$this->repository->create([
'user_id' => 1, 'parent_id' => null, 'title' => 'Такси', 'type' => 'expense',
'is_delete' => true, 'order' => 0,
]);
$this->assertFalse($this->repository->findActiveDuplicate(1, 'expense', null, 'Такси'));
$archived = $this->repository->findArchivedDuplicate(1, 'expense', null, 'Такси');
$this->assertNotFalse($archived);
$this->assertSame('Такси', $archived->title);
}
public function testGetTreeGroupsByParentId(): void
{
$group_id = $this->repository->create([
'user_id' => 1, 'parent_id' => null, 'title' => 'Транспорт', 'type' => 'expense',
'is_delete' => false, 'order' => 0,
]);
$this->repository->create([
'user_id' => 1, 'parent_id' => $group_id, 'title' => 'Такси', 'type' => 'expense',
'is_delete' => false, 'order' => 0,
]);
// архивная — не должна попасть в дерево
$this->repository->create([
'user_id' => 1, 'parent_id' => $group_id, 'title' => 'Метро', 'type' => 'expense',
'is_delete' => true, 'order' => 0,
]);
$tree = $this->repository->getTree(1);
$this->assertCount(1, $tree[0]);
$this->assertSame('Транспорт', $tree[0][0]->title);
$this->assertCount(1, $tree[$group_id]);
$this->assertSame('Такси', $tree[$group_id][0]->title);
}
public function testGetTreeScopedByUser(): void
{
$this->repository->create([
'user_id' => 1, 'parent_id' => null, 'title' => 'Моя статья', 'type' => 'expense',
'is_delete' => false, 'order' => 0,
]);
$this->repository->create([
'user_id' => 2, 'parent_id' => null, 'title' => 'Чужая статья', 'type' => 'expense',
'is_delete' => false, 'order' => 0,
]);
$tree = $this->repository->getTree(1);
$this->assertCount(1, $tree[0]);
$this->assertSame('Моя статья', $tree[0][0]->title);
}
}

View File

@ -160,7 +160,9 @@ class ViewTest extends TestCase
(new View())->getStyles();
$html = ob_get_clean();
$this->assertStringContainsString('<link rel="stylesheet" href="/App/media/css/' . $this->fixtureCss . '">', $html);
$this->assertMatchesRegularExpression(
'#<link rel="stylesheet" href="/App/media/css/' . preg_quote($this->fixtureCss, '#') . '\?v=\d+">#', $html
);
}
public function testSetStyleMissingFileEchoesAlert(): void
@ -184,8 +186,8 @@ class ViewTest extends TestCase
(new View())->getScripts();
$html = ob_get_clean();
$this->assertStringContainsString(
'<script type="text/javascript" src="/App/media/js/' . $this->fixtureJs . '"></script>', $html
$this->assertMatchesRegularExpression(
'#<script type="text/javascript" src="/App/media/js/' . preg_quote($this->fixtureJs, '#') . '\?v=\d+"></script>#', $html
);
}