dev
This commit is contained in:
parent
cff643ebc5
commit
c9b81cdcd1
@ -550,15 +550,21 @@ ALTER TABLE categories ADD CONSTRAINT fk_categories_linked_expense
|
|||||||
2. **Инвестиции** (вклады/акции/облигации) — следующий слой, схему делать с запасом.
|
2. **Инвестиции** (вклады/акции/облигации) — следующий слой, схему делать с запасом.
|
||||||
3. **Кредиты** — отложены, но `account_rates` намеренно универсальна для переиспользования позже.
|
3. **Кредиты** — отложены, но `account_rates` намеренно универсальна для переиспользования позже.
|
||||||
|
|
||||||
**Статьи (categories) + годовой план (budgets)** — план утверждён (2026-08-12), реализация ещё не
|
**Статьи (categories) + годовой план (budgets)** — реализовано (2026-08-13), план
|
||||||
начата. Полный план — `/home/isaevea/.claude/plans/rippling-hatching-rabbit.md` (Часть A —
|
`/home/isaevea/.claude/plans/rippling-hatching-rabbit.md` выполнен целиком: `CategoryRepository`
|
||||||
`CategoriesController`/`categories.js`/`categories.html`, отдельная страница `/categories`, защита
|
(`findActiveDuplicate`/`findArchivedDuplicate`/`getTree`), `CategoriesController`
|
||||||
от дублей активных/архивных статей; Часть B — `BudgetsController`/`budgets.js`/`budgets.html`, сетка
|
(index/create/edit/delete/restore, защита от дублей активных/архивных статей), `categories.html` +
|
||||||
статьи×12 месяцев сохраняется одной кнопкой одним POST, автозаполнение связанной expense-статьи по
|
`categories.js` (страница `/categories`, группы+подкатегории, формула income→expense в модалке);
|
||||||
`formula_pct`). `CategoryRepository`/`BudgetRepository` пока пустые обёртки — методы
|
`BudgetRepository` (`getYear`/`upsert`, JOIN-таблица categories вынесена в свойство
|
||||||
(`findActiveDuplicate`/`findArchivedDuplicate`/`getTree`, `getYear`/`upsert`) ещё не добавлены.
|
`$categories_table` — ради тестируемости без риска для боевой таблицы), `BudgetsController`
|
||||||
Начинать реализацию — с `CategoryRepository`, затем `CategoriesController` (по образцу
|
(сетка статьи×12 месяцев, один POST, автопересчёт формульной expense-статьи отдельным проходом
|
||||||
`AccountsController`).
|
после прямых значений — не зависит от порядка обхода), `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
|
**Реальная БД и пользователь** (2026-08-12): `schema.sql` применена к боевой MariaDB
|
||||||
(`192.168.11.247:3306`, база `budget`, все таблицы были пустые на момент применения — пересоздавались
|
(`192.168.11.247:3306`, база `budget`, все таблицы были пустые на момент применения — пересоздавались
|
||||||
|
|||||||
149
App/Controller/BudgetsController.php
Normal file
149
App/Controller/BudgetsController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
321
App/Controller/CategoriesController.php
Normal file
321
App/Controller/CategoriesController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,6 +9,8 @@
|
|||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
use App\Repositories\AccountRepository;
|
use App\Repositories\AccountRepository;
|
||||||
|
use App\Repositories\BudgetRepository;
|
||||||
|
use App\Repositories\CategoryRepository;
|
||||||
use App\Repositories\UserRepository;
|
use App\Repositories\UserRepository;
|
||||||
use Services\Auth;
|
use Services\Auth;
|
||||||
use System\Classes\Controller;
|
use System\Classes\Controller;
|
||||||
@ -40,11 +42,29 @@ class IndexController extends Controller
|
|||||||
$accounts ?: []
|
$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', [
|
return $this->render('index', [
|
||||||
'user' => $auth_user,
|
'user' => $auth_user,
|
||||||
'accounts' => $accounts ?: [],
|
'accounts' => $accounts ?: [],
|
||||||
'archived_accounts' => $archived_accounts ?: [],
|
'archived_accounts' => $archived_accounts ?: [],
|
||||||
'total' => $total,
|
'total' => $total,
|
||||||
|
'tree' => $tree,
|
||||||
|
'plan_month' => $plan_month,
|
||||||
|
'month' => $month,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
namespace App\Repositories;
|
namespace App\Repositories;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
use System\Classes\Repository;
|
use System\Classes\Repository;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -15,6 +16,12 @@ use System\Classes\Repository;
|
|||||||
*/
|
*/
|
||||||
class BudgetRepository extends 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 — по умолчанию
|
* @param string|null $connection Имя подключения (Database::instance($connection)); null — по умолчанию
|
||||||
*/
|
*/
|
||||||
@ -22,4 +29,65 @@ class BudgetRepository extends Repository
|
|||||||
{
|
{
|
||||||
parent::__construct('budgets', connection: $connection);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,4 +22,80 @@ class CategoryRepository extends Repository
|
|||||||
{
|
{
|
||||||
parent::__construct('categories', connection: $connection);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -208,6 +208,73 @@ main {
|
|||||||
.room-menu-list a:hover { background: var(--surface-3); color: var(--ink); }
|
.room-menu-list a:hover { background: var(--surface-3); color: var(--ink); }
|
||||||
.room-menu-list a.danger { color: var(--red); }
|
.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 {
|
.empty-state {
|
||||||
@ -246,6 +313,163 @@ main {
|
|||||||
|
|
||||||
.archive-row:last-child { border-bottom: none; }
|
.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-навбар ---------- */
|
||||||
|
|
||||||
.dock {
|
.dock {
|
||||||
@ -254,14 +478,15 @@ main {
|
|||||||
bottom: 1.1rem;
|
bottom: 1.1rem;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: stretch;
|
||||||
gap: 0.15rem;
|
gap: 0.2rem;
|
||||||
background: rgba(23, 23, 29, 0.92);
|
background: rgba(23, 23, 29, 0.86);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(16px);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
-webkit-backdrop-filter: blur(16px);
|
||||||
border-radius: 999px;
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
padding: 0.4rem;
|
border-radius: 26px;
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
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;
|
z-index: 30;
|
||||||
max-width: calc(100vw - 2rem);
|
max-width: calc(100vw - 2rem);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@ -269,19 +494,30 @@ main {
|
|||||||
|
|
||||||
.dock-item {
|
.dock-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.15rem;
|
justify-content: center;
|
||||||
padding: 0.45rem 0.9rem;
|
padding: 0.7rem;
|
||||||
border-radius: 999px;
|
border-radius: 18px;
|
||||||
font-size: 0.68rem;
|
|
||||||
color: var(--muted);
|
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) {
|
@media (max-width: 640px) {
|
||||||
.top-row { flex-direction: column; }
|
.top-row { flex-direction: column; }
|
||||||
.balance { font-size: 2.4rem; }
|
.balance { font-size: 2.4rem; }
|
||||||
|
.dock-item { padding: 0.5rem 0.65rem; }
|
||||||
}
|
}
|
||||||
|
|||||||
314
App/media/css/fonts.css
Normal file
314
App/media/css/fonts.css
Normal 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');
|
||||||
|
}
|
||||||
BIN
App/media/fonts/sf-pro-display_bold.woff2
Normal file
BIN
App/media/fonts/sf-pro-display_bold.woff2
Normal file
Binary file not shown.
BIN
App/media/fonts/sf-pro-display_regular.woff2
Normal file
BIN
App/media/fonts/sf-pro-display_regular.woff2
Normal file
Binary file not shown.
BIN
App/media/fonts/sf-pro-display_semibold.woff2
Normal file
BIN
App/media/fonts/sf-pro-display_semibold.woff2
Normal file
Binary file not shown.
BIN
App/media/fonts/sf-pro-icons_light.woff2
Normal file
BIN
App/media/fonts/sf-pro-icons_light.woff2
Normal file
Binary file not shown.
BIN
App/media/fonts/sf-pro-text_light.woff2
Normal file
BIN
App/media/fonts/sf-pro-text_light.woff2
Normal file
Binary file not shown.
BIN
App/media/fonts/sf-pro-text_regular.woff2
Normal file
BIN
App/media/fonts/sf-pro-text_regular.woff2
Normal file
Binary file not shown.
BIN
App/media/fonts/sf-pro-text_semibold.woff2
Normal file
BIN
App/media/fonts/sf-pro-text_semibold.woff2
Normal file
Binary file not shown.
51
App/media/js/budgets.js
Normal file
51
App/media/js/budgets.js
Normal 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
179
App/media/js/categories.js
Normal 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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
150
App/view/Budgets/budgets.html
Normal file
150
App/view/Budgets/budgets.html
Normal 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 ?>">←</a>
|
||||||
|
<div class="balance num" style="font-size:2rem;"><?= $year ?></div>
|
||||||
|
<a class="btn btn-outline-light btn-sm" href="?year=<?= $year + 1 ?>">→</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>
|
||||||
219
App/view/Categories/categories.html
Normal file
219
App/view/Categories/categories.html
Normal 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">→ формула <?= 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>
|
||||||
@ -145,6 +145,67 @@ $this->setScript('accounts.js');
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?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 fade" id="account-modal" tabindex="-1" data-bs-theme="dark">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content" style="background:var(--surface-2);color:var(--ink);">
|
<div class="modal-content" style="background:var(--surface-2);color:var(--ink);">
|
||||||
|
|||||||
@ -65,16 +65,18 @@ $auth_user = Auth::instance()->getUser();
|
|||||||
</main>
|
</main>
|
||||||
<?php if ($auth_user !== null): ?>
|
<?php if ($auth_user !== null): ?>
|
||||||
<nav class="dock">
|
<nav class="dock">
|
||||||
<a href="/" class="dock-item active"><span class="ic"><svg class="ico"><use href="#ic-home"/></svg></span>Обзор</a>
|
<a href="/" class="dock-item active" title="Обзор"><svg class="ico"><use href="#ic-home"/></svg></a>
|
||||||
<div class="dock-item"><span class="ic"><svg class="ico"><use href="#ic-card"/></svg></span>Счета</div>
|
<div class="dock-item" title="Счета"><svg class="ico"><use href="#ic-card"/></svg></div>
|
||||||
<div class="dock-item"><span class="ic"><svg class="ico"><use href="#ic-tag"/></svg></span>Категории</div>
|
<div class="dock-item" title="Категории"><svg class="ico"><use href="#ic-tag"/></svg></div>
|
||||||
<div class="dock-item"><span class="ic"><svg class="ico"><use href="#ic-swap"/></svg></span>Операции</div>
|
<div class="dock-item" title="Операции"><svg class="ico"><use href="#ic-swap"/></svg></div>
|
||||||
<div class="dock-item"><span class="ic"><svg class="ico"><use href="#ic-target"/></svg></span>Цели</div>
|
<div class="dock-item" title="Цели"><svg class="ico"><use href="#ic-target"/></svg></div>
|
||||||
</nav>
|
</nav>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<script src="/vendor/components/jquery/jquery.min.js"></script>
|
<script src="/vendor/components/jquery/jquery.min.js"></script>
|
||||||
<script src="/vendor/twbs/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="/vendor/twbs/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<?php $this->getScripts(); ?>
|
<?php $this->getScripts(); ?>
|
||||||
<?= ProfilerToolbar::render() ?>
|
<?php if($auth_user['login'] === 'mikrit1'){ ?>
|
||||||
|
<?= ProfilerToolbar::render() ?>
|
||||||
|
<?php } ?>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -81,6 +81,9 @@ class Controller extends BaseController
|
|||||||
* @return void
|
* @return void
|
||||||
* @throws \JsonException
|
* @throws \JsonException
|
||||||
*/
|
*/
|
||||||
|
/** @var int Безопасный предел размера X-Profiler в байтах (см. after()) */
|
||||||
|
protected const PROFILER_HEADER_LIMIT = 3000;
|
||||||
|
|
||||||
protected function after(): void
|
protected function after(): void
|
||||||
{
|
{
|
||||||
$user = Auth::instance()->getUser();
|
$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));
|
header('X-Profiler: ' . json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -122,15 +122,18 @@ class View
|
|||||||
public function setStyle(string $file): void
|
public function setStyle(string $file): void
|
||||||
{
|
{
|
||||||
$name = pathinfo($file, PATHINFO_FILENAME);
|
$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">Файл \'' .
|
echo '<div class="alert alert-danger" role="alert">Файл \'' .
|
||||||
htmlspecialchars($file, ENT_QUOTES, 'UTF-8') . '\' в media/css не найден</div>';
|
htmlspecialchars($file, ENT_QUOTES, 'UTF-8') . '\' в media/css не найден</div>';
|
||||||
|
|
||||||
return;
|
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
|
public function setScript(string $file, string $type = 'text/javascript'): void
|
||||||
{
|
{
|
||||||
$name = pathinfo($file, PATHINFO_FILENAME);
|
$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">Файл \'' .
|
echo '<div class="alert alert-danger" role="alert">Файл \'' .
|
||||||
htmlspecialchars($file, ENT_QUOTES, 'UTF-8') . '\' в media/js не найден</div>';
|
htmlspecialchars($file, ENT_QUOTES, 'UTF-8') . '\' в media/js не найден</div>';
|
||||||
|
|
||||||
return;
|
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];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
159
tests/Unit/BudgetRepositoryTest.php
Normal file
159
tests/Unit/BudgetRepositoryTest.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
170
tests/Unit/CategoryRepositoryTest.php
Normal file
170
tests/Unit/CategoryRepositoryTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -160,7 +160,9 @@ class ViewTest extends TestCase
|
|||||||
(new View())->getStyles();
|
(new View())->getStyles();
|
||||||
$html = ob_get_clean();
|
$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
|
public function testSetStyleMissingFileEchoesAlert(): void
|
||||||
@ -184,8 +186,8 @@ class ViewTest extends TestCase
|
|||||||
(new View())->getScripts();
|
(new View())->getScripts();
|
||||||
$html = ob_get_clean();
|
$html = ob_get_clean();
|
||||||
|
|
||||||
$this->assertStringContainsString(
|
$this->assertMatchesRegularExpression(
|
||||||
'<script type="text/javascript" src="/App/media/js/' . $this->fixtureJs . '"></script>', $html
|
'#<script type="text/javascript" src="/App/media/js/' . preg_quote($this->fixtureJs, '#') . '\?v=\d+"></script>#', $html
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user