This commit is contained in:
IsaevEA 2026-08-15 10:39:22 +03:00
parent ac1334c784
commit 913e3eec38
35 changed files with 5121 additions and 705 deletions

3
.gitignore vendored
View File

@ -11,6 +11,9 @@ App/config/auth_users.php
/App/logs/*
!/App/logs/.gitkeep
/App/cache/*
!/App/cache/.gitkeep
**/files/**/*
!**/files/**/
!**/files/**/.gitkeep

View File

@ -11,6 +11,7 @@ namespace App\Controller;
use App\Classes\Currency;
use App\Repositories\AccountRateRepository;
use App\Repositories\AccountRepository;
use App\Repositories\GoalRepository;
use App\Repositories\UserRepository;
use Services\Auth;
use System\Classes\Controller;
@ -52,6 +53,12 @@ class AccountsController extends Controller
$type_acc = $request->post('type_acc');
$rate_percent = $request->post('rate_percent');
[$goal_target, $goal_deadline, $goal_error] = $this->parseGoal($type_acc, $request->post('goal_target_summa'), $request->post('goal_deadline'));
if ($goal_error !== null) {
return $this->json(['success' => false, 'errors' => ['goal_target_summa' => $goal_error]], 422);
}
$data = [
'user_id' => $user->id,
'type_acc' => $type_acc,
@ -59,14 +66,16 @@ class AccountsController extends Controller
'summa' => $summa,
'currency' => strtoupper((string)$request->post('currency')),
'description' => $request->post('description') !== '' ? $request->post('description') : null,
'include_in_total' => $request->post('include_in_total') !== null,
'color' => $request->post('color') !== '' ? $request->post('color') : null,
'privacy' => in_array($request->post('privacy'), ['shared', 'personal'], true) ? $request->post('privacy') : 'shared',
'include_in_total' => $request->post('include_in_total') === '1',
'is_delete' => false,
'order' => 0,
];
$account_repo = new AccountRepository();
$account_repo->transaction(static function () use ($account_repo, $data, $type_acc, $rate_percent) {
$account_repo->transaction(static function () use ($account_repo, $data, $type_acc, $rate_percent, $goal_target, $goal_deadline) {
$account_id = $account_repo->create($data);
if ($type_acc === 'savings' && $rate_percent !== null && $rate_percent !== '') {
@ -78,6 +87,14 @@ class AccountsController extends Controller
]);
}
if ($type_acc === 'savings' && $goal_target !== null && $goal_deadline !== null) {
(new GoalRepository())->create([
'account_id' => $account_id,
'target_summa' => $goal_target,
'deadline' => $goal_deadline,
]);
}
return $account_id;
});
@ -100,22 +117,45 @@ class AccountsController extends Controller
return $this->json(['success' => false, 'errors' => $validation->errors()], 422);
}
$summa = (float)$request->post('summa');
$summa = (float)$request->post('summa');
$type_acc = $request->post('type_acc');
if ($summa < 0) {
return $this->json(['success' => false, 'errors' => ['summa' => 'Сумма не может быть отрицательной']], 422);
}
[$goal_target, $goal_deadline, $goal_error] = $this->parseGoal($type_acc, $request->post('goal_target_summa'), $request->post('goal_deadline'));
if ($goal_error !== null) {
return $this->json(['success' => false, 'errors' => ['goal_target_summa' => $goal_error]], 422);
}
$account_repo->update([
'id' => $account->id,
'type_acc' => $request->post('type_acc'),
'type_acc' => $type_acc,
'title' => $request->post('title'),
'summa' => $summa,
'currency' => strtoupper((string)$request->post('currency')),
'description' => $request->post('description') !== '' ? $request->post('description') : null,
'include_in_total' => $request->post('include_in_total') !== null,
'color' => $request->post('color') !== '' ? $request->post('color') : null,
'privacy' => in_array($request->post('privacy'), ['shared', 'personal'], true) ? $request->post('privacy') : 'shared',
'include_in_total' => $request->post('include_in_total') === '1',
]);
$goal_repo = new GoalRepository();
$goal = $goal_repo->getByAccountId($account->id);
if ($type_acc === 'savings' && $goal_target !== null && $goal_deadline !== null) {
if ($goal) {
$goal_repo->update(['id' => $goal->id, 'target_summa' => $goal_target, 'deadline' => $goal_deadline]);
} else {
$goal_repo->create(['account_id' => $account->id, 'target_summa' => $goal_target, 'deadline' => $goal_deadline]);
}
} elseif ($goal) {
// Тип сменили с savings, либо оба поля цели очистили в форме — цель больше не нужна.
$goal_repo->delete($goal->id);
}
return $this->json(['success' => true]);
}
@ -153,6 +193,40 @@ class AccountsController extends Controller
return $this->json(['success' => true]);
}
/**
* Сохраняет ручной порядок карточек счетов (режим drag&drop на дашборде, см.
* App/media/js/accounts.js) — id[] уже в новом порядке, просто проставляем order по позиции
* в массиве. Чужие/несуществующие id молча пропускаются (не валят весь запрос), это не
* критичная операция — максимум, что может пойти не так, порядок не сохранится полностью.
*
* @return string JSON
* @throws MyException
*/
public function reorderAction(): string
{
$request = Request::$current;
$user = $this->currentUser();
$account_repo = new AccountRepository();
$ids = $request->post('ids');
if (!is_array($ids)) {
return $this->json(['success' => false, 'errors' => ['ids' => 'Некорректный список']], 422);
}
foreach (array_values($ids) as $position => $id) {
$account = $account_repo->get((int)$id);
if (!$account || (int)$account->user_id !== (int)$user->id) {
continue;
}
$account_repo->update(['id' => $account->id, 'order' => $position]);
}
return $this->json(['success' => true]);
}
/**
* Общие правила валидации формы счёта (create/edit).
*
@ -174,7 +248,45 @@ class AccountsController extends Controller
->rule('summa', 'numeric')
->rule('currency', 'required')
->rule('currency', 'in', [array_keys(Currency::LIST)])
->rule('description', 'max_length', [255]);
->rule('description', 'max_length', [255])
->rule('color', 'max_length', [20])
->rule('goal_target_summa', 'numeric')
->rule('goal_deadline', 'regex', ['/^\d{4}-\d{2}-\d{2}$/']);
}
/**
* «Цель накопления» + «Дата цели» — необязательная пара полей формы счёта, доступна только
* для type_acc=savings (см. дизайн-хендофф, App/design_handoff_personal_finance). Оба поля
* заполняются вместе — тот же паттерн, что и формульная связь категорий
* (App\Controller\CategoriesController::parseFormulaLink).
*
* @param string|null $type_acc
* @param mixed $raw_target
* @param mixed $raw_deadline
* @return array{0:float|null,1:string|null,2:string|null} [target_summa, deadline, error]
*/
protected function parseGoal(?string $type_acc, mixed $raw_target, mixed $raw_deadline): array
{
$has_target = $raw_target !== null && $raw_target !== '';
$has_deadline = $raw_deadline !== null && $raw_deadline !== '';
if (!$has_target && !$has_deadline) {
return [null, null, null];
}
if ($type_acc !== 'savings') {
return [null, null, 'Цель доступна только для накопительного счёта'];
}
if ($has_target !== $has_deadline) {
return [null, null, 'Укажите и сумму цели, и дату'];
}
if (!is_numeric($raw_target) || (float)$raw_target <= 0) {
return [null, null, 'Сумма цели должна быть больше 0'];
}
return [(float)$raw_target, (string)$raw_deadline, null];
}
/**

View File

@ -0,0 +1,71 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description AnalyticsController.php
* @copyright (c) 14/08/2026
*/
namespace App\Controller;
use App\Repositories\BudgetRepository;
use App\Repositories\TransactionRepository;
use App\Repositories\UserRepository;
use Services\Auth;
use System\Classes\Controller;
use System\Classes\HTTP\HTTPException;
use System\Classes\MyException;
use System\Classes\Request;
/**
* Аналитика — доходы/расходы по месяцам за год, структура расходов за текущий месяц, план vs факт,
* прогноз до конца года, сравнение с прошлым годом по кварталам (5 панелей дизайн-хендоффа
* App/design_handoff_personal_finance, без сторонней chart-библиотеки — сырой SVG/conic-gradient
* прямо в App/view/Analytics/index.html, как и в самом хендоффе).
*/
class AnalyticsController extends Controller
{
protected bool $_auth_protection = true;
/**
* @return string
* @throws HTTPException|MyException
*/
public function indexAction(): string
{
$auth_user = Auth::instance()->getUser();
$user = (new UserRepository())->getByLogin($auth_user['login']);
if (!$user) {
throw HTTPException::factory(404);
}
$year = (int)(Request::$current->query('year') ?: date('Y'));
$month = (int)date('n');
$tx_repo = new TransactionRepository();
$budget_repo = new BudgetRepository();
$monthly = $tx_repo->getMonthlyTotals($user->id, $year);
$monthly_prev = $tx_repo->getMonthlyTotals($user->id, $year - 1);
$expense_cats = $tx_repo->getMonthExpenseByCategory($user->id, $year, $month);
// План текущего месяца по тем же expense-категориям, что и факт (для панели «План vs факт»).
$plan_year = $budget_repo->getYear($user->id, $year);
$plan_month = [];
foreach ($expense_cats as $cat) {
$plan_month[(int)$cat->categorie_id] = $plan_year[(int)$cat->categorie_id][$month]['plan_summ'] ?? 0.0;
}
return $this->render('index', [
'user' => $auth_user,
'year' => $year,
'month' => $month,
'monthly' => $monthly,
'monthly_prev' => $monthly_prev,
'expense_cats' => $expense_cats,
'plan_month' => $plan_month,
]);
}
}

View File

@ -9,6 +9,7 @@
namespace App\Controller;
use App\Repositories\BudgetRepository;
use App\Repositories\CategoryGroupRepository;
use App\Repositories\CategoryRepository;
use App\Repositories\UserRepository;
use Services\Auth;
@ -36,16 +37,22 @@ class BudgetsController extends Controller
$year = (int)(Request::$current->query('year') ?: (int)date('Y'));
$category_repo = new CategoryRepository();
$group_repo = new CategoryGroupRepository();
$budget_repo = new BudgetRepository();
$tree = $category_repo->getTree($user->id);
$plan = $budget_repo->getYear($user->id, $year);
$tree = $category_repo->getTree($user->id);
$groups = $group_repo->getForUser($user->id);
$plan = $budget_repo->getYear($user->id, $year);
$archived = $category_repo->getList('*', ['user_id' => $user->id, 'is_delete' => 1], 'title');
$archived_groups = $group_repo->getList('*', ['user_id' => $user->id, 'is_delete' => 1], 'title');
return $this->render('budgets', [
'tree' => $tree,
'groups' => $tree[0] ?? [],
'plan' => $plan,
'year' => $year,
'tree' => $tree,
'groups' => $groups,
'plan' => $plan,
'year' => $year,
'archived' => $archived ?: [],
'archived_groups' => $archived_groups ?: [],
]);
}

View File

@ -8,6 +8,7 @@
namespace App\Controller;
use App\Repositories\CategoryGroupRepository;
use App\Repositories\CategoryRepository;
use App\Repositories\UserRepository;
use Services\Auth;
@ -18,31 +19,15 @@ use System\Classes\Request;
use System\Classes\Validation;
/**
* CRUD статей (categories) — доходы/расходы с группами (parent_id). Своя страница /categories
* (в отличие от счетов, для которых достаточно модалки на дашборде — см. AccountsController).
* CRUD статей (categories) — доходы/расходы, опционально в группе (group_id, см.
* App\Controller\CategoryGroupsController — группа теперь отдельная сущность, не статья с
* детьми). JSON API, вызывается ajax'ом из модалки «Статьи» на странице /budgets
* (см. App/view/Budgets/budgets.html, App/media/js/categories.js).
*/
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
@ -62,10 +47,10 @@ class CategoriesController extends Controller
$type = $request->post('type');
$title = $request->post('title');
$parent_id = $this->parseParentId($category_repo, $user, $type, $request->post('parent_id'));
$group_id = $this->parseGroupId($user, $type, $request->post('group_id'));
if ($parent_id === false) {
return $this->json(['success' => false, 'errors' => ['parent_id' => 'Родительская статья недоступна']], 422);
if ($group_id === false) {
return $this->json(['success' => false, 'errors' => ['group_id' => 'Группа недоступна']], 422);
}
[$linked_id, $formula_pct, $link_error] = $this->parseFormulaLink(
@ -76,11 +61,11 @@ class CategoriesController extends Controller
return $this->json(['success' => false, 'errors' => ['linked_expense_categorie_id' => $link_error]], 422);
}
if ($category_repo->findActiveDuplicate($user->id, $type, $parent_id, $title)) {
if ($category_repo->findActiveDuplicate($user->id, $type, $group_id, $title)) {
return $this->json(['success' => false, 'errors' => ['title' => 'Такая статья уже есть']], 422);
}
$archived_duplicate = $category_repo->findArchivedDuplicate($user->id, $type, $parent_id, $title);
$archived_duplicate = $category_repo->findArchivedDuplicate($user->id, $type, $group_id, $title);
if ($archived_duplicate) {
return $this->json([
@ -91,7 +76,7 @@ class CategoriesController extends Controller
$category_repo->create([
'user_id' => $user->id,
'parent_id' => $parent_id,
'group_id' => $group_id,
'title' => $title,
'type' => $type,
'is_delete' => false,
@ -123,10 +108,10 @@ class CategoriesController extends Controller
$type = $request->post('type');
$title = $request->post('title');
$parent_id = $this->parseParentId($category_repo, $user, $type, $request->post('parent_id'));
$group_id = $this->parseGroupId($user, $type, $request->post('group_id'));
if ($parent_id === false || $parent_id === (int)$category->id) {
return $this->json(['success' => false, 'errors' => ['parent_id' => 'Родительская статья недоступна']], 422);
if ($group_id === false) {
return $this->json(['success' => false, 'errors' => ['group_id' => 'Группа недоступна']], 422);
}
[$linked_id, $formula_pct, $link_error] = $this->parseFormulaLink(
@ -137,11 +122,11 @@ class CategoriesController extends Controller
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)) {
if ($category_repo->findActiveDuplicate($user->id, $type, $group_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);
$archived_duplicate = $category_repo->findArchivedDuplicate($user->id, $type, $group_id, $title, (int)$category->id);
if ($archived_duplicate) {
return $this->json([
@ -152,7 +137,7 @@ class CategoriesController extends Controller
$category_repo->update([
'id' => $category->id,
'parent_id' => $parent_id,
'group_id' => $group_id,
'title' => $title,
'type' => $type,
'linked_expense_categorie_id' => $linked_id,
@ -217,27 +202,29 @@ class CategoriesController extends Controller
}
/**
* Родительская статья (группа) из сырого значения формы — с проверкой владения/уровня/типа.
* Группа статьи из сырого значения формы — с проверкой владения/типа. Группа теперь
* самостоятельная сущность (App\Repositories\CategoryGroupRepository), поэтому, в отличие от
* старой parent_id-модели, тут не нужно отдельно проверять "родитель не должен сам быть
* подстатьёй" — group_id физически не может указывать ни на что, кроме реальной группы.
*
* @param CategoryRepository $category_repo
* @param object $user
* @param string $type Тип создаваемой/правимой статьи — родитель должен совпадать
* @param mixed $raw Сырое значение поля parent_id
* @return int|false|null null — верхний уровень (родителя нет); false — родитель недопустим
* @param object $user
* @param string $type Тип статьи — группа должна совпадать
* @param mixed $raw Сырое значение поля group_id
* @return int|false|null null — без группы; false — группа недоступна
*/
protected function parseParentId(CategoryRepository $category_repo, object $user, string $type, mixed $raw): int|false|null
protected function parseGroupId(object $user, string $type, mixed $raw): int|false|null
{
if ($raw === null || $raw === '') {
return null;
}
$parent = $category_repo->get((int)$raw);
$group = (new CategoryGroupRepository())->get((int)$raw);
if (!$parent || (int)$parent->user_id !== (int)$user->id || $parent->parent_id !== null || $parent->type !== $type) {
if (!$group || (int)$group->user_id !== (int)$user->id || $group->type !== $type || $group->is_delete) {
return false;
}
return (int)$parent->id;
return (int)$group->id;
}
/**

View File

@ -0,0 +1,200 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description CategoryGroupsController.php
* @copyright (c) 15/08/2026
*/
namespace App\Controller;
use App\Repositories\CategoryGroupRepository;
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 групп статей (category_groups) — самостоятельная сущность, отдельная от статей
* (см. App\Controller\CategoriesController). JSON API, вызывается из той же модалки «Статьи»
* на /budgets (см. App/media/js/categories.js).
*/
class CategoryGroupsController extends Controller
{
protected bool $_auth_protection = true;
/**
* @return string JSON
* @throws MyException
*/
public function createAction(): string
{
$request = Request::$current;
$user = $this->currentUser();
$group_repo = new CategoryGroupRepository();
$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');
if ($group_repo->findActiveDuplicate($user->id, $type, $title)) {
return $this->json(['success' => false, 'errors' => ['title' => 'Такая группа уже есть']], 422);
}
$archived_duplicate = $group_repo->findArchivedDuplicate($user->id, $type, $title);
if ($archived_duplicate) {
return $this->json([
'success' => false,
'archived' => ['id' => $archived_duplicate->id, 'title' => $archived_duplicate->title],
], 422);
}
$group_repo->create([
'user_id' => $user->id,
'title' => $title,
'type' => $type,
'is_delete' => false,
'order' => 0,
]);
return $this->json(['success' => true]);
}
/**
* @return string JSON
* @throws HTTPException|MyException
*/
public function editAction(): string
{
$request = Request::$current;
$user = $this->currentUser();
$group_repo = new CategoryGroupRepository();
$group = $this->ownGroup($group_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');
if ($group_repo->findActiveDuplicate($user->id, $type, $title, (int)$group->id)) {
return $this->json(['success' => false, 'errors' => ['title' => 'Такая группа уже есть']], 422);
}
$archived_duplicate = $group_repo->findArchivedDuplicate($user->id, $type, $title, (int)$group->id);
if ($archived_duplicate) {
return $this->json([
'success' => false,
'archived' => ['id' => $archived_duplicate->id, 'title' => $archived_duplicate->title],
], 422);
}
$group_repo->update(['id' => $group->id, 'title' => $title, 'type' => $type]);
return $this->json(['success' => true]);
}
/**
* Архивация группы. Статьи внутри — не архивируются автоматически, а просто становятся без
* группы (group_id = NULL): архивация группы не должна "тихо" утаскивать за собой все статьи —
* это уже было бы массовым удалением без явного подтверждения. ON DELETE SET NULL в схеме
* сработал бы так же, но только при физическом удалении группы, а тут — архивация (is_delete),
* поэтому group_id у статей чистим явно сами.
*
* @return string JSON
* @throws HTTPException|MyException
*/
public function deleteAction(): string
{
$request = Request::$current;
$user = $this->currentUser();
$group_repo = new CategoryGroupRepository();
$group = $this->ownGroup($group_repo, $user, (int)$request->param(0));
$group_repo->update(['id' => $group->id, 'is_delete' => true]);
(new CategoryRepository())->clearGroup((int)$group->id, (int)$user->id);
return $this->json(['success' => true]);
}
/**
* Возврат группы из архива.
*
* @return string JSON
* @throws HTTPException|MyException
*/
public function restoreAction(): string
{
$request = Request::$current;
$user = $this->currentUser();
$group_repo = new CategoryGroupRepository();
$group = $this->ownGroup($group_repo, $user, (int)$request->param(0));
$group_repo->update(['id' => $group->id, 'is_delete' => false]);
return $this->json(['success' => true]);
}
/**
* @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']]);
}
/**
* @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;
}
/**
* @param CategoryGroupRepository $group_repo
* @param object $user
* @param int $id
* @return object
* @throws HTTPException|MyException
*/
protected function ownGroup(CategoryGroupRepository $group_repo, object $user, int $id): object
{
$group = $group_repo->get($id);
if (!$group || (int)$group->user_id !== (int)$user->id) {
throw HTTPException::factory(404);
}
return $group;
}
}

View File

@ -0,0 +1,70 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description GoalsController.php
* @copyright (c) 15/08/2026
*/
namespace App\Controller;
use App\Repositories\AccountRepository;
use App\Repositories\GoalRepository;
use App\Repositories\UserRepository;
use Services\Auth;
use System\Classes\Controller;
use System\Classes\HTTP\HTTPException;
use System\Classes\MyException;
use System\Classes\Request;
/**
* Удаление цели (goals) — сама цель не отдельная страница, создаётся/правится прямо в модалке
* счёта (см. App\Controller\AccountsController, поля goal_target_summa/goal_deadline). Тут только
* «Удалить» из ⋮-меню карточки цели на дашборде — счёт при этом остаётся, удаляется только цель.
*/
class GoalsController extends Controller
{
protected bool $_auth_protection = true;
/**
* @return string JSON
* @throws HTTPException|MyException
*/
public function deleteAction(): string
{
$request = Request::$current;
$user = $this->currentUser();
$goal_repo = new GoalRepository();
$goal = $goal_repo->get((int)$request->param(0));
if (!$goal) {
throw HTTPException::factory(404);
}
$account = (new AccountRepository())->get((int)$goal->account_id);
if (!$account || (int)$account->user_id !== (int)$user->id) {
throw HTTPException::factory(404);
}
$goal_repo->delete($goal->id);
return $this->json(['success' => true]);
}
/**
* @return object
* @throws HTTPException|MyException
*/
protected function currentUser(): object
{
$auth_user = Auth::instance()->getUser();
$user = (new UserRepository())->getByLogin($auth_user['login']);
if (!$user) {
throw HTTPException::factory(404);
}
return $user;
}
}

View File

@ -10,11 +10,13 @@ namespace App\Controller;
use App\Repositories\AccountRepository;
use App\Repositories\BudgetRepository;
use App\Repositories\CategoryGroupRepository;
use App\Repositories\CategoryRepository;
use App\Repositories\GoalRepository;
use App\Repositories\TransactionRepository;
use App\Repositories\UserRepository;
use Services\Auth;
use Services\CurrencyRate;
use System\Classes\Controller;
use System\Classes\MyException;
@ -39,19 +41,36 @@ class IndexController extends Controller
$accounts = $user ? $account_repo->getList('*', ['user_id' => $user->id, 'is_delete' => 0], '`order`') : [];
$archived_accounts = $user ? $account_repo->getList('*', ['user_id' => $user->id, 'is_delete' => 1], 'title') : [];
// Курс ЦБ РФ на не-RUB счета — Services\CurrencyRate; при недоступности источника rate()
// возвращает null для конкретного кода, тогда счёт просто не участвует в переводе в рубли
// (не показываем непересчитанную сумму как есть — это исказило бы «Доступно сейчас»).
$rate_service = CurrencyRate::instance();
$rates = [];
foreach ($accounts ?: [] as $account) {
if ($account->currency !== 'RUB' && !array_key_exists($account->currency, $rates)) {
$rates[$account->currency] = $rate_service->rate($account->currency);
}
}
$rubValue = static function (object $account) use ($rates): float {
if ($account->currency === 'RUB') {
return (float)$account->summa;
}
$rate = $rates[$account->currency] ?? null;
return $rate !== null ? (float)$account->summa * $rate : 0.0;
};
$total = array_sum(array_map(
static fn ($account) => $account->include_in_total && $account->currency === 'RUB' ? (float)$account->summa : 0,
static fn ($account) => $account->include_in_total ? $rubValue($account) : 0,
$accounts ?: []
));
// «Всего по всем счетам» — без фильтра include_in_total, для строки под «Доступно сейчас»
// (см. дизайн-хендофф App/design_handoff_personal_finance). Валютные счета — та же
// сознательная недоделка, что и у $total выше: без Services\CurrencyRate конвертировать
// не во что, поэтому считаем только RUB (не показываем непересчитанную сумму как есть).
$total_all = array_sum(array_map(
static fn ($account) => $account->currency === 'RUB' ? (float)$account->summa : 0,
$accounts ?: []
));
// (см. дизайн-хендофф App/design_handoff_personal_finance).
$total_all = array_sum(array_map($rubValue, $accounts ?: []));
$month = (int)date('n');
$year = (int)date('Y');
@ -60,6 +79,7 @@ class IndexController extends Controller
$budget_repo = new BudgetRepository();
$tree = $user ? $category_repo->getTree($user->id) : [];
$groups = $user ? (new CategoryGroupRepository())->getForUser($user->id) : [];
$plan_month = $user ? array_map(
static fn ($months) => $months[$month]['plan_summ'] ?? 0.0,
$budget_repo->getYear($user->id, $year)
@ -86,6 +106,16 @@ class IndexController extends Controller
}
$pct_expense = $plan_expense > 0 ? round($fact_expense / $plan_expense * 100) : null;
$fact_income = 0.0;
foreach ($tree as $items) {
foreach ($items as $c) {
if ($c->type === 'income') {
$fact_income += $fact_month[(int)$c->id] ?? 0.0;
}
}
}
$month_delta = $fact_income - $fact_expense;
$goals = $user ? (new GoalRepository())->getForUser($user->id) : [];
return $this->render('index', [
@ -95,11 +125,23 @@ class IndexController extends Controller
'total' => $total,
'total_all' => $total_all,
'tree' => $tree,
'groups' => $groups,
'plan_month' => $plan_month,
'fact_month' => $fact_month,
'pct_expense' => $pct_expense,
'month_delta' => $month_delta,
'goals' => $goals ?: [],
'month' => $month,
'rates' => $rates,
]);
}
public function testAction(): string
{
return str_replace(
['href="nocturne.css"', 'src="./support.js"'],
['href="/App/design_handoff_personal_finance/nocturne.css"', ''],
file_get_contents(APPPATH . '/design_handoff_personal_finance/Личные финансы.dc.html')
);
}
}

View File

@ -0,0 +1,42 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description SettingsController.php
* @copyright (c) 14/08/2026
*/
namespace App\Controller;
use App\Repositories\UserRepository;
use Services\Auth;
use System\Classes\Controller;
use System\Classes\HTTP\HTTPException;
use System\Classes\MyException;
/**
* Настройки — профиль и быстрый доступ к управлению статьями/счетами.
*/
class SettingsController extends Controller
{
protected bool $_auth_protection = true;
/**
* @return string
* @throws HTTPException|MyException
*/
public function indexAction(): string
{
$auth_user = Auth::instance()->getUser();
$user = (new UserRepository())->getByLogin($auth_user['login']);
if (!$user) {
throw HTTPException::factory(404);
}
return $this->render('index', [
'auth_user' => $auth_user,
'user' => $user,
]);
}
}

View File

@ -0,0 +1,45 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description TransactionsController.php
* @copyright (c) 14/08/2026
*/
namespace App\Controller;
use App\Repositories\TransactionRepository;
use App\Repositories\UserRepository;
use Services\Auth;
use System\Classes\Controller;
use System\Classes\HTTP\HTTPException;
use System\Classes\MyException;
/**
* Список транзакций пользователя — последние 50, с категорией и счётом.
*/
class TransactionsController extends Controller
{
protected bool $_auth_protection = true;
/**
* @return string
* @throws HTTPException|MyException
*/
public function indexAction(): string
{
$auth_user = Auth::instance()->getUser();
$user = (new UserRepository())->getByLogin($auth_user['login']);
if (!$user) {
throw HTTPException::factory(404);
}
$transactions = (new TransactionRepository())->getRecentForUser($user->id, 50);
return $this->render('index', [
'user' => $auth_user,
'transactions' => $transactions,
]);
}
}

View File

@ -0,0 +1,90 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description CategoryGroupRepository.php
* @copyright (c) 15/08/2026
*/
namespace App\Repositories;
use System\Classes\Repository;
/**
* Репозиторий таблицы category_groups — группа статей как самостоятельная сущность (не статья
* с детьми, см. схему и .claude/memory/бюджет_текущий_план.md). Статья (categories) ссылается
* на группу через group_id (может быть NULL — статья без группы).
*/
class CategoryGroupRepository extends Repository
{
/**
* @param string|null $connection Имя подключения (Database::instance($connection)); null — по умолчанию
*/
public function __construct(?string $connection = null)
{
parent::__construct('category_groups', connection: $connection);
}
/**
* Активная (is_delete=0) группа с тем же названием у пользователя, в рамках type.
*
* @param int $user_id
* @param string $type income|expense
* @param string $title
* @param int|null $exclude_id Исключить этот id (правка своего же названия — не дубль)
* @return object|false
*/
public function findActiveDuplicate(int $user_id, string $type, string $title, ?int $exclude_id = null): mixed
{
return $this->findDuplicate($user_id, $type, $title, false, $exclude_id);
}
/**
* Архивная (is_delete=1) группа с тем же названием у пользователя.
*
* @param int $user_id
* @param string $type
* @param string $title
* @param int|null $exclude_id
* @return object|false
*/
public function findArchivedDuplicate(int $user_id, string $type, string $title, ?int $exclude_id = null): mixed
{
return $this->findDuplicate($user_id, $type, $title, true, $exclude_id);
}
/**
* @param int $user_id
* @param string $type
* @param string $title
* @param bool $is_delete
* @param int|null $exclude_id
* @return object|false
*/
protected function findDuplicate(int $user_id, string $type, 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);
if ($exclude_id !== null) {
$where .= " AND id != $exclude_id";
}
return $this->getItemWhere($where);
}
/**
* Активные группы пользователя.
*
* @param int $user_id
* @return object[]
*/
public function getForUser(int $user_id): array
{
return $this->getList('*', ['user_id' => $user_id, 'is_delete' => 0], '`order`') ?: [];
}
}

View File

@ -11,7 +11,8 @@ namespace App\Repositories;
use System\Classes\Repository;
/**
* Репозиторий таблицы categories (статьи income/expense, с подкатегориями через parent_id).
* Репозиторий таблицы categories (статьи income/expense; группа — отдельная сущность,
* см. App\Repositories\CategoryGroupRepository, статья ссылается на неё через group_id).
*/
class CategoryRepository extends Repository
{
@ -24,18 +25,18 @@ class CategoryRepository extends Repository
}
/**
* Активная (is_delete=0) статья с тем же названием у пользователя, в рамках type+parent_id.
* Активная (is_delete=0) статья с тем же названием у пользователя, в рамках type+group_id.
*
* @param int $user_id
* @param string $type income|expense
* @param int|null $parent_id null — верхний уровень
* @param int|null $group_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
public function findActiveDuplicate(int $user_id, string $type, ?int $group_id, string $title, ?int $exclude_id = null): mixed
{
return $this->findDuplicate($user_id, $type, $parent_id, $title, false, $exclude_id);
return $this->findDuplicate($user_id, $type, $group_id, $title, false, $exclude_id);
}
/**
@ -43,26 +44,26 @@ class CategoryRepository extends Repository
*
* @param int $user_id
* @param string $type income|expense
* @param int|null $parent_id
* @param int|null $group_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
public function findArchivedDuplicate(int $user_id, string $type, ?int $group_id, string $title, ?int $exclude_id = null): mixed
{
return $this->findDuplicate($user_id, $type, $parent_id, $title, true, $exclude_id);
return $this->findDuplicate($user_id, $type, $group_id, $title, true, $exclude_id);
}
/**
* @param int $user_id
* @param string $type
* @param int|null $parent_id
* @param int|null $group_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
protected function findDuplicate(int $user_id, string $type, ?int $group_id, string $title, bool $is_delete, ?int $exclude_id): mixed
{
$pdo = $this->pdo->pdo();
@ -70,7 +71,7 @@ class CategoryRepository extends Repository
. ' 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");
. ' AND group_id ' . ($group_id === null ? 'IS NULL' : "= $group_id");
if ($exclude_id !== null) {
$where .= " AND id != $exclude_id";
@ -80,8 +81,8 @@ class CategoryRepository extends Repository
}
/**
* Активные статьи пользователя, сгруппированные по parent_id (0 — верхний уровень, вместо null,
* чтобы использовать как ключ массива) — для рендера дерева на странице статей.
* Активные статьи пользователя, сгруппированные по group_id (0 — без группы, вместо null,
* чтобы использовать как ключ массива) — для рендера списка статей по группам.
*
* @param int $user_id
* @return array<int,object[]>
@ -92,10 +93,25 @@ class CategoryRepository extends Repository
$tree = [];
foreach ($categories ?: [] as $category) {
$key = $category->parent_id === null ? 0 : (int)$category->parent_id;
$key = $category->group_id === null ? 0 : (int)$category->group_id;
$tree[$key][] = $category;
}
return $tree;
}
/**
* Отвязать все статьи пользователя от группы (group_id = NULL) — используется при архивации
* группы (App\Controller\CategoryGroupsController::deleteAction), не при её физическом
* удалении (то делает fk_categories_group ... ON DELETE SET NULL само).
*
* @param int $group_id
* @param int $user_id
* @return void
*/
public function clearGroup(int $group_id, int $user_id): void
{
$this->pdo->prepare("UPDATE $this->table SET group_id = NULL WHERE group_id = :group_id AND user_id = :user_id")
->execute(['group_id' => $group_id, 'user_id' => $user_id]);
}
}

View File

@ -51,4 +51,15 @@ class GoalRepository extends Repository
// Model — и так дефолт конструктора Repository, GoalRepository его не переопределяет.
return $stmt->fetchAll(PDO::FETCH_CLASS, Model::class);
}
/**
* Цель конкретного savings-счёта (у счёта максимум одна цель — uq_goals_account_id).
*
* @param int $account_id
* @return object|false|null
*/
public function getByAccountId(int $account_id): object|false|null
{
return $this->getItemWhere("account_id = " . $account_id);
}
}

View File

@ -24,6 +24,82 @@ class TransactionRepository extends Repository
parent::__construct('transactions', connection: $connection);
}
/**
* Последние N транзакций пользователя с джойнами на категорию и счета.
*
* @param int $user_id
* @param int $limit
* @return object[]
*/
public function getRecentForUser(int $user_id, int $limit = 50): array
{
$limit = (int)$limit;
$sql = "SELECT t.id, t.date, t.type, t.summa, t.currency, t.rate_to_rub,
c.title AS category_title,
af.title AS account_f_title,
ai.title AS account_in_title
FROM {$this->table} t
LEFT JOIN categories c ON c.id = t.categorie_id
LEFT JOIN accounts af ON af.id = t.account_f_id
LEFT JOIN accounts ai ON ai.id = t.account_in_id
WHERE t.user_id = :user_id
ORDER BY t.date DESC, t.id DESC
LIMIT {$limit}";
$stmt = $this->pdo->prepare($sql);
$stmt->execute(['user_id' => $user_id]);
return $stmt->fetchAll(PDO::FETCH_OBJ);
}
/**
* Агрегат income/expense по месяцам за год.
*
* @param int $user_id
* @param int $year
* @return array<int,array<string,float>> [month => ['income'=>..., 'expense'=>...]]
*/
public function getMonthlyTotals(int $user_id, int $year): array
{
$sql = "SELECT MONTH(date) AS month, type, SUM(summa * COALESCE(rate_to_rub, 1)) AS total
FROM {$this->table}
WHERE user_id = :user_id AND YEAR(date) = :year AND type IN ('income','expense')
GROUP BY MONTH(date), type
ORDER BY MONTH(date)";
$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['month']][$row['type']] = (float)$row['total'];
}
return $result;
}
/**
* Расходы за месяц по категориям (топ 8) — для кругового распределения на аналитике.
*
* @param int $user_id
* @param int $year
* @param int $month
* @return object[] [{title, total}]
*/
public function getMonthExpenseByCategory(int $user_id, int $year, int $month): array
{
$sql = "SELECT c.id AS categorie_id, c.title, SUM(t.summa * COALESCE(t.rate_to_rub, 1)) AS total
FROM {$this->table} t
JOIN categories c ON c.id = t.categorie_id
WHERE t.user_id = :user_id AND t.type = 'expense'
AND YEAR(t.date) = :year AND MONTH(t.date) = :month
GROUP BY c.id, c.title
ORDER BY total DESC
LIMIT 8";
$stmt = $this->pdo->prepare($sql);
$stmt->execute(['user_id' => $user_id, 'year' => $year, 'month' => $month]);
return $stmt->fetchAll(PDO::FETCH_OBJ);
}
/**
* Факт за месяц по статьям — сумма реальных операций (income/expense, не transfer — у тех
* categorie_id = null), в рублях (см. бюджет_текущий_план.md — summa * COALESCE(rate_to_rub, 1)).

0
App/cache/.gitkeep vendored Normal file
View File

View File

@ -9,8 +9,8 @@ return [
'db' => [
'default' => [
'dialect' => 'mysql', // mysql|pgsql|sqlite — какой DSN собирает PdoConnection::buildDsn()
'host' => '192.168.1.36',
'port' => 3307,
'host' => 'db',
'port' => 3306,
'user' => 'root',
'password' => 'qwerty',
'dbname' => 'budget'
@ -33,6 +33,15 @@ return [
],
],
'currency_rate' => [
'default' => [
'url' => 'https://www.cbr.ru/scripts/XML_daily.asp',
'timeout' => 10,
'cache_path' => APPPATH . '/cache/currency_rate.json',
'ttl' => 86400,
],
],
'session' => [
'cookie_params' => [
'lifetime' => 0,

View File

@ -1,34 +0,0 @@
# Handoff: Личные финансы (Dashboard / План на год / Аналитика)
## Overview
Адаптивный (комп/планшет/телефон) прототип системы личных финансов: дашборд, годовой план по месяцам с CRUD статей доходов/расходов (категории + подкатегории, с формулой-привязкой доход→расход), экран транзакций, аналитика. Нижняя навигация: Дашборд / Бюджет / Транзакции / Аналитика / Настройки.
## About the Design Files
Файлы в этой папке (`Личные финансы.dc.html`, `nocturne.css`) — **дизайн-референс на HTML**, не production-код. Задача разработчика — **пересобрать эти экраны в целевом окружении проекта** (в вашем случае — существующее приложение "Bicycle", PHP-бэкенд + фронтенд-шаблоны в `App/view/`), используя его текущие паттерны (layout.html, контроллеры, существующий CSS), а не копировать HTML как есть.
## Fidelity
**Hi-fi.** Финальные цвета, типографика, отступы и интерактивные состояния — DC-файл можно открыть в браузере и использовать как эталон 1:1.
## Design system
Nocturne (dark, mono-accent). Токены — в `nocturne.css` (`:root`): `--color-bg #161826`, `--color-surface #232532`, `--color-text #e9e9ed`, `--color-accent #9184d9` (+ тональные рампы 100–900 для accent/accent-2/neutral). Шрифт: Inter (`--font-heading`/`--font-body`). Радиусы/тени — `--radius-*`, `--shadow-sm/md/lg`. Разноцветные акценты счетов/статей в дашборде заданы прямыми `oklch()` — не входят в токены Nocturne, подобраны под ту же светлоту/насыщенность.
## Screens
1. **Дашборд** — статус-бар (дата, бюджет %, дельта, курс), доступно сейчас (крупная сумма), карточки счетов (⋮-меню: редактировать/удалить, кнопка «+» → модалка «Новый счёт» → «Новый кредит»), копилки/цели (кольцевой прогресс, ⋮-меню), список статей доходов (прогресс-бар план/факт), бюджет по группам (то же, с перерасходом >100% — красный).
2. **План на год** (`planStyle`) — таблица статей × 12 месяцев (план), группы доходов/расходов, инлайн-редактируемые ячейки; на телефоне — аккордеон по группам. Кнопка «Статьи» открывает CRUD-модалку категорий (группы + подкатегории, add/edit/delete, поле формулы для доходов).
3. **Транзакции** (`transactionsStyle`) — список операций (дата, категория, сумма, доход зелёным).
4. **Аналитика** (`analyticsStyle`) — динамика доходов/расходов (SVG-полилиния), структура расходов (donut), план vs факт (бары), тренд/прогноз, год к году (столбцы по кварталам).
Модалки: «Новый счёт» (тип: расчётный/накопительный/наличные/кредит-карта, цвет, валюта, приватность, учёт в общем балансе), «Новый кредит» (новый/уже выплачиваю, 4 типа: потребительский/ипотека/авто/кредитная карта, поля ставки/срока/платежа/остатка), «Статьи доходов и расходов» (CRUD дерева категорий).
## Interactions
- Верхний свитчер экрана + свитчер устройства (комп/планшет/телефон, 1280/834/390px) — только для превью в самом DC-файле, в продакшене не нужен.
- Нижняя навигация — плавающая пилюля, 5 иконок, активная подсвечена акцентным кружком (важно: во React/Vue-эквиваленте стилизовать активный пункт целиком одним объектом стилей — не смешивать динамический style-объект с литеральной строкой CSS в одном атрибуте, как в исходном DC один раз аукнулось).
- ⋮-меню на карточках счетов/целей — клик открывает мини-дропдаун (редактировать/удалить), закрывается повторным кликом.
- CRUD статей: добавление/редактирование через инлайн-форму внутри модалки, удаление — сразу.
## Assets
Иконки — инлайн SVG-спрайт (`<symbol>` в начале файла), в духе Phosphor (design-system требует Phosphor — при переносе на реальный icon-set заменить на настоящие Phosphor-иконки: home, chart, sliders, gear, wallet, card, piggy-bank, folder, trash, pencil, plus, bank, bag, bolt, car, coin).
## Files
- `Личные финансы.dc.html` — весь прототип (все экраны + модалки + логика состояния в одном файле).
- `nocturne.css` — токены и базовые компоненты дизайн-системы Nocturne.

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,38 @@
--accent: #9184d9;
--accent-tint: #d2cefd;
/* тональные рампы nocturne.css — донор для мест, где нужен не плоский, а ступенчатый цвет
(например §Аналитика — легенда донат-диаграммы, .tag-* варианты) */
--color-neutral-100: #f3f5fe;
--color-neutral-200: #e4e7f5;
--color-neutral-300: #cfd3e5;
--color-neutral-400: #b2b6ca;
--color-neutral-500: #9397ab;
--color-neutral-600: #75798c;
--color-neutral-700: #595d6c;
--color-neutral-800: #3f424d;
--color-neutral-900: #292b31;
--color-accent-100: #f5f4ff;
--color-accent-200: #e7e5fe;
--color-accent-300: #d2cefd;
--color-accent-400: #b5abfc;
--color-accent-500: #968ae0;
--color-accent-600: #796cbf;
--color-accent-700: #5d5294;
--color-accent-800: #423a6a;
--color-accent-900: #2b2741;
--color-accent-2-100: #f5f4ff;
--color-accent-2-200: #e7e5fe;
--color-accent-2-300: #d2cefd;
--color-accent-2-400: #b5afe8;
--color-accent-2-500: #9690c9;
--color-accent-2-600: #7972a9;
--color-accent-2-700: #5c5783;
--color-accent-2-800: #423e5d;
--color-accent-2-900: #2b293a;
--teal: #2dd4bf;
--green: #34d399;
--purple: #a78bfa;
@ -27,8 +59,20 @@
--amber: #fbbf24;
--gray: #6b7280;
--sans: "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--font-heading: "SF Pro Display", var(--sans);
/* единый "зелёный" для дохода/положительной динамики — раньше по проекту было два несовпадающих
зелёных (--green:#34d399 и россыпь литералов oklch(0.7x 0.13 155)), теперь один источник */
--positive: oklch(0.72 0.13 155);
--positive-text: oklch(0.78 0.13 155);
--sans: "Inter", system-ui, sans-serif;
--font-heading: "Inter", system-ui, sans-serif;
--space-1: 2.8px;
--space-2: 5.6px;
--space-3: 8.4px;
--space-4: 11.2px;
--space-6: 16.8px;
--space-8: 22.4px;
--radius-sm: 4px;
--radius-md: 8px;
@ -36,6 +80,54 @@
--shadow-card: 0 0 0 1px var(--surface-3), 0 6px 18px var(--shadow);
--shadow-pop: 0 0 0 1px var(--surface-3), 0 16px 40px var(--shadow);
--shadow-sm: 0 0 0 1px var(--surface-3);
--shadow-md: 0 0 0 1px #595d6c, 0 6px 18px rgba(0,0,0,0.55);
--shadow-lg: 0 0 0 1px #9397ab, 0 16px 40px rgba(0,0,0,0.65);
}
/* ---------- типографическая шкала (nocturne.css) ---------- */
h1 { font: 700 42px/1.15 var(--font-heading); margin: 0; }
h2 { font: 600 32px/1.2 var(--font-heading); margin: 0; }
h3 { font: 600 25px/1.25 var(--font-heading); margin: 0; }
h4 { font: 600 20px/1.3 var(--font-heading); margin: 0; }
h5 { font: 500 16px/1.35 var(--font-heading); margin: 0; }
h6 {
font: 600 13px/1.3 var(--font-heading);
letter-spacing: 0.08em;
text-transform: uppercase;
margin: 0;
}
/* затухающая по краям линия — "фирменная деталь" nocturne */
.hr {
height: 1px;
border: none;
background: linear-gradient(
to right,
transparent,
var(--divider) 48px,
var(--divider) calc(100% - 48px),
transparent
);
margin: 0;
}
/* ---------- теги (варианты nocturne, .tag/.tag-outline уже были) ---------- */
.tag-accent {
background: var(--color-accent-900);
color: var(--color-accent-100);
}
.tag-accent-2 {
background: var(--color-accent-2-900);
color: var(--color-accent-2-100);
}
.tag-neutral {
background: var(--color-neutral-900);
color: var(--color-neutral-100);
}
body {
@ -45,6 +137,15 @@ body {
margin: 0;
}
.page-wrap {
min-height: 100vh;
background:
radial-gradient(600px 300px at 8% 0%, color-mix(in srgb, oklch(0.72 0.19 330) 14%, transparent), transparent 70%),
radial-gradient(500px 260px at 95% 10%, color-mix(in srgb, oklch(0.78 0.17 230) 12%, transparent), transparent 70%),
radial-gradient(500px 300px at 50% 60%, color-mix(in srgb, var(--positive) 8%, transparent), transparent 70%),
var(--bg);
}
main {
max-width: 72rem;
margin: 0 auto;
@ -54,130 +155,127 @@ main {
.num { font-variant-numeric: tabular-nums; }
.ico { width: 1.05em; height: 1.05em; flex-shrink: 0; }
.ico-sm { width: 0.9em; height: 0.9em; }
/* ---------- шапка: дата-плашка + теги (день недели/месяц/% бюджета) ---------- */
.card {
display: flex;
flex-direction: column;
background: var(--surface);
border-radius: var(--radius-md);
}
.elev-md { box-shadow: var(--shadow-md); }
.elev-lg { box-shadow: var(--shadow-lg); }
/* ---------- шапка: дата-плашка + теги ---------- */
.dash-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 0.9rem;
margin-bottom: 1.6rem;
}
.dash-kicker {
font-size: 0.72rem;
font-size: 0.69rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
color: color-mix(in srgb, var(--ink) 55%, transparent);
}
.dash-tags {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
margin-top: 0.6rem;
}
.tag {
display: inline-flex;
align-items: center;
font-size: 0.72rem;
font-size: 0.69rem;
letter-spacing: 0.01em;
padding: 0.2rem 0.65rem;
border-radius: var(--radius-sm);
}
.tag-outline {
border: 1px solid var(--divider);
color: var(--ink-soft);
border: 1px solid var(--accent);
color: var(--accent);
}
/* ---------- шапка: приветствие + баланс ---------- */
/* ---------- шапка: баланс ---------- */
.top-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1.5rem;
flex-wrap: wrap;
.balance-block {
margin-bottom: 2rem;
}
.hello {
font-size: 0.95rem;
color: var(--ink-soft);
}
.balance-label {
font-size: 0.72rem;
font-size: 0.69rem;
font-weight: 600;
letter-spacing: 0.06em;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--accent-tint);
margin-top: 0.6rem;
}
.balance {
font-family: var(--font-heading);
font-size: 3rem;
font-weight: 800;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1;
margin: 0.375rem 0 0;
}
.balance span { font-size: 1.4rem; font-weight: 600; color: var(--muted); }
.balance-total {
font-size: 1.15rem;
font-weight: 700;
margin-top: 0.4rem;
color: var(--ink-soft);
font-size: 0.81rem;
margin-top: 0.25rem;
color: color-mix(in srgb, var(--ink) 55%, transparent);
}
.balance-total .label {
font-size: 0.78rem;
font-weight: 500;
color: var(--muted);
margin-right: 0.4rem;
.balance-total span {
color: var(--ink);
}
/* ---------- заголовок секции ---------- */
.section-label {
display: flex;
align-items: center;
justify-content: space-between;
font-family: var(--font-heading);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
margin: 2rem 0 0.9rem;
}
.section-label:first-of-type { margin-top: 0; }
/* ---------- карточки счетов ---------- */
.grid-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 0.9rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px;
}
.grid-cards-goals {
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
}
.room-card {
position: relative;
background: var(--surface);
border-radius: var(--radius-lg);
padding: 1rem 1.1rem 1.15rem;
overflow: hidden;
box-shadow: var(--shadow-card);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
padding: 18px 18px 20px;
display: flex;
flex-direction: column;
gap: 0.625rem;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.room-card:hover { transform: translateY(-2px); }
/* акцентная подсветка снизу карточки (nocturne .acc-card::after) — цвет из --card-accent,
уже проставленного инлайново на каждой карточке счёта/цели */
.room-card::after {
content: "";
position: absolute;
left: 14%;
right: 14%;
left: 16%;
right: 16%;
bottom: 0;
height: 3px;
border-radius: 3px;
background: linear-gradient(90deg, transparent, var(--card-accent, var(--blue)), transparent);
height: 2px;
border-radius: 0 0 2px 2px;
background: linear-gradient(90deg, transparent, var(--card-accent, var(--accent)), transparent);
opacity: 0.9;
}
@ -188,47 +286,74 @@ main {
}
.room-icon {
width: 2.1rem;
height: 2.1rem;
border-radius: var(--radius-md);
width: 38px;
height: 38px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1rem;
background: color-mix(in srgb, var(--card-accent, var(--blue)) 20%, transparent);
background: color-mix(in srgb, var(--card-accent, var(--blue)) 18%, transparent);
color: var(--card-accent, var(--blue));
margin-bottom: 0.7rem;
}
.room-title { font-family: var(--font-heading); font-size: 0.92rem; font-weight: 600; }
.room-icon .ico { width: 1.2em; height: 1.2em; }
.room-title {
font-size: 0.875rem;
font-weight: 500;
color: color-mix(in srgb, var(--ink) 85%, transparent);
}
.room-sub {
font-size: 0.76rem;
color: var(--muted);
margin-top: 0.15rem;
font-size: 0.69rem;
color: color-mix(in srgb, var(--ink) 50%, transparent);
margin-top: 1px;
}
.room-value {
font-size: 1.3rem;
font-weight: 700;
margin-top: 0.6rem;
font-size: 1.375rem;
font-weight: 600;
color: var(--card-accent, var(--ink));
}
.acc-dot {
display: flex;
align-items: center;
gap: 5px;
font-size: 0.69rem;
color: color-mix(in srgb, var(--ink) 45%, transparent);
margin-top: auto;
}
.acc-dot span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--card-accent, var(--blue));
display: inline-block;
flex-shrink: 0;
}
/* три точки на карточке счёта */
.room-menu { position: relative; }
.room-menu-btn {
width: 26px;
height: 26px;
padding: 0;
min-width: 0;
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--muted);
padding: 0.15rem 0.3rem;
border-radius: 6px;
color: var(--accent);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.room-menu-btn:hover { color: var(--ink); background: var(--surface-3); }
.room-menu-btn:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); }
.room-menu-list {
display: none;
@ -280,77 +405,108 @@ main {
padding: 0;
}
/* ---------- бюджет по категориям — компактный список (дашборд, макет "Бюджет — обзор") ---------- */
/* ---------- бюджет по категориям ---------- */
.budget-list {
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
.pm-wrap {
container-type: inline-size;
overflow: hidden;
margin-bottom: 1.5rem;
margin-bottom: 1.75rem;
padding: 6px 0;
}
.budget-row {
padding: 0.65rem 1.1rem;
.stat-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
gap: 16px;
padding: 12px 20px;
border-bottom: 1px solid var(--divider);
}
.budget-row + .budget-row { border-top: 1px solid var(--divider); }
.stat-row:last-child { border-bottom: none; }
.budget-group-head {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 0.55rem 1.1rem;
background: var(--surface-2);
.stat-row > *:first-child { width: 160px; flex-shrink: 0; }
.stat-row > *:nth-child(2) { flex: 1; min-width: 0; }
.stat-row > *:last-child { width: 90px; flex-shrink: 0; text-align: right; }
@container (max-width: 440px) {
.stat-row { flex-wrap: wrap; padding: 12px 16px; row-gap: 6px; }
.stat-row > *:first-child { width: auto; flex: 1; order: 1; }
.stat-row > *:last-child { width: auto; order: 2; }
.stat-row > *:nth-child(2) { width: 100%; order: 3; }
}
.budget-group-head + .budget-row { border-top: none; }
.budget-row + .budget-group-head { border-top: 1px solid var(--divider); }
.stat-name { font-size: 0.81rem; font-weight: 600; color: var(--ink); }
.budget-group-name {
font-family: var(--font-heading);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--card-accent, var(--ink-soft));
.stat-caption {
font-size: 0.69rem;
color: color-mix(in srgb, var(--ink) 55%, transparent);
margin-top: 4px;
}
.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; }
/* строка статьи план/факт — название сверху, бар+подпись снизу, % справа (см. дизайн-хендофф) */
.budget-row-main { flex: 1; min-width: 0; }
.budget-bar {
height: 7px;
border-radius: 4px;
background: var(--surface-3);
overflow: hidden;
margin-top: 0.35rem;
margin-top: 5px;
}
.budget-bar-fill { height: 100%; border-radius: 4px; }
.budget-row-caption { font-size: 0.72rem; color: var(--muted); margin-top: 0.3rem; }
.budget-group-head {
display: flex;
justify-content: space-between;
padding: 10px 20px;
background: var(--surface-2);
}
.budget-row-side { flex-shrink: 0; font-size: 0.84rem; font-weight: 600; }
.budget-group-name {
font-size: 0.69rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--accent-tint);
}
/* ---------- копилки/цели: кольцо прогресса ---------- */
.budget-group-sum {
font-size: 0.69rem;
color: color-mix(in srgb, var(--ink) 55%, transparent);
}
.goal-card { display: flex; flex-direction: row; align-items: center; gap: 0.9rem; }
/* ---------- заголовок секции — единый источник отступа/цвета для всех "Счета"/"Копилки и
цели"/"Доходы"/"Бюджет на..."/"Доходы"+"Расходы" на /budgets (была россыпь инлайновых
style="margin:...;color:..." с разными значениями в каждом месте — свели к одному классу).
:first-of-type сбрасывает верхний отступ у первой секции страницы без ручного margin:0. ---------- */
.goal-ring { flex-shrink: 0; }
.section-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-family: var(--font-heading);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
margin: 2rem 0 0.9rem;
}
.goal-ring text { font-family: var(--sans); }
.section-label:first-of-type { margin-top: 0; }
/* ---------- card-title / card-meta (nocturne) ---------- */
.card-title { font-weight: 500; line-height: 1.2; }
.card-meta {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
color: color-mix(in srgb, var(--ink) 50%, transparent);
}
.card-meta .num { color: var(--ink); }
/* пустое состояние + кнопка добавить */
@ -376,6 +532,21 @@ main {
}
.btn-add:hover { background: var(--accent); }
.btn-add.active { background: var(--accent); color: var(--bg); }
/* ---------- режим ручной сортировки карточек счетов (drag & drop) ---------- */
.grid-cards.reorder-mode .room-card {
cursor: grab;
box-shadow: var(--shadow-md), inset 0 0 0 1.5px color-mix(in srgb, var(--accent) 55%, transparent);
}
.grid-cards.reorder-mode .room-card:active { cursor: grabbing; }
.grid-cards.reorder-mode .room-card.dragging { opacity: 0.4; }
/* в режиме сортировки клики по ⋮-меню отключены — не мешают drag-жесту */
.grid-cards.reorder-mode .room-menu { pointer-events: none; }
/* ---------- архив счетов (модалка) ---------- */
@ -556,15 +727,12 @@ main {
bottom: 1.1rem;
transform: translateX(-50%);
display: flex;
align-items: stretch;
gap: 0.2rem;
background: rgba(23, 23, 29, 0.86);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 26px;
padding: 0.5rem;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.04);
align-items: center;
gap: 6px;
background: var(--surface);
border-radius: 999px;
padding: 6px;
box-shadow: var(--shadow-md);
z-index: 30;
max-width: calc(100vw - 2rem);
overflow-x: auto;
@ -574,30 +742,31 @@ main {
display: flex;
align-items: center;
justify-content: center;
padding: 0.7rem;
border-radius: 18px;
width: 40px;
height: 40px;
border-radius: 50%;
color: var(--muted);
text-decoration: none;
flex-shrink: 0;
transition: color 0.15s ease, background 0.15s ease;
cursor: pointer;
border: none;
background: transparent;
}
.dock-item .ico { width: 1.35rem; height: 1.35rem; transition: transform 0.15s ease; }
.dock-item .ico { width: 1.15rem; height: 1.15rem; transition: transform 0.15s ease; }
a.dock-item:hover { color: var(--ink-soft); }
.dock-item.active {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 16%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);
color: var(--positive);
background: color-mix(in srgb, var(--positive) 20%, transparent);
}
.dock-item.active .ico { transform: translateY(-1px); }
@media (max-width: 640px) {
.top-row { flex-direction: column; }
.balance { font-size: 2.4rem; }
.dock-item { padding: 0.5rem 0.65rem; }
}
/* ---------- Bootstrap: формы/кнопки/модалки под токены Nocturne ----------
@ -606,6 +775,18 @@ a.dock-item:hover { color: var(--ink-soft); }
.btn { border-radius: var(--radius-md); }
.btn-ghost {
color: var(--accent);
background: transparent;
border-color: transparent;
}
.btn-ghost:hover,
.btn-ghost:focus,
.btn-ghost:active { background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent); box-shadow: none; }
.btn-icon { width: 36px; height: 36px; padding: 0; min-width: 0; }
.btn-primary {
background: var(--accent);
border-color: var(--accent);
@ -676,3 +857,171 @@ a.dock-item:hover { color: var(--ink-soft); }
}
.modal-content { border-radius: var(--radius-lg); }
/* ---------- диалог: содержимое шапки модалок (модалки теперь Bootstrap, см. .modal-content
выше — .dialog-backdrop/.dialog как самостоятельный компонент больше не используются) ---------- */
.dialog-icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: color-mix(in srgb, var(--accent) 20%, transparent);
color: var(--accent);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.dialog-sub {
font-size: 0.75rem;
color: color-mix(in srgb, var(--ink) 50%, transparent);
margin-top: 2px;
}
.dialog-section-label {
font-size: 0.69rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: color-mix(in srgb, var(--ink) 55%, transparent);
margin: 1rem 0 0.625rem;
}
.btn-icon-round {
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: transparent;
color: color-mix(in srgb, var(--ink) 55%, transparent);
cursor: pointer;
font-size: 1rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
line-height: 1;
}
.btn-icon-round:hover { background: var(--surface-3); color: var(--ink); }
/* ---------- тайлы выбора типа ---------- */
.pm-tiles-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(145px, 1fr));
gap: 10px;
}
.pm-tile {
border: 1.5px solid var(--divider);
border-radius: 10px;
padding: 12px 14px;
cursor: pointer;
display: flex;
align-items: flex-start;
gap: 10px;
background: var(--surface-2);
position: relative;
transition: border-color 0.15s;
}
.pm-tile:hover { border-color: color-mix(in srgb, var(--ink) 30%, transparent); }
.pm-tile.selected {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, var(--surface-2));
}
.pm-check {
position: absolute;
top: 10px;
right: 10px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
display: none;
align-items: center;
justify-content: center;
color: var(--bg);
font-size: 10px;
}
.pm-tile.selected .pm-check { display: flex; }
.pm-tile-icon {
width: 34px;
height: 34px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.pm-tile-title { font-size: 0.81rem; font-weight: 600; }
.pm-tile-desc { font-size: 0.69rem; color: color-mix(in srgb, var(--ink) 50%, transparent); margin-top: 2px; }
/* ---------- поля формы ---------- */
.field > label {
display: block;
font-size: 0.75rem;
margin-bottom: 5px;
color: color-mix(in srgb, var(--ink) 70%, transparent);
}
.input {
width: 100%;
min-height: 36px;
padding: 6px 10px;
font: inherit;
font-size: 0.875rem;
color: var(--ink);
caret-color: var(--accent);
background: var(--bg);
border: 1px solid var(--divider);
border-radius: var(--radius-md);
appearance: none;
-webkit-appearance: none;
box-sizing: border-box;
}
.input:hover { border-color: color-mix(in srgb, var(--ink) 45%, transparent); }
.input:focus, .input:focus-visible { border-color: var(--accent); outline: none; }
/* ---------- сегментированный контрол ---------- */
.seg {
display: inline-flex;
overflow: hidden;
border: 1px solid var(--divider);
border-radius: var(--radius-md);
}
.seg-opt {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
font-size: 0.81rem;
cursor: pointer;
}
.seg-opt input { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; }
.seg-opt + .seg-opt { border-left: 1px solid var(--divider); }
.seg-opt:has(input:checked) { color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
.seg-opt:not(:has(input:checked)):hover { background: color-mix(in srgb, var(--ink) 7%, transparent); }
/* ---------- ошибка в форме диалога ---------- */
.alert-error {
background: color-mix(in srgb, var(--red) 15%, transparent);
border: 1px solid color-mix(in srgb, var(--red) 35%, transparent);
border-radius: var(--radius-md);
padding: 0.5rem 0.75rem;
font-size: 0.81rem;
color: var(--red);
}

View File

@ -1,34 +1,77 @@
$(function () {
var modalEl = document.getElementById('account-modal');
var accountModalEl = document.getElementById('account-modal');
var creditModalEl = document.getElementById('credit-modal');
if (!modalEl) {
var $form = $('#account-form');
var $error = $('#account-form-error');
var $rate = $('#account-rate-field');
var $goalFields = $('#account-goal-fields');
var $colorHidden = $('#acc-color-hidden');
var hasColorOverride = false;
if (!accountModalEl) {
return;
}
var modal = new bootstrap.Modal(modalEl);
var $form = $('#account-form');
var $error = $('#account-form-error');
var $rate = $('#account-rate-field');
var accountModal = new bootstrap.Modal(accountModalEl);
var creditModal = creditModalEl ? new bootstrap.Modal(creditModalEl) : null;
var archiveEl = document.getElementById('archive-modal');
var archiveModal = archiveEl ? new bootstrap.Modal(archiveEl) : null;
function toggleRateField() {
$rate.toggle($form.find('[name=type_acc]').val() === 'savings');
// Плитка «Кредит» в модалке счёта переключает на модалку кредита (см. design-хендофф) —
// Bootstrap не любит два открытых .modal одновременно (общий backdrop/scroll-lock), поэтому
// ждём полного закрытия account-modal (событие hidden.bs.modal) и только потом открываем credit-modal.
var openCreditAfterHide = false;
$(accountModalEl).on('hidden.bs.modal', function () {
$('#acc-color-picker').hide();
if (openCreditAfterHide) {
openCreditAfterHide = false;
creditModal.show();
}
});
function applyPreview(color, icon) {
$('#acc-color-swatch').css('background', color);
$('#acc-icon-preview').css('background', 'color-mix(in srgb,' + color + ' 20%, transparent)').css('color', color);
$('#acc-icon-preview-use').attr('href', '#' + icon);
}
function setType(type, isEdit) {
var $tile = $('#acc-type-tiles .pm-tile[data-type="' + type + '"]');
$('#acc-type-tiles .pm-tile').removeClass('selected');
$tile.addClass('selected');
$('#acc-type-hidden').val(type);
applyPreview(hasColorOverride ? $colorHidden.val() : $tile.data('color'), $tile.data('icon'));
var isSavings = type === 'savings';
$rate.toggle(isSavings && !isEdit);
$goalFields.toggle(isSavings);
if (!isEdit) {
var includeVal = isSavings ? '0' : '1';
$form.find('[name=include_in_total][value="' + includeVal + '"]').prop('checked', true);
}
}
function resetForm() {
$form[0].reset();
$form.find('[name=id]').val('');
$error.hide().text('');
toggleRateField();
hasColorOverride = false;
$colorHidden.val('');
setType('bank', false);
$form.find('[name=include_in_total][value="1"]').prop('checked', true);
}
$(document).on('click', '.js-account-add', function () {
resetForm();
$form.data('mode', 'create').attr('action', '/accounts/create');
$('#account-modal-title').text('Новый счёт');
$('#account-rate-group').show();
modal.show();
accountModal.show();
});
$(document).on('click', '.js-account-archive-open', function () {
@ -38,64 +81,140 @@ $(function () {
});
$(document).on('click', '.js-account-restore', function () {
var $row = $(this).closest('.archive-row');
var csrf = $form.find('[name=csrf_token]').val();
var $row = $(this).closest('.archive-row');
var csrf = $form.find('[name=csrf_token]').val();
$.post('/accounts/restore/' + $row.data('id'), {csrf_token: csrf})
.done(function () {
window.location.reload();
})
.fail(function () {
window.alert('Не удалось восстановить счёт');
});
.done(function () { window.location.reload(); })
.fail(function () { window.alert('Не удалось восстановить счёт'); });
});
// Общая логика заполнения модалки счёта на редактирование — используется и для room-card
// (обычный счёт), и для goal-card (та же карточка счёта, только с иным набором data-*
// не нужна — goal-card несёт ровно те же атрибуты, см. index.html), $accountId берётся
// отдельно, т.к. имя атрибута с id разное (data-id / data-account-id).
function populateEditForm($card, accountId) {
resetForm();
$form.data('mode', 'edit').attr('action', '/accounts/edit/' + accountId);
$form.find('[name=title]').val($card.data('title'));
$form.find('[name=summa]').val($card.data('summa'));
$form.find('[name=currency]').val($card.data('currency'));
$form.find('[name=description]').val($card.data('description'));
$form.find('[name=goal_target_summa]').val($card.data('goal-target'));
$form.find('[name=goal_deadline]').val($card.data('goal-deadline'));
var color = $card.data('color');
if (color) {
hasColorOverride = true;
$colorHidden.val(color);
}
var includeVal = $card.data('include') ? '1' : '0';
$form.find('[name=include_in_total][value="' + includeVal + '"]').prop('checked', true);
setType($card.data('type'), true);
$('#account-modal-title').text('Редактировать счёт');
accountModal.show();
}
$(document).on('click', '.js-account-edit', function (e) {
e.preventDefault();
var $card = $(this).closest('.room-card');
resetForm();
$form.data('mode', 'edit').attr('action', '/accounts/edit/' + $card.data('id'));
$form.find('[name=title]').val($card.data('title'));
$form.find('[name=type_acc]').val($card.data('type'));
$form.find('[name=summa]').val($card.data('summa'));
$form.find('[name=currency]').val($card.data('currency'));
$form.find('[name=description]').val($card.data('description'));
$form.find('[name=include_in_total]').prop('checked', !!$card.data('include'));
$('#account-modal-title').text('Редактировать счёт');
// ставка правится не здесь — см. бюджет_текущий_план.md → Account_rates
$('#account-rate-group').hide();
toggleRateField();
modal.show();
populateEditForm($card, $card.data('id'));
});
$(document).on('click', '.js-account-delete', function (e) {
e.preventDefault();
var $card = $(this).closest('.room-card');
var title = $card.data('title');
var csrf = $form.find('[name=csrf_token]').val();
var $card = $(this).closest('.room-card');
var title = $card.data('title');
var csrf = $form.find('[name=csrf_token]').val();
if (!window.confirm('Архивировать счёт «' + title + '»?')) {
return;
}
$.post('/accounts/delete/' + $card.data('id'), {csrf_token: csrf})
.done(function () {
window.location.reload();
})
.fail(function () {
window.alert('Не удалось архивировать счёт');
});
.done(function () { window.location.reload(); })
.fail(function () { window.alert('Не удалось архивировать счёт'); });
});
$(document).on('change', '[name=type_acc]', function () {
toggleRateField();
// «Редактировать» на карточке копилки — просто .js-account-edit (goal-card несёт класс
// .room-card и тот же набор data-*, см. index.html), отдельного обработчика не нужно.
// «Удалить» на карточке копилки с целью — убирает только цель, счёт остаётся (для копилки
// без цели там же используется обычный .js-account-delete, архивирующий сам счёт).
$(document).on('click', '.js-goal-delete', function (e) {
e.preventDefault();
if ($form.data('mode') === 'create') {
$('#account-include').prop('checked', $(this).val() !== 'savings');
var $card = $(this).closest('.goal-card');
var csrf = $form.find('[name=csrf_token]').val();
if (!window.confirm('Удалить цель? Сам счёт останется.')) {
return;
}
$.post('/goals/delete/' + $card.data('goal-id'), {csrf_token: csrf})
.done(function () { window.location.reload(); })
.fail(function () { window.alert('Не удалось удалить цель'); });
});
$(document).on('click', '#acc-type-tiles .pm-tile', function () {
var type = $(this).data('type');
if (type === 'credit') {
openCreditAfterHide = true;
accountModal.hide();
return;
}
setType(type, $form.data('mode') === 'edit');
});
$(document).on('click', '#acc-color-swatch', function (e) {
e.stopPropagation();
$('#acc-color-picker').toggle();
});
$(document).on('click', '.acc-color-swatch-opt', function () {
var color = $(this).data('color');
hasColorOverride = true;
$colorHidden.val(color);
applyPreview(color, $('#acc-type-tiles .pm-tile.selected').data('icon'));
$('#acc-color-picker').hide();
});
$(document).on('click', function (e) {
if (!$(e.target).closest('#acc-color-swatch, #acc-color-picker').length) {
$('#acc-color-picker').hide();
}
});
// ---------- модалка «Новый кредит» (вёрстка-заглушка, см. App/design_handoff_personal_finance) ----------
$(document).on('click', '#credit-mode-tiles .pm-tile', function () {
$('#credit-mode-tiles .pm-tile').removeClass('selected');
$(this).addClass('selected');
var isExisting = $(this).data('mode') === 'existing';
$('#credit-existing-hint').toggle(isExisting);
$('#credit-existing-fields').css('display', isExisting ? 'grid' : 'none');
});
$(document).on('click', '#credit-type-tiles .pm-tile', function () {
$('#credit-type-tiles .pm-tile').removeClass('selected');
$(this).addClass('selected');
var isCard = $(this).data('credit-type') === 'card';
$('#credit-card-fields').toggle(isCard);
$('#credit-noncard-fields').toggle(!isCard);
$('#credit-min-hint').html(
'Минимум нужно: <strong style="color:var(--ink)">' +
(isCard ? 'название, лимит, ставка' : 'название, сумма, ставка и срок или платёж') +
'</strong>'
);
});
$(document).on('submit', '#account-form', function (e) {
@ -103,13 +222,10 @@ $(function () {
$error.hide().text('');
$.post($form.attr('action'), $form.serialize())
.done(function () {
window.location.reload();
})
.done(function () { window.location.reload(); })
.fail(function (xhr) {
var errors = xhr.responseJSON && xhr.responseJSON.errors;
var errors = xhr.responseJSON && xhr.responseJSON.errors;
var message = errors ? Object.values(errors).join(' ') : 'Не удалось сохранить счёт';
$error.text(message).show();
});
});
@ -123,4 +239,69 @@ $(function () {
$('.room-menu.open').removeClass('open');
}
});
// ---------- ручная сортировка счетов (drag & drop) ----------
// Одна и та же кнопка включает режим (карточки становятся draggable, ⋮-меню временно
// отключено — см. CSS .reorder-mode .room-menu) и выключает его же, сохраняя итоговый
// порядок одним запросом на /accounts/reorder.
var $accountsGrid = $('.grid-cards').first();
var reorderMode = false;
var $dragging = null;
function saveAccountOrder() {
var ids = $accountsGrid.find('.room-card').map(function () { return $(this).data('id'); }).get();
var csrf = $form.find('[name=csrf_token]').val();
$.post('/accounts/reorder', {csrf_token: csrf, ids: ids});
}
$(document).on('click', '.js-account-reorder-toggle', function () {
reorderMode = !reorderMode;
$accountsGrid.toggleClass('reorder-mode', reorderMode);
$accountsGrid.find('.room-card').attr('draggable', reorderMode);
$(this).toggleClass('active', reorderMode).attr(
'title', reorderMode ? 'Готово' : 'Изменить порядок счетов'
);
if (!reorderMode) {
saveAccountOrder();
}
});
$(document).on('dragstart', '.room-card', function (e) {
if (!reorderMode) {
return;
}
$dragging = $(this).addClass('dragging');
e.originalEvent.dataTransfer.effectAllowed = 'move';
e.originalEvent.dataTransfer.setData('text/plain', String($(this).data('id')));
});
$(document).on('dragover', '.room-card', function (e) {
if (!reorderMode || !$dragging) {
return;
}
e.preventDefault();
var $target = $(this);
if ($target.is($dragging)) {
return;
}
if ($dragging.index() < $target.index()) {
$target.after($dragging);
} else {
$target.before($dragging);
}
});
$(document).on('dragend', '.room-card', function () {
$(this).removeClass('dragging');
$dragging = null;
});
});

View File

@ -10,16 +10,33 @@ $(function () {
var $error = $('#category-form-error');
var archiveEl = document.getElementById('category-archive-modal');
var archiveModal = archiveEl ? new bootstrap.Modal(archiveEl) : null;
var categoriesModalEl = document.getElementById('categories-modal');
var categoriesModal = categoriesModalEl ? new bootstrap.Modal(categoriesModalEl) : null;
function filterParentOptions(type) {
$('#category-parent option[data-type]').each(function () {
var groupModalEl = document.getElementById('group-modal');
var groupModal = groupModalEl ? new bootstrap.Modal(groupModalEl) : null;
var $groupForm = $('#group-form');
var $groupError = $('#group-form-error');
// Внешняя модалка «Статьи» — обычный Bootstrap Modal, открывается поверх страницы /budgets;
// add/edit (category-modal/group-modal) и архив (category-archive-modal) открываются уже
// поверх неё — штатный для Bootstrap 5 стек модалок, закрытие/Escape/клик по backdrop у
// каждой работает независимо через data-bs-dismiss/её собственный bootstrap.Modal instance.
$(document).on('click', '.js-categories-open', function () {
if (categoriesModal) {
categoriesModal.show();
}
});
function filterGroupOptions(type) {
$('#category-group option[data-type]').each(function () {
var match = $(this).data('type') === type;
$(this).prop('hidden', !match).prop('disabled', !match);
});
var $selected = $('#category-parent option:selected');
var $selected = $('#category-group option:selected');
if ($selected.data('type') && $selected.data('type') !== type) {
$('#category-parent').val('');
$('#category-group').val('');
}
}
@ -40,7 +57,7 @@ $(function () {
$form.data('mode', 'create').attr('action', '/categories/create');
$('#category-modal-title').text(type === 'income' ? 'Новая статья дохода' : 'Новая статья расхода');
$('#category-type').val(type);
filterParentOptions(type);
filterGroupOptions(type);
toggleFormulaGroup(type);
modal.show();
});
@ -56,8 +73,8 @@ $(function () {
$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') || '');
filterGroupOptions(type);
$('#category-group').val($row.data('group_id') || '');
toggleFormulaGroup(type);
$('#category-linked').val($row.data('linked') || '');
$form.find('[name=formula_pct]').val($row.data('pct') || '');
@ -137,10 +154,10 @@ $(function () {
});
$(document).on('change', '.js-category-move', function () {
var $select = $(this);
var newParent = $select.val();
var $select = $(this);
var newGroup = $select.val();
if (newParent === '') {
if (newGroup === '') {
return;
}
@ -151,7 +168,7 @@ $(function () {
csrf_token: csrf,
title: $row.data('title'),
type: $row.data('type'),
parent_id: newParent,
group_id: newGroup,
linked_expense_categorie_id: $row.data('linked') || '',
formula_pct: $row.data('pct') || ''
})
@ -167,6 +184,96 @@ $(function () {
});
});
// ---------- группы (App\Controller\CategoryGroupsController) ----------
$(document).on('click', '.js-group-add', function () {
if (!groupModal) {
return;
}
var type = $(this).data('type');
$groupForm[0].reset();
$groupForm.find('[name=id]').val('');
$groupError.hide().text('');
$groupForm.data('mode', 'create').attr('action', '/categorygroups/create');
$('#group-modal-title').text(type === 'income' ? 'Новая группа доходов' : 'Новая группа расходов');
$('#group-type').val(type);
groupModal.show();
});
$(document).on('click', '.js-group-edit', function (e) {
e.preventDefault();
if (!groupModal) {
return;
}
var $row = $(this).closest('.category-row');
$groupForm[0].reset();
$groupError.hide().text('');
$groupForm.data('mode', 'edit').attr('action', '/categorygroups/edit/' + $row.data('id'));
$groupForm.find('[name=title]').val($row.data('title'));
$('#group-type').val($row.data('type'));
$('#group-modal-title').text('Редактировать группу');
groupModal.show();
});
$(document).on('submit', '#group-form', function (e) {
e.preventDefault();
$groupError.hide().text('');
$.post($groupForm.attr('action'), $groupForm.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 = $groupForm.find('[name=csrf_token]').val();
$.post('/categorygroups/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(' ') : 'Не удалось сохранить группу';
$groupError.text(message).show();
});
});
$(document).on('click', '.js-group-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('/categorygroups/delete/' + $row.data('id'), {csrf_token: csrf})
.done(function () { window.location.reload(); })
.fail(function () { window.alert('Не удалось архивировать группу'); });
});
$(document).on('click', '.js-group-restore', function () {
var $row = $(this).closest('.archive-row');
var csrf = $form.find('[name=csrf_token]').val();
$.post('/categorygroups/restore/' + $row.data('id'), {csrf_token: csrf})
.done(function () { window.location.reload(); })
.fail(function () { window.alert('Не удалось восстановить группу'); });
});
$(document).on('click', '.room-menu-btn', function () {
$(this).closest('.room-menu').toggleClass('open');
});

View File

@ -0,0 +1,235 @@
<?php
/**
* @package Bicycle
* @description Analytics/index.html — аналитика, 5 панелей дизайн-хендоффа
* (App/design_handoff_personal_finance): доходы/расходы по месяцам, структура
* расходов, план vs факт, прогноз до конца года, год к году по кварталам.
* Без сторонней chart-библиотеки — сырой inline SVG/conic-gradient, как и в самом
* хендоффе, но на реальных данных вместо хардкода.
*
* @var array $user
* @var int $year
* @var int $month
* @var array $monthly [month => ['income'=>float, 'expense'=>float]] — текущий год
* @var array $monthly_prev То же — предыдущий год ($year - 1), для панели «год к году»
* @var object[] $expense_cats [{categorie_id, title, total}] — расходы текущего месяца по категориям
* @var array $plan_month [categorie_id => plan_summ] — план текущего месяца по тем же категориям
*/
$month_names = [
1 => 'Янв', 2 => 'Фев', 3 => 'Мар', 4 => 'Апр', 5 => 'Май', 6 => 'Июн',
7 => 'Июл', 8 => 'Авг', 9 => 'Сен', 10 => 'Окт', 11 => 'Ноя', 12 => 'Дек',
];
$month_names_gen = [
1 => 'январь', 2 => 'февраль', 3 => 'март', 4 => 'апрель', 5 => 'май', 6 => 'июнь',
7 => 'июль', 8 => 'август', 9 => 'сентябрь', 10 => 'октябрь', 11 => 'ноябрь', 12 => 'декабрь',
];
$cat_colors = ['var(--accent)', 'var(--blue)', 'var(--teal)', 'var(--green)', 'var(--orange)', 'var(--pink)', 'var(--amber)', 'var(--purple)'];
$total_income = array_sum(array_column($monthly, 'income'));
$total_expense = array_sum(array_column($monthly, 'expense'));
$expense_cat_total = array_sum(array_map(fn($c) => (float)$c->total, $expense_cats));
$prev_year = $year - 1;
$next_year = $year + 1;
// ---------- панель 1: доходы/расходы по месяцам (полилинии) ----------
$chart_max = 0.0;
for ($m = 1; $m <= 12; $m++) {
$chart_max = max($chart_max, $monthly[$m]['income'] ?? 0, $monthly[$m]['expense'] ?? 0);
}
$toPoints = static function (string $key) use ($monthly, $chart_max): string {
$points = [];
for ($m = 1; $m <= 12; $m++) {
$x = 10 + ($m - 1) / 11 * 700;
$y = $chart_max > 0 ? 150 - (($monthly[$m][$key] ?? 0) / $chart_max * 140) : 150;
$points[] = round($x, 1) . ',' . round($y, 1);
}
return implode(' ', $points);
};
$income_points = $toPoints('income');
$expense_points = $toPoints('expense');
// ---------- панель 4: прогноз до конца года — грубая линейная экстраполяция по среднему
// месячному балансу с начала года (не модель, честно подписано в тексте панели ниже) ----------
$is_current_year = $year === (int)date('Y');
$elapsed = $is_current_year ? $month : 12; // прошлый год — считаем законченным целиком
$ytd_balance = 0.0;
$running = [];
$running_balance = 0.0;
for ($m = 1; $m <= $elapsed; $m++) {
$running_balance += ($monthly[$m]['income'] ?? 0.0) - ($monthly[$m]['expense'] ?? 0.0);
$ytd_balance = $running_balance;
$running[] = $running_balance;
}
$avg_monthly = $elapsed > 0 ? $ytd_balance / $elapsed : 0.0;
$remaining = max(0, 12 - $elapsed);
$forecast_total = $ytd_balance + $avg_monthly * $remaining;
$spark_max = $running !== [] ? max(array_map('abs', $running)) : 0.0;
$spark_points = [];
$spark_n = count($running);
foreach ($running as $i => $value) {
$x = $spark_n > 1 ? 10 + $i / ($spark_n - 1) * 220 : 10;
$y = $spark_max > 0 ? 30 - ($value / $spark_max * 25) : 30;
$spark_points[] = round($x, 1) . ',' . round($y, 1);
}
// ---------- панель 5: год к году по кварталам (баланс доход-расход) ----------
$quarterBalance = static function (array $monthly, int $quarter): float {
$sum = 0.0;
for ($m = ($quarter - 1) * 3 + 1; $m <= $quarter * 3; $m++) {
$sum += ($monthly[$m]['income'] ?? 0) - ($monthly[$m]['expense'] ?? 0);
}
return $sum;
};
$quarters = [];
for ($q = 1; $q <= 4; $q++) {
$quarters[$q] = ['prev' => $quarterBalance($monthly_prev, $q), 'cur' => $quarterBalance($monthly, $q)];
}
$quarter_max = 1.0;
foreach ($quarters as $q) {
$quarter_max = max($quarter_max, abs($q['prev']), abs($q['cur']));
}
?>
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-bottom:20px">
<h1 style="margin:0;font-size:24px">Аналитика</h1>
<div style="display:flex;align-items:center;gap:8px">
<a href="/analytics?year=<?= $prev_year ?>" class="btn btn-secondary btn-icon" style="width:32px;height:32px;padding:0;line-height:32px;text-align:center;text-decoration:none">‹</a>
<span style="font-size:15px;font-weight:600;min-width:3.5rem;text-align:center"><?= $year ?></span>
<a href="/analytics?year=<?= $next_year ?>" class="btn btn-secondary btn-icon" style="width:32px;height:32px;padding:0;line-height:32px;text-align:center;text-decoration:none">›</a>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px">
<!-- 1. Доходы и расходы по месяцам -->
<div class="card elev-sm" style="padding:18px 20px">
<div class="card-kicker" style="font-size:10px">Год к дате</div>
<div class="card-title" style="font-size:15px;margin:2px 0 12px">Доходы и расходы по месяцам</div>
<?php if ($total_income == 0 && $total_expense == 0): ?>
<div style="color:var(--muted);font-size:13px">Нет данных за <?= $year ?> год</div>
<?php else: ?>
<svg viewBox="0 0 720 160" style="width:100%;height:auto">
<polyline points="<?= $income_points ?>" fill="none" stroke="var(--positive-text)" stroke-width="2"/>
<polyline points="<?= $expense_points ?>" fill="none" stroke="var(--muted)" stroke-width="2" stroke-dasharray="4 3"/>
</svg>
<div style="display:flex;gap:16px;margin-top:8px;font-size:11px;color:var(--muted)">
<span style="display:flex;align-items:center;gap:5px"><span style="width:8px;height:8px;border-radius:50%;background:var(--positive-text);display:inline-block"></span>Доходы</span>
<span style="display:flex;align-items:center;gap:5px"><span style="width:8px;height:8px;border-radius:50%;background:var(--muted);display:inline-block"></span>Расходы</span>
</div>
<?php endif; ?>
</div>
<!-- 2. Структура расходов -->
<div class="card elev-sm" style="padding:18px 20px">
<div class="card-kicker" style="font-size:10px"><?= $month_names_gen[$month] ?></div>
<div class="card-title" style="font-size:15px;margin:2px 0 12px">Структура расходов</div>
<?php if ($expense_cat_total <= 0): ?>
<div style="color:var(--muted);font-size:13px">Нет расходов за месяц</div>
<?php else: ?>
<?php
$gradient_parts = [];
$cursor_pct = 0.0;
$legend = [];
foreach ($expense_cats as $i => $cat) {
$pct = round((float)$cat->total / $expense_cat_total * 100);
$color = $cat_colors[$i % count($cat_colors)];
$gradient_parts[] = "$color {$cursor_pct}% " . ($cursor_pct + $pct) . '%';
$cursor_pct += $pct;
$legend[] = ['title' => $cat->title, 'pct' => $pct, 'color' => $color];
}
?>
<div style="display:flex;gap:18px;align-items:center">
<div style="width:96px;height:96px;border-radius:50%;flex-shrink:0;background:conic-gradient(<?= implode(', ', $gradient_parts) ?>)"></div>
<div style="display:flex;flex-direction:column;gap:6px;flex:1;min-width:0">
<?php foreach ($legend as $item): ?>
<div style="display:flex;justify-content:space-between;font-size:12px;gap:8px">
<span style="display:flex;align-items:center;gap:6px;min-width:0">
<span style="width:8px;height:8px;border-radius:2px;background:<?= $item['color'] ?>;flex-shrink:0"></span>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><?= htmlspecialchars($item['title'], ENT_QUOTES, 'UTF-8') ?></span>
</span>
<span class="num" style="color:var(--muted);flex-shrink:0"><?= $item['pct'] ?>%</span>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
<!-- 3. План vs факт -->
<div class="card elev-sm" style="padding:18px 20px">
<div class="card-kicker" style="font-size:10px"><?= $month_names_gen[$month] ?></div>
<div class="card-title" style="font-size:15px;margin:2px 0 12px">План vs факт</div>
<?php if (empty($expense_cats)): ?>
<div style="color:var(--muted);font-size:13px">Нет расходов за месяц</div>
<?php else: ?>
<div style="display:flex;flex-direction:column;gap:10px">
<?php foreach ($expense_cats as $cat): ?>
<?php
$plan = $plan_month[(int)$cat->categorie_id] ?? 0.0;
$fact = (float)$cat->total;
$pct = $plan > 0 ? round($fact / $plan * 100) : ($fact > 0 ? 100 : 0);
$over = $plan > 0 && $fact > $plan;
?>
<div>
<div style="display:flex;justify-content:space-between;font-size:12px;margin-bottom:3px">
<span><?= htmlspecialchars($cat->title, ENT_QUOTES, 'UTF-8') ?></span>
<span class="num" style="color:<?= $over ? 'var(--red)' : 'var(--muted)' ?>"><?= number_format($fact, 0, ',', ' ') ?> ₽</span>
</div>
<div style="height:6px;border-radius:3px;background:var(--surface-3);overflow:hidden">
<div style="height:100%;width:<?= min(100, $pct) ?>%;border-radius:3px;background:<?= $over ? 'var(--red)' : 'var(--accent)' ?>"></div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<!-- 4. Тренд до конца года -->
<div class="card elev-sm" style="padding:18px 20px">
<div class="card-kicker" style="font-size:10px">Прогноз</div>
<div class="card-title" style="font-size:15px;margin:2px 0 12px">Тренд до конца года</div>
<?php if ($elapsed === 0 || $running === []): ?>
<div style="color:var(--muted);font-size:13px">Недостаточно данных для прогноза</div>
<?php else: ?>
<svg viewBox="0 0 240 60" style="width:140px;height:auto">
<polyline points="<?= implode(' ', $spark_points) ?>" fill="none" stroke="var(--accent)" stroke-width="2"/>
</svg>
<div style="font-size:12px;color:var(--ink-soft);margin-top:8px;line-height:1.5">
При текущем темпе к декабрю
<?= $forecast_total >= 0 ? 'останется' : 'не хватит' ?>
≈ <span class="num" style="color:var(--ink)"><?= number_format(abs($forecast_total), 0, ',', ' ') ?> ₽</span>
<?= $forecast_total >= 0 ? 'сверх текущих трат' : '' ?>
— грубая оценка по среднему темпу <?= $elapsed ?> прошедших <?= $remaining > 0 ? 'месяцев' : 'месяцев года' ?>, не модель.
</div>
<?php endif; ?>
</div>
<!-- 5. Сравнение периодов (год к году) -->
<div class="card elev-sm" style="padding:18px 20px">
<div class="card-kicker" style="font-size:10px">Год к году</div>
<div class="card-title" style="font-size:15px;margin:2px 0 12px">Сравнение периодов</div>
<div style="display:flex;align-items:flex-end;gap:14px;height:100px">
<?php foreach ($quarters as $q => $val): ?>
<?php
$h_prev = $quarter_max > 0 ? max(2, abs($val['prev']) / $quarter_max * 90) : 2;
$h_cur = $quarter_max > 0 ? max(2, abs($val['cur']) / $quarter_max * 90) : 2;
?>
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px">
<div style="display:flex;align-items:flex-end;gap:3px;height:90px">
<div style="width:14px;height:<?= round($h_prev) ?>px;background:var(--color-accent-700);border-radius:2px 2px 0 0"></div>
<div style="width:14px;height:<?= round($h_cur) ?>px;background:var(--color-accent-400);border-radius:2px 2px 0 0"></div>
</div>
<span style="font-size:11px;color:var(--muted)">Q<?= $q ?></span>
</div>
<?php endforeach; ?>
</div>
<div style="display:flex;gap:16px;margin-top:8px;font-size:11px;color:var(--muted)">
<span style="display:flex;align-items:center;gap:5px"><span style="width:8px;height:8px;border-radius:2px;background:var(--color-accent-700);display:inline-block"></span><?= $prev_year ?></span>
<span style="display:flex;align-items:center;gap:5px"><span style="width:8px;height:8px;border-radius:2px;background:var(--color-accent-400);display:inline-block"></span><?= $year ?></span>
</div>
</div>
</div>

View File

@ -8,10 +8,12 @@
* (все 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 array<int,object[]> $tree Активные статьи пользователя, сгруппированные по group_id (0 — без группы)
* @var object[] $groups Активные группы пользователя (App\Repositories\CategoryGroupRepository)
* @var array $plan [categorie_id => [month => ['plan_summ'=>float,'formula_pct_used'=>?float]]]
* @var int $year
* @var object[] $archived Архивные статьи пользователя — для модалки «Статьи» → архив
* @var object[] $archived_groups Архивные группы пользователя — туда же
*/
use System\Classes\CSRF;
@ -26,6 +28,13 @@ $sections = [
'expense' => 'Расходы',
];
// Плоский список активных статей (группа — отдельная сущность, сюда не входит) — для select'а
// формулы в модалке «Статьи» (только статьи расхода могут быть целью формулы, не группы).
$all = [];
foreach ($tree as $items) {
$all = array_merge($all, $items);
}
// Категории, чей план считается автоматически по формуле связанной income-статьи —
// у них своя ячейка readonly (см. CategoriesController::parseFormulaLink()).
$formula_targets = [];
@ -48,8 +57,49 @@ $planInput = static function (int $categorie_id, int $month, array $plan, bool $
};
$this->setScript('budgets.js');
$this->setScript('categories.js');
?>
<svg width="0" height="0" style="position:absolute">
<defs>
<symbol id="ic-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</symbol>
<symbol id="ic-dots" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="1.6"/>
<circle cx="12" cy="12" r="1.6"/>
<circle cx="12" cy="19" r="1.6"/>
</symbol>
<symbol id="ic-pencil" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9"/>
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>
</symbol>
<symbol id="ic-trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/>
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
</symbol>
<symbol id="ic-archive" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="4" width="20" height="5" rx="1"/>
<path d="M4 9v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9"/>
<line x1="10" y1="13" x2="14" y2="13"/>
</symbol>
<symbol id="ic-undo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 10h10a5 5 0 0 1 0 10H8"/>
<path d="M8 5 3 10l5 5"/>
</symbol>
<symbol id="ic-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>
</symbol>
<symbol id="ic-folder-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>
<line x1="12" y1="10" x2="12" y2="15"/>
<line x1="9.5" y1="12.5" x2="14.5" y2="12.5"/>
</symbol>
</defs>
</svg>
<div class="top-row">
<div>
<div class="hello">План бюджета на год</div>
@ -58,6 +108,10 @@ $this->setScript('budgets.js');
<a class="btn btn-outline-light btn-sm" href="?year=<?= $year - 1 ?>">&larr;</a>
<div class="balance num" style="font-size:2rem;"><?= $year ?></div>
<a class="btn btn-outline-light btn-sm" href="?year=<?= $year + 1 ?>">&rarr;</a>
<button type="button" class="btn btn-primary js-categories-open" style="display:inline-flex;align-items:center;gap:6px">
<svg class="ico" style="width:0.95em;height:0.95em"><use href="#ic-gear"/></svg>
Статьи
</button>
</div>
</div>
@ -68,9 +122,12 @@ $this->setScript('budgets.js');
<?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
$type_groups = array_filter($groups, static fn ($g) => $g->type === $type);
$ungrouped = array_filter($tree[0] ?? [], static fn ($c) => $c->type === $type);
?>
<?php if (empty($type_groups)): ?>
<?php if (empty($type_groups) && empty($ungrouped)): ?>
<div class="empty-state">Нет статей — сначала добавьте их на странице «Статьи»</div>
<?php else: ?>
<div class="budget-zoom">
@ -84,12 +141,10 @@ $this->setScript('budgets.js');
<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 foreach ($children 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-card-name"><?= htmlspecialchars($category->title, ENT_QUOTES, 'UTF-8') ?></div>
<div class="budget-months-inline">
<?php foreach ($months as $m => $mlabel): ?>
<div class="budget-cell">
@ -103,17 +158,34 @@ $this->setScript('budgets.js');
</div>
</div>
<?php endforeach; ?>
<?php foreach ($ungrouped as $category): ?>
<?php $is_target = isset($formula_targets[(int)$category->id]); ?>
<div class="budget-plan-group">
<div class="budget-cards">
<div class="budget-card">
<div class="budget-card-name"><?= htmlspecialchars($category->title, ENT_QUOTES, 'UTF-8') ?></div>
<div class="budget-months-inline">
<?php foreach ($months as $m => $mlabel): ?>
<div class="budget-cell">
<div class="budget-cell-label"><?= $mlabel ?></div>
<?php $planInput((int)$category->id, $m, $plan, $is_target); ?>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<!-- ---------- телефон: один аккордeон на статью верхнего уровня (группу или
самостоятельную статью), дети — подписями внутри того же аккорд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 ($children as $row_category) {
foreach ($months as $m => $_) {
$year_sum += (float)($plan[$row_category->id][$m]['plan_summ'] ?? 0);
}
@ -124,11 +196,9 @@ $this->setScript('budgets.js');
<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 foreach ($children 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-caption"><?= htmlspecialchars($category->title, ENT_QUOTES, 'UTF-8') ?></div>
<div class="budget-acc-body">
<?php foreach ($months as $m => $mlabel): ?>
<div class="budget-acc-month">
@ -140,6 +210,30 @@ $this->setScript('budgets.js');
<?php endforeach; ?>
</details>
<?php endforeach; ?>
<?php foreach ($ungrouped as $category): ?>
<?php
$is_target = isset($formula_targets[(int)$category->id]);
$year_sum = 0.0;
foreach ($months as $m => $_) {
$year_sum += (float)($plan[$category->id][$m]['plan_summ'] ?? 0);
}
?>
<details class="budget-acc-card">
<summary>
<span class="budget-acc-name"><?= htmlspecialchars($category->title, ENT_QUOTES, 'UTF-8') ?></span>
<span class="budget-acc-total num"><?= number_format($year_sum, 0, ',', ' ') ?> ₽/год</span>
</summary>
<div class="budget-acc-body">
<?php foreach ($months as $m => $mlabel): ?>
<div class="budget-acc-month">
<span><?= $mlabel ?></span>
<?php $planInput((int)$category->id, $m, $plan, $is_target); ?>
</div>
<?php endforeach; ?>
</div>
</details>
<?php endforeach; ?>
</div>
</div>
@ -148,3 +242,255 @@ $this->setScript('budgets.js');
<div id="budgets-form-error" class="alert alert-danger" style="display:none"></div>
</form>
<div class="modal fade" id="categories-modal" tabindex="-1" data-bs-theme="dark">
<div class="modal-dialog modal-dialog-scrollable" style="max-width:640px">
<div class="modal-content" style="background:var(--surface);color:var(--ink);border-radius:var(--radius-lg)">
<div class="modal-header" style="border-color:var(--surface-3)">
<h5 class="modal-title">Статьи доходов и расходов</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body" style="padding:0">
<?php foreach ($sections as $type => $label): ?>
<div class="section-label">
<span><?= $label ?></span>
<div style="display:flex;gap:0.5rem;">
<button type="button" class="btn-add js-category-archive-open" title="Архив"><svg class="ico"><use href="#ic-archive"/></svg></button>
<button type="button" class="btn-add js-group-add" data-type="<?= $type ?>" title="Добавить группу"><svg class="ico"><use href="#ic-folder-plus"/></svg></button>
<button type="button" class="btn-add js-category-add" data-type="<?= $type ?>" title="Добавить статью"><svg class="ico"><use href="#ic-plus"/></svg></button>
</div>
</div>
<?php
$type_groups = array_filter($groups, static fn ($g) => $g->type === $type);
$ungrouped = array_filter($tree[0] ?? [], static fn ($c) => $c->type === $type);
?>
<?php if (empty($type_groups) && empty($ungrouped)): ?>
<div class="empty-state">Пока нет ни одной статьи — нажмите «+», чтобы добавить первую</div>
<?php else: ?>
<div class="category-list">
<?php foreach ($type_groups as $group): ?>
<div class="category-row category-group"
data-id="<?= (int)$group->id ?>"
data-title="<?= htmlspecialchars($group->title, ENT_QUOTES, 'UTF-8') ?>"
data-type="<?= $group->type ?>">
<div class="category-row-main">
<svg class="ico"><use href="#ic-folder"/></svg>
<span class="room-title"><?= htmlspecialchars($group->title, ENT_QUOTES, 'UTF-8') ?></span>
</div>
<div class="room-menu">
<button type="button" class="room-menu-btn"><svg class="ico"><use href="#ic-dots"/></svg></button>
<div class="room-menu-list">
<a href="#" class="js-group-edit"><svg class="ico ico-sm"><use href="#ic-pencil"/></svg>Редактировать</a>
<a href="#" class="danger js-group-delete"><svg class="ico ico-sm"><use href="#ic-trash"/></svg>Архивировать</a>
</div>
</div>
</div>
<?php foreach ($tree[$group->id] ?? [] as $sub): ?>
<div class="category-row category-sub"
data-id="<?= (int)$sub->id ?>"
data-title="<?= htmlspecialchars($sub->title, ENT_QUOTES, 'UTF-8') ?>"
data-type="<?= $sub->type ?>"
data-group_id="<?= (int)$sub->group_id ?>"
data-linked="<?= (int)($sub->linked_expense_categorie_id ?? 0) ?>"
data-pct="<?= htmlspecialchars((string)($sub->formula_pct ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<div class="category-row-main">
<span class="room-title"><?= htmlspecialchars($sub->title, ENT_QUOTES, 'UTF-8') ?></span>
<?php if ($sub->linked_expense_categorie_id): ?>
<span class="room-sub">&rarr; формула <?= htmlspecialchars((string)$sub->formula_pct, ENT_QUOTES, 'UTF-8') ?>%</span>
<?php endif; ?>
</div>
<div class="room-menu">
<button type="button" class="room-menu-btn"><svg class="ico"><use href="#ic-dots"/></svg></button>
<div class="room-menu-list">
<a href="#" class="js-category-edit"><svg class="ico ico-sm"><use href="#ic-pencil"/></svg>Редактировать</a>
<label class="room-menu-move">
<svg class="ico ico-sm"><use href="#ic-folder"/></svg>
<select class="js-category-move">
<option value="">Перенести в...</option>
<?php foreach ($groups as $move_group): ?>
<?php if ($move_group->type === $sub->type && $move_group->id !== $sub->group_id): ?>
<option value="<?= (int)$move_group->id ?>"><?= htmlspecialchars($move_group->title, ENT_QUOTES, 'UTF-8') ?></option>
<?php endif; ?>
<?php endforeach; ?>
</select>
</label>
<a href="#" class="danger js-category-delete"><svg class="ico ico-sm"><use href="#ic-trash"/></svg>Архивировать</a>
</div>
</div>
</div>
<?php endforeach; ?>
<?php endforeach; ?>
<?php foreach ($ungrouped as $sub): ?>
<div class="category-row"
data-id="<?= (int)$sub->id ?>"
data-title="<?= htmlspecialchars($sub->title, ENT_QUOTES, 'UTF-8') ?>"
data-type="<?= $sub->type ?>"
data-group_id=""
data-linked="<?= (int)($sub->linked_expense_categorie_id ?? 0) ?>"
data-pct="<?= htmlspecialchars((string)($sub->formula_pct ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<div class="category-row-main">
<span class="room-title"><?= htmlspecialchars($sub->title, ENT_QUOTES, 'UTF-8') ?></span>
<?php if ($sub->linked_expense_categorie_id): ?>
<span class="room-sub">&rarr; формула <?= htmlspecialchars((string)$sub->formula_pct, ENT_QUOTES, 'UTF-8') ?>%</span>
<?php endif; ?>
</div>
<div class="room-menu">
<button type="button" class="room-menu-btn"><svg class="ico"><use href="#ic-dots"/></svg></button>
<div class="room-menu-list">
<a href="#" class="js-category-edit"><svg class="ico ico-sm"><use href="#ic-pencil"/></svg>Редактировать</a>
<?php if (!empty($groups)): ?>
<label class="room-menu-move">
<svg class="ico ico-sm"><use href="#ic-folder"/></svg>
<select class="js-category-move">
<option value="">Перенести в...</option>
<?php foreach ($groups as $move_group): ?>
<?php if ($move_group->type === $sub->type): ?>
<option value="<?= (int)$move_group->id ?>"><?= htmlspecialchars($move_group->title, ENT_QUOTES, 'UTF-8') ?></option>
<?php endif; ?>
<?php endforeach; ?>
</select>
</label>
<?php endif; ?>
<a href="#" class="danger js-category-delete"><svg class="ico ico-sm"><use href="#ic-trash"/></svg>Архивировать</a>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
<div class="modal fade" id="category-modal" tabindex="-1" data-bs-theme="dark">
<div class="modal-dialog">
<div class="modal-content" style="background:var(--surface-2);color:var(--ink);">
<form id="category-form" method="post">
<?= CSRF::field() ?>
<input type="hidden" name="id">
<input type="hidden" name="type" id="category-type">
<div class="modal-header" style="border-color:var(--surface-3)">
<h5 class="modal-title" id="category-modal-title">Новая статья</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div id="category-form-error" class="alert alert-danger" style="display:none"></div>
<div class="mb-3">
<label class="form-label">Название</label>
<input type="text" class="form-control" name="title" maxlength="128" required>
</div>
<div class="mb-3">
<label class="form-label">Группа</label>
<select class="form-select" name="group_id" id="category-group">
<option value="">— без группы —</option>
<?php foreach ($groups as $group): ?>
<option value="<?= (int)$group->id ?>" data-type="<?= $group->type ?>"><?= htmlspecialchars($group->title, ENT_QUOTES, 'UTF-8') ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="mb-3" id="category-formula-group" style="display:none">
<label class="form-label">Формула — связанная статья расхода</label>
<select class="form-select mb-2" name="linked_expense_categorie_id" id="category-linked">
<option value="">— не привязано —</option>
<?php foreach ($all as $c): ?>
<?php if ($c->type === 'expense'): ?>
<option value="<?= (int)$c->id ?>"><?= htmlspecialchars($c->title, ENT_QUOTES, 'UTF-8') ?></option>
<?php endif; ?>
<?php endforeach; ?>
</select>
<input type="number" step="0.01" min="0" max="100" class="form-control" name="formula_pct" placeholder="Процент">
</div>
</div>
<div class="modal-footer" style="border-color:var(--surface-3)">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" class="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal fade" id="group-modal" tabindex="-1" data-bs-theme="dark">
<div class="modal-dialog">
<div class="modal-content" style="background:var(--surface-2);color:var(--ink);">
<form id="group-form" method="post">
<?= CSRF::field() ?>
<input type="hidden" name="id">
<input type="hidden" name="type" id="group-type">
<div class="modal-header" style="border-color:var(--surface-3)">
<h5 class="modal-title" id="group-modal-title">Новая группа</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div id="group-form-error" class="alert alert-danger" style="display:none"></div>
<div class="mb-3">
<label class="form-label">Название</label>
<input type="text" class="form-control" name="title" maxlength="128" required>
</div>
</div>
<div class="modal-footer" style="border-color:var(--surface-3)">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" class="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal fade" id="category-archive-modal" tabindex="-1" data-bs-theme="dark">
<div class="modal-dialog">
<div class="modal-content" style="background:var(--surface-2);color:var(--ink);">
<div class="modal-header" style="border-color:var(--surface-3)">
<h5 class="modal-title">Архив статей и групп</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<?php if (empty($archived_groups)): ?>
<div class="card-meta" style="font-size:11px;margin-bottom:6px">Групп в архиве нет</div>
<?php else: ?>
<div class="card-meta" style="font-size:11px;margin-bottom:6px">Группы</div>
<?php foreach ($archived_groups as $group): ?>
<div class="archive-row" data-id="<?= (int)$group->id ?>" data-title="<?= htmlspecialchars($group->title, ENT_QUOTES, 'UTF-8') ?>">
<div>
<div class="room-title"><svg class="ico ico-sm"><use href="#ic-folder"/></svg> <?= htmlspecialchars($group->title, ENT_QUOTES, 'UTF-8') ?></div>
<div class="room-sub"><?= $group->type === 'income' ? 'Доход' : 'Расход' ?></div>
</div>
<button type="button" class="btn btn-sm btn-outline-light js-group-restore" title="Восстановить">
<svg class="ico"><use href="#ic-undo"/></svg>
Восстановить
</button>
</div>
<?php endforeach; ?>
<?php endif; ?>
<?php if (empty($archived)): ?>
<div class="card-meta" style="font-size:11px;margin:14px 0 6px">Статей в архиве нет</div>
<?php else: ?>
<div class="card-meta" style="font-size:11px;margin:14px 0 6px">Статьи</div>
<?php foreach ($archived as $category): ?>
<div class="archive-row" data-id="<?= (int)$category->id ?>" data-title="<?= htmlspecialchars($category->title, ENT_QUOTES, 'UTF-8') ?>">
<div>
<div class="room-title"><?= htmlspecialchars($category->title, ENT_QUOTES, 'UTF-8') ?></div>
<div class="room-sub"><?= $category->type === 'income' ? 'Доход' : 'Расход' ?></div>
</div>
<button type="button" class="btn btn-sm btn-outline-light js-category-restore" title="Восстановить">
<svg class="ico"><use href="#ic-undo"/></svg>
Восстановить
</button>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div>
</div>

View File

@ -1,219 +0,0 @@
<?php
/**
* @package Bicycle
* @description categories.html — страница статей (доходы/расходы), группы + подкатегории.
*
* @var array<int,object[]> $tree Активные статьи пользователя, сгруппированные по parent_id (0 — верхний уровень)
* @var object[] $archived Архивные статьи пользователя
*/
use System\Classes\CSRF;
$sections = [
'income' => 'Доходы',
'expense' => 'Расходы',
];
$groups = $tree[0] ?? [];
// Плоский список активных статей (группы + подкатегории) — для select'ов в модалке.
$all = $groups;
foreach ($tree as $parent_id => $items) {
if ($parent_id !== 0) {
$all = array_merge($all, $items);
}
}
$this->setScript('categories.js');
?>
<svg width="0" height="0" style="position:absolute">
<defs>
<symbol id="ic-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</symbol>
<symbol id="ic-dots" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="1.6"/>
<circle cx="12" cy="12" r="1.6"/>
<circle cx="12" cy="19" r="1.6"/>
</symbol>
<symbol id="ic-pencil" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9"/>
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>
</symbol>
<symbol id="ic-trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/>
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
</symbol>
<symbol id="ic-archive" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="4" width="20" height="5" rx="1"/>
<path d="M4 9v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9"/>
<line x1="10" y1="13" x2="14" y2="13"/>
</symbol>
<symbol id="ic-undo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 10h10a5 5 0 0 1 0 10H8"/>
<path d="M8 5 3 10l5 5"/>
</symbol>
<symbol id="ic-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>
</symbol>
</defs>
</svg>
<?php foreach ($sections as $type => $label): ?>
<div class="section-label">
<span><?= $label ?></span>
<div style="display:flex;gap:0.5rem;">
<button type="button" class="btn-add js-category-archive-open" title="Архив статей"><svg class="ico"><use href="#ic-archive"/></svg></button>
<button type="button" class="btn-add js-category-add" data-type="<?= $type ?>" title="Добавить статью"><svg class="ico"><use href="#ic-plus"/></svg></button>
</div>
</div>
<?php $type_groups = array_filter($groups, static fn ($g) => $g->type === $type); ?>
<?php if (empty($type_groups)): ?>
<div class="empty-state">Пока нет ни одной статьи — нажмите «+», чтобы добавить первую</div>
<?php else: ?>
<div class="category-list">
<?php foreach ($type_groups as $group): ?>
<div class="category-row category-group"
data-id="<?= (int)$group->id ?>"
data-title="<?= htmlspecialchars($group->title, ENT_QUOTES, 'UTF-8') ?>"
data-type="<?= $group->type ?>"
data-parent_id=""
data-linked="<?= (int)($group->linked_expense_categorie_id ?? 0) ?>"
data-pct="<?= htmlspecialchars((string)($group->formula_pct ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<div class="category-row-main">
<svg class="ico"><use href="#ic-folder"/></svg>
<span class="room-title"><?= htmlspecialchars($group->title, ENT_QUOTES, 'UTF-8') ?></span>
</div>
<div class="room-menu">
<button type="button" class="room-menu-btn"><svg class="ico"><use href="#ic-dots"/></svg></button>
<div class="room-menu-list">
<a href="#" class="js-category-edit"><svg class="ico ico-sm"><use href="#ic-pencil"/></svg>Редактировать</a>
<a href="#" class="danger js-category-delete"><svg class="ico ico-sm"><use href="#ic-trash"/></svg>Архивировать</a>
</div>
</div>
</div>
<?php foreach ($tree[$group->id] ?? [] as $sub): ?>
<div class="category-row category-sub"
data-id="<?= (int)$sub->id ?>"
data-title="<?= htmlspecialchars($sub->title, ENT_QUOTES, 'UTF-8') ?>"
data-type="<?= $sub->type ?>"
data-parent_id="<?= (int)$sub->parent_id ?>"
data-linked="<?= (int)($sub->linked_expense_categorie_id ?? 0) ?>"
data-pct="<?= htmlspecialchars((string)($sub->formula_pct ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<div class="category-row-main">
<span class="room-title"><?= htmlspecialchars($sub->title, ENT_QUOTES, 'UTF-8') ?></span>
<?php if ($sub->linked_expense_categorie_id): ?>
<span class="room-sub">&rarr; формула <?= htmlspecialchars((string)$sub->formula_pct, ENT_QUOTES, 'UTF-8') ?>%</span>
<?php endif; ?>
</div>
<div class="room-menu">
<button type="button" class="room-menu-btn"><svg class="ico"><use href="#ic-dots"/></svg></button>
<div class="room-menu-list">
<a href="#" class="js-category-edit"><svg class="ico ico-sm"><use href="#ic-pencil"/></svg>Редактировать</a>
<label class="room-menu-move">
<svg class="ico ico-sm"><use href="#ic-folder"/></svg>
<select class="js-category-move">
<option value="">Перенести в...</option>
<?php foreach ($groups as $move_group): ?>
<?php if ($move_group->type === $sub->type && $move_group->id !== $sub->parent_id): ?>
<option value="<?= (int)$move_group->id ?>"><?= htmlspecialchars($move_group->title, ENT_QUOTES, 'UTF-8') ?></option>
<?php endif; ?>
<?php endforeach; ?>
</select>
</label>
<a href="#" class="danger js-category-delete"><svg class="ico ico-sm"><use href="#ic-trash"/></svg>Архивировать</a>
</div>
</div>
</div>
<?php endforeach; ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php endforeach; ?>
<div class="modal fade" id="category-modal" tabindex="-1" data-bs-theme="dark">
<div class="modal-dialog">
<div class="modal-content" style="background:var(--surface-2);color:var(--ink);">
<form id="category-form" method="post">
<?= CSRF::field() ?>
<input type="hidden" name="id">
<input type="hidden" name="type" id="category-type">
<div class="modal-header" style="border-color:var(--surface-3)">
<h5 class="modal-title" id="category-modal-title">Новая статья</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div id="category-form-error" class="alert alert-danger" style="display:none"></div>
<div class="mb-3">
<label class="form-label">Название</label>
<input type="text" class="form-control" name="title" maxlength="128" required>
</div>
<div class="mb-3">
<label class="form-label">Группа</label>
<select class="form-select" name="parent_id" id="category-parent">
<option value="">— верхний уровень (группа) —</option>
<?php foreach ($groups as $group): ?>
<option value="<?= (int)$group->id ?>" data-type="<?= $group->type ?>"><?= htmlspecialchars($group->title, ENT_QUOTES, 'UTF-8') ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="mb-3" id="category-formula-group" style="display:none">
<label class="form-label">Формула — связанная статья расхода</label>
<select class="form-select mb-2" name="linked_expense_categorie_id" id="category-linked">
<option value="">— не привязано —</option>
<?php foreach ($all as $c): ?>
<?php if ($c->type === 'expense'): ?>
<option value="<?= (int)$c->id ?>"><?= htmlspecialchars($c->title, ENT_QUOTES, 'UTF-8') ?></option>
<?php endif; ?>
<?php endforeach; ?>
</select>
<input type="number" step="0.01" min="0" max="100" class="form-control" name="formula_pct" placeholder="Процент">
</div>
</div>
<div class="modal-footer" style="border-color:var(--surface-3)">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" class="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal fade" id="category-archive-modal" tabindex="-1" data-bs-theme="dark">
<div class="modal-dialog">
<div class="modal-content" style="background:var(--surface-2);color:var(--ink);">
<div class="modal-header" style="border-color:var(--surface-3)">
<h5 class="modal-title">Архив статей</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<?php if (empty($archived)): ?>
<div class="empty-state">Архив пуст</div>
<?php else: ?>
<?php foreach ($archived as $category): ?>
<div class="archive-row" data-id="<?= (int)$category->id ?>" data-title="<?= htmlspecialchars($category->title, ENT_QUOTES, 'UTF-8') ?>">
<div>
<div class="room-title"><?= htmlspecialchars($category->title, ENT_QUOTES, 'UTF-8') ?></div>
<div class="room-sub"><?= $category->type === 'income' ? 'Доход' : 'Расход' ?></div>
</div>
<button type="button" class="btn btn-sm btn-outline-light js-category-restore" title="Восстановить">
<svg class="ico"><use href="#ic-undo"/></svg>
Восстановить
</button>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div>
</div>

View File

@ -12,16 +12,40 @@
* @var float $total_all Сумма всех (не архивных) счетов в рублях, без фильтра include_in_total
* @var array $fact_month [categorie_id => сумма] — факт за текущий месяц (реальные transactions)
* @var int|null $pct_expense % бюджета по расходам за месяц (факт/план), null — если план не задан
* @var float $month_delta Дельта за месяц: доход − расход (может быть 0)
* @var object[] $goals Цели пользователя + данные счёта-копилки (account_title/account_summa/account_currency)
* @var array $rates [currency => float|null] курс ЦБ РФ по не-RUB валютам, встреченным среди счетов
*/
use App\Classes\Currency;
use System\Classes\CSRF;
// Цель savings-счёта по account_id — для предзаполнения полей цели в модалке редактирования
// (см. App/media/js/accounts.js) и для рабочего "Редактировать" в ⋮-меню карточки цели.
$goalsByAccount = [];
foreach ($goals as $g) {
$goalsByAccount[(int)$g->account_id] = $g;
}
// Тип счёта — граница между «Счета» и «Копилки и цели», а не наличие цели: savings — это
// копилка по определению (см. бюджет_текущий_план.md), с целью или без, поэтому в «Счета»
// (обычные cash/bank счета) не попадает вообще ни один savings.
$gridAccounts = array_filter($accounts, static fn ($a) => $a->type_acc !== 'savings');
$savingsAccounts = array_filter($accounts, static fn ($a) => $a->type_acc === 'savings');
// Полные данные счёта по id — карточка копилки (цикл по $savingsAccounts ниже) несёт те же
// data-* атрибуты, что и room-card, чтобы «Редактировать» работало без поиска несуществующего
// (скрытого из сетки) room-card в DOM.
$accountsById = [];
foreach ($accounts as $a) {
$accountsById[(int)$a->id] = $a;
}
// 'savings' тут нет намеренно — savings-счета больше не попадают в эту сетку (см. $gridAccounts
// ниже, они целиком переехали в «Копилки и цели» с другой иконкой, bi-piggy-bank).
$type_icons = [
'cash' => 'ic-wallet',
'bank' => 'ic-card',
'savings' => 'ic-piggy',
'cash' => 'ic-wallet',
'bank' => 'ic-card',
];
$type_accents = [
@ -64,11 +88,13 @@ $this->setScript('accounts.js');
<rect x="2" y="5" width="20" height="14" rx="2"/>
<line x1="2" y1="10" x2="22" y2="10"/>
</symbol>
<symbol id="ic-piggy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="9" width="18" height="9" rx="4.5"/>
<line x1="9" y1="13.5" x2="14" y2="13.5"/>
<circle cx="17" cy="6" r="2"/>
<line x1="17" y1="8" x2="17" y2="9"/>
<symbol id="ic-move" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="5 9 2 12 5 15"/>
<polyline points="9 5 12 2 15 5"/>
<polyline points="15 19 12 22 9 19"/>
<polyline points="19 9 22 12 19 15"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<line x1="12" y1="2" x2="12" y2="22"/>
</symbol>
<symbol id="ic-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/>
@ -114,49 +140,84 @@ $this->setScript('accounts.js');
<path d="M3 10h10a5 5 0 0 1 0 10H8"/>
<path d="M8 5 3 10l5 5"/>
</symbol>
<symbol id="ic-bag" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="7" width="20" height="14" rx="2"/>
<path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
<line x1="2" y1="13" x2="22" y2="13"/>
</symbol>
<symbol id="ic-bolt" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 2 3 14h9l-1 8 10-12h-9l1-8Z"/>
</symbol>
<symbol id="ic-bank" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 10 12 4l9 6"/>
<path d="M5 10v9"/>
<path d="M10 10v9"/>
<path d="M14 10v9"/>
<path d="M19 10v9"/>
<path d="M3 21h18"/>
</symbol>
<symbol id="ic-car" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 17V12l2-5h10l2 5v5"/>
<line x1="5" y1="12" x2="19" y2="12"/>
<circle cx="7" cy="17" r="2"/>
<circle cx="17" cy="17" r="2"/>
</symbol>
<symbol id="ic-coin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="9"/>
<line x1="12" y1="6" x2="12" y2="18"/>
<path d="M9 15c0 1.1 1.3 2 3 2s3-.9 3-2-1.3-1.5-3-2-3-.9-3-2 1.3-2 3-2 3 .9 3 2"/>
</symbol>
</defs>
</svg>
<div class="dash-kicker"><?= $weekdays[(int)date('N')] ?>, <?= (int)date('j') ?> <?= $month_names_gen[(int)date('n')] ?></div>
<div class="dash-tags">
<span class="tag tag-outline">📅 <?= $ucfirstMb($month_names[(int)date('n')]) ?> <?= date('Y') ?></span>
<?php if ($pct_expense !== null): ?>
<?php $over = $pct_expense > 100; ?>
<span class="tag" style="background:color-mix(in srgb, <?= $over ? 'var(--red)' : 'var(--accent)' ?> 20%, transparent);color:<?= $over ? 'var(--red)' : 'var(--accent-tint)' ?>">Бюджет <?= $pct_expense ?>% потрачено</span>
<?php endif; ?>
<div class="dash-header">
<div class="dash-kicker"><?= $weekdays[(int)date('N')] ?>, <?= (int)date('j') ?> <?= $month_names_gen[(int)date('n')] ?></div>
<div class="dash-tags">
<?php $over = ($pct_expense ?? 0) > 100; ?>
<span class="tag" style="background:color-mix(in srgb, <?= $over ? 'var(--red)' : 'oklch(0.80 0.18 90)' ?> 20%, transparent);color:<?= $over ? 'var(--red)' : 'oklch(0.85 0.14 90)' ?>">Бюджет <?= $pct_expense ?? 0 ?>% потрачено</span>
<?php $delta_pos = $month_delta >= 0; ?>
<span class="tag" style="background:color-mix(in srgb, <?= $delta_pos ? 'var(--positive)' : 'var(--red)' ?> 20%, transparent);color:<?= $delta_pos ? 'var(--positive-text)' : 'var(--red)' ?>"><?= $delta_pos ? '↗' : '↘' ?> <?= ($delta_pos && $month_delta > 0 ? '+' : '') . number_format($month_delta, 0, ',', ' ') ?> ₽ за месяц</span>
</div>
</div>
<div class="top-row">
<div>
<div class="hello">Здравствуйте, <?= htmlspecialchars($user['full_name'] ?? $user['login'], ENT_QUOTES, 'UTF-8') ?></div>
<div class="balance-label">Доступно сейчас</div>
<div class="balance num"><?= number_format($total, 0, ',', ' ') ?> <span>₽</span></div>
<div class="balance-total num"><span class="label">Всего по всем счетам</span><?= number_format($total_all, 0, ',', ' ') ?> ₽</div>
</div>
<div class="balance-block">
<div class="balance-label">Доступно сейчас</div>
<div class="balance num"><?= number_format($total, 0, ',', ' ') ?> ₽</div>
<div class="balance-total num">Всего по всем счетам <span><?= number_format($total_all, 0, ',', ' ') ?> ₽</span></div>
</div>
<div class="section-label">
<span>Счета</span>
<div style="display:flex;gap:0.5rem;">
<div style="display:flex;gap:0.5rem">
<button type="button" class="btn-add js-account-reorder-toggle" title="Изменить порядок счетов"><svg class="ico"><use href="#ic-move"/></svg></button>
<button type="button" class="btn-add js-account-archive-open" title="Архив счетов"><svg class="ico"><use href="#ic-archive"/></svg></button>
<button type="button" class="btn-add js-account-add" title="Добавить счёт"><svg class="ico"><use href="#ic-plus"/></svg></button>
</div>
</div>
<?php if (empty($accounts)): ?>
<?php if (empty($gridAccounts)): ?>
<div class="empty-state">Пока нет ни одного счёта — нажмите «+», чтобы добавить первый</div>
<?php else: ?>
<div class="grid-cards">
<?php foreach ($accounts as $account): ?>
<?php foreach ($gridAccounts as $account): ?>
<?php
$accent = $account->color ?: ($type_accents[$account->type_acc] ?? 'var(--blue)');
$goal = $goalsByAccount[(int)$account->id] ?? null;
$fx_rate = $account->currency !== 'RUB' ? ($rates[$account->currency] ?? null) : null;
?>
<div class="room-card"
style="--card-accent:<?= $type_accents[$account->type_acc] ?? 'var(--blue)' ?>"
style="--card-accent:<?= htmlspecialchars($accent, ENT_QUOTES, 'UTF-8') ?>"
data-id="<?= (int)$account->id ?>"
data-title="<?= htmlspecialchars($account->title, ENT_QUOTES, 'UTF-8') ?>"
data-type="<?= htmlspecialchars($account->type_acc, ENT_QUOTES, 'UTF-8') ?>"
data-summa="<?= htmlspecialchars((string)$account->summa, ENT_QUOTES, 'UTF-8') ?>"
data-currency="<?= htmlspecialchars($account->currency, ENT_QUOTES, 'UTF-8') ?>"
data-description="<?= htmlspecialchars((string)($account->description ?? ''), ENT_QUOTES, 'UTF-8') ?>"
data-include="<?= $account->include_in_total ? '1' : '' ?>">
data-color="<?= htmlspecialchars((string)($account->color ?? ''), ENT_QUOTES, 'UTF-8') ?>"
data-privacy="<?= htmlspecialchars($account->privacy ?? 'shared', ENT_QUOTES, 'UTF-8') ?>"
data-include="<?= $account->include_in_total ? '1' : '' ?>"
data-goal-target="<?= $goal ? htmlspecialchars((string)$goal->target_summa, ENT_QUOTES, 'UTF-8') : '' ?>"
data-goal-deadline="<?= $goal ? htmlspecialchars((string)$goal->deadline, ENT_QUOTES, 'UTF-8') : '' ?>">
<div class="room-card-head">
<span class="room-icon"><svg class="ico"><use href="#<?= $type_icons[$account->type_acc] ?? 'ic-wallet' ?>"/></svg></span>
<div class="room-menu">
@ -169,42 +230,86 @@ $this->setScript('accounts.js');
</div>
</div>
</div>
<div class="room-title"><?= htmlspecialchars($account->title, ENT_QUOTES, 'UTF-8') ?></div>
<div class="room-sub"><?= $type_labels[$account->type_acc] ?? '' ?></div>
<div>
<div class="room-title"><?= htmlspecialchars($account->title, ENT_QUOTES, 'UTF-8') ?></div>
<div class="room-sub"><?= $type_labels[$account->type_acc] ?? '' ?></div>
</div>
<div class="room-value num">
<?= number_format((float)$account->summa, 2, ',', ' ') ?>
<?= htmlspecialchars($account->currency, ENT_QUOTES, 'UTF-8') ?>
</div>
<?php if ($fx_rate !== null): ?>
<div class="card-meta" style="font-size:11px">1 <?= htmlspecialchars($account->currency, ENT_QUOTES, 'UTF-8') ?> = <?= number_format($fx_rate, 2, ',', ' ') ?> ₽</div>
<div class="card-meta" style="font-size:11px">≈<?= number_format((float)$account->summa * $fx_rate, 0, ',', ' ') ?> ₽</div>
<?php endif; ?>
<div class="acc-dot"><span></span>Обновлён сегодня</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (!empty($goals)): ?>
<?php if (!empty($savingsAccounts)): ?>
<div class="section-label"><span>Копилки и цели</span></div>
<div class="grid-cards grid-cards-goals">
<?php foreach ($goals as $goal): ?>
<div class="grid-cards-goals" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:14px;margin-bottom:2rem">
<?php foreach ($savingsAccounts as $account): ?>
<?php
$saved = (float)$goal->account_summa;
$target = (float)$goal->target_summa;
$pct = $target > 0 ? min(100, $saved / $target * 100) : 0;
$goal = $goalsByAccount[(int)$account->id] ?? null;
$accent = $account->color ?: 'var(--purple)';
$r = 22;
$circumference = 2 * M_PI * $r;
$offset = $circumference * (1 - $pct / 100);
if ($goal) {
$saved = (float)$account->summa;
$target = (float)$goal->target_summa;
$pct = $target > 0 ? min(100, $saved / $target * 100) : 0;
$offset = $circumference * (1 - $pct / 100);
}
?>
<div class="room-card goal-card" style="--card-accent:var(--purple)">
<svg width="52" height="52" viewBox="0 0 52 52" class="goal-ring">
<circle cx="26" cy="26" r="<?= $r ?>" fill="none" stroke="var(--surface-3)" stroke-width="5"/>
<circle cx="26" cy="26" r="<?= $r ?>" fill="none" stroke="var(--purple)" stroke-width="5" stroke-linecap="round"
stroke-dasharray="<?= round($circumference, 2) ?>" stroke-dashoffset="<?= round($offset, 2) ?>"
transform="rotate(-90 26 26)"/>
<text x="26" y="30" text-anchor="middle" font-size="12" fill="var(--ink)"><?= round($pct) ?>%</text>
</svg>
<div>
<div class="room-title"><?= htmlspecialchars($goal->account_title, ENT_QUOTES, 'UTF-8') ?></div>
<div class="room-sub num">
<?= number_format($saved, 0, ',', ' ') ?> из <?= number_format($target, 0, ',', ' ') ?>
<?= htmlspecialchars($goal->account_currency, ENT_QUOTES, 'UTF-8') ?>
<div class="room-card goal-card" style="padding:16px 18px;flex-direction:row;align-items:center;gap:14px;--card-accent:<?= htmlspecialchars($accent, ENT_QUOTES, 'UTF-8') ?>;position:relative"
data-id="<?= (int)$account->id ?>"
<?php if ($goal): ?>data-goal-id="<?= (int)$goal->id ?>"<?php endif; ?>
data-title="<?= htmlspecialchars($account->title, ENT_QUOTES, 'UTF-8') ?>"
data-type="<?= htmlspecialchars($account->type_acc, ENT_QUOTES, 'UTF-8') ?>"
data-summa="<?= htmlspecialchars((string)$account->summa, ENT_QUOTES, 'UTF-8') ?>"
data-currency="<?= htmlspecialchars($account->currency, ENT_QUOTES, 'UTF-8') ?>"
data-description="<?= htmlspecialchars((string)($account->description ?? ''), ENT_QUOTES, 'UTF-8') ?>"
data-color="<?= htmlspecialchars((string)($account->color ?? ''), ENT_QUOTES, 'UTF-8') ?>"
data-privacy="<?= htmlspecialchars($account->privacy ?? 'shared', ENT_QUOTES, 'UTF-8') ?>"
data-include="<?= $account->include_in_total ? '1' : '' ?>"
data-goal-target="<?= $goal ? htmlspecialchars((string)$goal->target_summa, ENT_QUOTES, 'UTF-8') : '' ?>"
data-goal-deadline="<?= $goal ? htmlspecialchars((string)$goal->deadline, ENT_QUOTES, 'UTF-8') : '' ?>">
<?php if ($goal): ?>
<svg width="52" height="52" viewBox="0 0 52 52" style="flex-shrink:0">
<circle cx="26" cy="26" r="<?= $r ?>" fill="none" stroke="var(--surface-3)" stroke-width="5"/>
<circle cx="26" cy="26" r="<?= $r ?>" fill="none" stroke="<?= htmlspecialchars($accent, ENT_QUOTES, 'UTF-8') ?>" stroke-width="5" stroke-linecap="round"
stroke-dasharray="<?= round($circumference, 2) ?>" stroke-dashoffset="<?= round($offset, 2) ?>"
transform="rotate(-90 26 26)"/>
<text x="26" y="30" text-anchor="middle" font-size="13" fill="var(--ink)"><?= round($pct) ?>%</text>
</svg>
<?php else: ?>
<span class="room-icon" style="width:52px;height:52px;flex-shrink:0"><i class="bi bi-piggy-bank" style="font-size:1.4em"></i></span>
<?php endif; ?>
<div style="flex:1">
<div class="card-title" style="font-size:15px"><?= htmlspecialchars($account->title, ENT_QUOTES, 'UTF-8') ?></div>
<div class="card-meta" style="font-size:12px">
<?php if ($goal): ?>
<span class="num" style="color:var(--ink)"><?= number_format($saved, 0, ',', ' ') ?></span>
из
<span class="num"><?= number_format($target, 0, ',', ' ') ?> <?= htmlspecialchars($account->currency, ENT_QUOTES, 'UTF-8') ?></span>
<?php else: ?>
<span class="num" style="color:var(--ink)"><?= number_format((float)$account->summa, 0, ',', ' ') ?> <?= htmlspecialchars($account->currency, ENT_QUOTES, 'UTF-8') ?></span>
<?php endif; ?>
</div>
</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-account-edit"><svg class="ico ico-sm"><use href="#ic-pencil"/></svg>Редактировать</a>
<?php if ($goal): ?>
<a href="#" class="danger js-goal-delete"><svg class="ico ico-sm"><use href="#ic-trash"/></svg>Удалить цель</a>
<?php else: ?>
<a href="#" class="danger js-account-delete"><svg class="ico ico-sm"><use href="#ic-trash"/></svg>Удалить</a>
<?php endif; ?>
</div>
</div>
</div>
@ -213,23 +318,23 @@ $this->setScript('accounts.js');
<?php endif; ?>
<?php
$groups = $tree[0] ?? [];
// Строки плана+факта для секции типа $type (income|expense): группа с подкатегориями —
// заголовком группы + строками по ним; группа без подкатегорий — одной строкой на саму группу
// (тот же приём, что и в App/view/Budgets/budgets.html). Факт — реальные transactions за месяц
// ($fact_month, 0 пока операций нет — это корректное значение, не заглушка). Замыкание, не
// именованная функция — шаблон может быть include'ится не один раз за процесс (например
// в тестах), именованная функция дала бы "Cannot redeclare".
// Строки плана+факта для секции типа $type (income|expense): у каждой настоящей группы
// (App\Repositories\CategoryGroupRepository — самостоятельная сущность, не статья с детьми) —
// заголовок-плашка + строки по её статьям; статьи без группы ($tree[0]) — сразу строками, без
// плашки. Факт — реальные transactions за месяц ($fact_month, 0 пока операций нет — это
// корректное значение, не заглушка). Замыкание, не именованная функция — шаблон может быть
// include'ится не один раз за процесс (например в тестах), именованная функция дала бы
// "Cannot redeclare".
$renderBudgetList = static function (array $groups, array $tree, array $plan_month, array $fact_month, string $type): void {
$type_groups = array_filter($groups, static fn ($g) => $g->type === $type);
$type_groups = array_filter($groups, static fn ($g) => $g->type === $type);
$ungrouped = array_filter($tree[0] ?? [], static fn ($c) => $c->type === $type);
if ($type_groups === []) {
if ($type_groups === [] && $ungrouped === []) {
echo '<div class="empty-state">Нет статей — добавьте их на странице «Статьи»</div>';
return;
}
// Строка одной статьи (или самостоятельной группы без детей) — план+факт+прогресс-бар.
// Строка план+факт+прогресс-бар — и для статьи внутри группы, и для статьи без группы.
// Перерасход (факт > плана) для расходов подсвечивается красным — для доходов это, наоборот,
// хорошо (получено больше плана), там всегда акцентный цвет.
$renderRow = static function (object $category, float $plan, float $fact, string $type): void {
@ -239,41 +344,36 @@ $renderBudgetList = static function (array $groups, array $tree, array $plan_mon
$pct_color = $over ? 'var(--red)' : 'var(--accent-tint)';
$bar_color = $over ? 'var(--red)' : 'var(--accent)';
echo '<div class="budget-row">'
. '<div class="budget-row-main">'
. '<div class="budget-name">' . htmlspecialchars($category->title, ENT_QUOTES, 'UTF-8') . '</div>'
echo '<div class="stat-row">'
. '<span class="stat-name">' . htmlspecialchars($category->title, ENT_QUOTES, 'UTF-8') . '</span>'
. '<div>'
. '<div class="budget-bar"><div class="budget-bar-fill" style="width:' . $bar_width . '%;background:' . $bar_color . '"></div></div>'
. '<div class="budget-row-caption num">' . number_format($fact, 0, ',', ' ') . ' из ' . number_format($plan, 0, ',', ' ') . ' ₽</div>'
. '<div class="stat-caption num">' . number_format($fact, 0, ',', ' ') . ' из ' . number_format($plan, 0, ',', ' ') . ' ₽</div>'
. '</div>'
. '<div class="budget-row-side"><span class="num" style="color:' . $pct_color . '">' . $pct . '%</span></div>'
. '<div style="text-align:right"><span class="num" style="color:' . $pct_color . '">' . $pct . '%</span></div>'
. '</div>';
};
echo '<div class="budget-list">';
echo '<div class="card elev-md pm-wrap">';
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);
$children = $tree[$group->id] ?? [];
$head_plan = array_sum(array_map(static fn ($c) => $plan_month[$c->id] ?? 0.0, $children));
echo '<div class="budget-group-head" style="--card-accent:var(--blue)">'
. '<span class="budget-group-name">' . htmlspecialchars($group->title, ENT_QUOTES, 'UTF-8') . '</span>'
. '<span class="budget-group-sum num">' . number_format($head_plan, 0, ',', ' ') . ' ₽</span>'
. '</div>';
// Самостоятельная статья (без подкатегорий) — плашка выше уже единственная строка на неё
// (название+план), отдельного .budget-row для неё не рисуем (см. комментарий выше).
foreach ($children as $category) {
$renderRow($category, $plan_month[$category->id] ?? 0.0, $fact_month[$category->id] ?? 0.0, $type);
}
}
foreach ($ungrouped as $category) {
$renderRow($category, $plan_month[$category->id] ?? 0.0, $fact_month[$category->id] ?? 0.0, $type);
}
echo '</div>';
};
?>
@ -285,63 +385,127 @@ $renderBudgetList = static function (array $groups, array $tree, array $plan_mon
<?php $renderBudgetList($groups, $tree, $plan_month, $fact_month, 'expense'); ?>
<div class="modal fade" id="account-modal" tabindex="-1" data-bs-theme="dark">
<div class="modal-dialog">
<div class="modal-content" style="background:var(--surface-2);color:var(--ink);">
<div class="modal-dialog modal-dialog-scrollable" style="max-width:520px">
<div class="modal-content" style="background:var(--surface);color:var(--ink);border-radius:var(--radius-lg)">
<div class="modal-header" style="border-color:var(--surface-3);align-items:flex-start">
<div style="display:flex;gap:12px;align-items:flex-start">
<span class="dialog-icon"><svg class="ico" style="width:1.3em;height:1.3em"><use href="#ic-wallet"/></svg></span>
<div>
<h5 class="modal-title" id="account-modal-title">Новый счёт</h5>
<div class="dialog-sub">Расчётный, накопительный, наличные или кредит</div>
</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<form id="account-form" method="post">
<?= CSRF::field() ?>
<input type="hidden" name="id">
<div class="modal-header" style="border-color:var(--surface-3)">
<h5 class="modal-title" id="account-modal-title">Новый счёт</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div id="account-form-error" class="alert alert-danger" style="display:none"></div>
<div id="account-form-error" class="alert-error" 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 class="dialog-section-label" style="margin-top:0">Тип счёта</div>
<div class="pm-tiles-grid" id="acc-type-tiles" style="grid-template-columns:1fr 1fr">
<div class="pm-tile selected" data-type="bank" data-color="oklch(0.80 0.18 90)" data-icon="ic-card">
<span class="pm-check">✓</span>
<span class="pm-tile-icon" style="background:color-mix(in srgb,oklch(0.80 0.18 90) 20%,transparent);color:oklch(0.80 0.18 90)"><svg class="ico" style="width:1.1em;height:1.1em"><use href="#ic-card"/></svg></span>
<div><div class="pm-tile-title">Расчётный</div><div class="pm-tile-desc">Повседневные траты и доходы</div></div>
</div>
<div class="pm-tile" data-type="cash" data-color="var(--positive)" data-icon="ic-wallet">
<span class="pm-check">✓</span>
<span class="pm-tile-icon" style="background:color-mix(in srgb,var(--positive) 20%,transparent);color:var(--positive)"><svg class="ico" style="width:1.1em;height:1.1em"><use href="#ic-wallet"/></svg></span>
<div><div class="pm-tile-title">Наличные</div><div class="pm-tile-desc">Деньги на руках</div></div>
</div>
<div class="pm-tile" data-type="savings" data-color="oklch(0.78 0.17 230)" data-icon="ic-bank">
<span class="pm-check">✓</span>
<span class="pm-tile-icon" style="background:color-mix(in srgb,oklch(0.78 0.17 230) 20%,transparent);color:oklch(0.78 0.17 230)"><svg class="ico" style="width:1.1em;height:1.1em"><use href="#ic-bank"/></svg></span>
<div><div class="pm-tile-title">Накопительный</div><div class="pm-tile-desc">Копилка с целью</div></div>
</div>
<div class="pm-tile" data-type="credit" data-color="oklch(0.72 0.19 25)" data-icon="ic-card">
<span class="pm-check">✓</span>
<span class="pm-tile-icon" style="background:color-mix(in srgb,oklch(0.72 0.19 25) 20%,transparent);color:oklch(0.72 0.19 25)"><svg class="ico" style="width:1.1em;height:1.1em"><use href="#ic-card"/></svg></span>
<div><div class="pm-tile-title">Кредит / Карта</div><div class="pm-tile-desc">Кредит, ипотека, кредитка</div></div>
</div>
</div>
<input type="hidden" name="type_acc" id="acc-type-hidden" value="bank">
<div class="dialog-section-label">Параметры</div>
<div style="display:flex;gap:10px;align-items:center;position:relative">
<div style="position:relative">
<button type="button" id="acc-color-swatch" style="background:oklch(0.80 0.18 90);width:44px;height:44px;border-radius:50%;border:2px solid var(--divider);cursor:pointer;flex-shrink:0"></button>
<div id="acc-color-picker" class="card elev-lg" style="display:none;position:absolute;top:52px;left:0;z-index:20;padding:12px;gap:8px;width:170px">
<div style="font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:color-mix(in srgb, var(--ink) 55%, transparent)">Цвет</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px" id="acc-color-swatches">
<?php foreach ([
'oklch(0.72 0.19 25)', 'var(--positive)', 'oklch(0.78 0.17 230)', 'oklch(0.80 0.18 90)',
'oklch(0.72 0.19 330)', 'oklch(0.75 0.16 200)', 'oklch(0.74 0.18 55)', 'oklch(0.76 0.17 290)',
] as $swatch): ?>
<button type="button" class="acc-color-swatch-opt" data-color="<?= htmlspecialchars($swatch, ENT_QUOTES, 'UTF-8') ?>"
style="width:28px;height:28px;border-radius:50%;background:<?= htmlspecialchars($swatch, ENT_QUOTES, 'UTF-8') ?>;border:2px solid transparent;cursor:pointer;padding:0"></button>
<?php endforeach; ?>
</div>
</div>
</div>
<div class="field" style="flex:1;margin:0">
<label>Название</label>
<input class="input" type="text" name="title" maxlength="128" placeholder="Напр. Основная карта" required>
</div>
<span id="acc-icon-preview" style="width:36px;height:36px;border-radius:8px;background:color-mix(in srgb,oklch(0.80 0.18 90) 20%,transparent);color:oklch(0.80 0.18 90);display:inline-flex;align-items:center;justify-content:center;flex-shrink:0">
<svg class="ico" style="width:1.05em;height:1.05em"><use href="#ic-card" id="acc-icon-preview-use"/></svg>
</span>
<input type="hidden" name="color" id="acc-color-hidden" value="">
</div>
<div class="mb-3">
<label class="form-label">Тип</label>
<select class="form-select" name="type_acc">
<option value="cash">Кошелёк (наличные)</option>
<option value="bank">Банковский счёт</option>
<option value="savings">Копилка</option>
<div style="display:flex;gap:10px;margin-top:10px">
<div class="field" style="flex:1;margin:0">
<label>Сумма</label>
<input class="input" type="number" step="0.01" min="0" name="summa" placeholder="0" required>
</div>
<div style="width:110px">
<div class="field" style="margin:0">
<label>Валюта</label>
<select class="input" name="currency">
<?php foreach (Currency::LIST as $code => $name): ?>
<option value="<?= $code ?>"<?= $code === 'RUB' ? ' selected' : '' ?>><?= $code ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
</div>
<div class="row">
<div class="col-7 mb-3">
<label class="form-label">Сумма</label>
<input type="number" step="0.01" min="0" class="form-control" name="summa" required>
</div>
<div class="col-5 mb-3">
<label class="form-label">Валюта</label>
<select class="form-select" name="currency">
<?php foreach (Currency::LIST as $code => $name): ?>
<option value="<?= $code ?>"<?= $code === 'RUB' ? ' selected' : '' ?> title="<?= htmlspecialchars($name, ENT_QUOTES, 'UTF-8') ?>"><?= $code ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div id="account-rate-field" style="display:none;margin-top:10px">
<div class="field" style="margin:0">
<label>Ставка, % годовых</label>
<input class="input" type="number" step="0.01" min="0" name="rate_percent" placeholder="0">
</div>
</div>
<div class="mb-3" id="account-rate-group">
<div id="account-rate-field" style="display:none">
<label class="form-label">Ставка, % годовых</label>
<input type="number" step="0.01" min="0" class="form-control" name="rate_percent">
</div>
<div id="account-goal-fields" style="display:none;margin-top:10px">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div class="field" style="margin:0">
<label>Цель накопления</label>
<input class="input" type="number" step="0.01" min="0" name="goal_target_summa" placeholder="100 000">
</div>
<div class="field" style="margin:0">
<label>Дата цели</label>
<input class="input" type="date" name="goal_deadline">
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Описание</label>
<input type="text" class="form-control" name="description" maxlength="255">
</div>
<div class="field" style="margin-top:10px">
<label>Описание (необязательно)</label>
<input class="input" type="text" name="description" maxlength="255">
</div>
<div style="margin-top:16px">
<div class="dialog-section-label" style="margin:0 0 8px">Общий баланс</div>
<div class="seg">
<label class="seg-opt"><input type="radio" name="include_in_total" value="1" checked>Учитывать</label>
<label class="seg-opt"><input type="radio" name="include_in_total" value="0">Не учитывать</label>
</div>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" name="include_in_total" value="1" id="account-include" checked>
<label class="form-check-label" for="account-include">Учитывать в «Доступно сейчас»</label>
</div>
</div>
<div class="modal-footer" style="border-color:var(--surface-3)">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
@ -352,6 +516,131 @@ $renderBudgetList = static function (array $groups, array $tree, array $plan_mon
</div>
</div>
<div class="modal fade" id="credit-modal" tabindex="-1" data-bs-theme="dark">
<div class="modal-dialog modal-dialog-scrollable" style="max-width:560px">
<div class="modal-content" style="background:var(--surface);color:var(--ink);border-radius:var(--radius-lg)">
<div class="modal-header" style="border-color:var(--surface-3);align-items:flex-start">
<div style="display:flex;gap:12px;align-items:flex-start">
<span class="dialog-icon"><svg class="ico" style="width:1.3em;height:1.3em"><use href="#ic-bolt"/></svg></span>
<div>
<h5 class="modal-title">Новый кредит</h5>
<div class="dialog-sub">Раздел «Кредиты» · банковский кредит или карта</div>
</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div class="alert-error" style="background:color-mix(in srgb, var(--accent) 12%, transparent);border-color:color-mix(in srgb, var(--accent) 30%, transparent);color:var(--accent-tint);margin-bottom:14px">
Раздел «Кредиты» пока в разработке — форма показывает, как это будет выглядеть, но
ничего не сохраняет.
</div>
<div class="dialog-section-label" style="margin-top:0">Что добавляем</div>
<div class="pm-tiles-grid" id="credit-mode-tiles" style="grid-template-columns:1fr 1fr">
<div class="pm-tile selected" data-mode="new">
<span class="pm-check">✓</span>
<div><div class="pm-tile-title">Новый кредит</div><div class="pm-tile-desc">Только что оформил</div></div>
</div>
<div class="pm-tile" data-mode="existing">
<span class="pm-check">✓</span>
<div><div class="pm-tile-title">Уже выплачиваю</div><div class="pm-tile-desc">Завести по остатку</div></div>
</div>
</div>
<div class="dialog-section-label">Тип кредита</div>
<div class="pm-tiles-grid" id="credit-type-tiles" style="grid-template-columns:1fr 1fr">
<div class="pm-tile selected" data-credit-type="consumer">
<span class="pm-check">✓</span>
<span class="pm-tile-icon" style="background:color-mix(in srgb,oklch(0.80 0.18 90) 20%,transparent);color:oklch(0.80 0.18 90)"><svg class="ico" style="width:1.1em;height:1.1em"><use href="#ic-card"/></svg></span>
<div><div class="pm-tile-title">Потребительский</div><div class="pm-tile-desc">Обычный кредит с графиком платежей</div></div>
</div>
<div class="pm-tile" data-credit-type="mortgage">
<span class="pm-check">✓</span>
<span class="pm-tile-icon" style="background:color-mix(in srgb,oklch(0.72 0.19 25) 20%,transparent);color:oklch(0.72 0.19 25)"><svg class="ico" style="width:1.1em;height:1.1em"><use href="#ic-home"/></svg></span>
<div><div class="pm-tile-title">Ипотека</div><div class="pm-tile-desc">Долгий кредит под залог жилья</div></div>
</div>
<div class="pm-tile" data-credit-type="auto">
<span class="pm-check">✓</span>
<span class="pm-tile-icon" style="background:color-mix(in srgb,oklch(0.72 0.19 25) 20%,transparent);color:oklch(0.72 0.19 25)"><svg class="ico" style="width:1.1em;height:1.1em"><use href="#ic-car"/></svg></span>
<div><div class="pm-tile-title">Автокредит</div><div class="pm-tile-desc">Кредит на покупку автомобиля</div></div>
</div>
<div class="pm-tile" data-credit-type="card">
<span class="pm-check">✓</span>
<span class="pm-tile-icon" style="background:color-mix(in srgb,oklch(0.80 0.18 90) 20%,transparent);color:oklch(0.80 0.18 90)"><svg class="ico" style="width:1.1em;height:1.1em"><use href="#ic-coin"/></svg></span>
<div><div class="pm-tile-title">Кредитная карта</div><div class="pm-tile-desc">Лимит, льготный период, мин. платёж</div></div>
</div>
</div>
<div id="credit-existing-hint" style="display:none;font-size:12px;color:var(--muted);margin:8px 0 0;padding:10px 12px;background:var(--surface-2);border-radius:var(--radius-md)">
Заводим уже идущий кредит — укажите остаток долга и дату, на которую он верен.
Историю пересчитывать не нужно.
</div>
<div class="dialog-section-label">Основные параметры</div>
<div class="field"><label>Название</label><input class="input" type="text" placeholder="Напр. Тачка"></div>
<div id="credit-card-fields" style="display:none">
<div style="display:flex;gap:10px;margin-top:10px">
<div class="field" style="flex:1;margin:0"><label>Кредитный лимит *</label><input class="input" type="number" min="0"></div>
<div style="width:110px"><div class="field" style="margin:0"><label>Валюта</label>
<select class="input"><?php foreach (Currency::LIST as $code => $name): ?><option value="<?= $code ?>"<?= $code === 'RUB' ? ' selected' : '' ?>><?= $code ?></option><?php endforeach; ?></select>
</div></div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:10px">
<div class="field" style="margin:0"><label>Льготный период, дней</label><input class="input" type="number" min="0"></div>
<div class="field" style="margin:0"><label>Ставка годовая, % *</label><input class="input" type="number" min="0" step="0.01"></div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:10px">
<div class="field" style="margin:0"><label>Мин. платёж</label><input class="input" type="number" min="0"></div>
<div class="field" style="margin:0"><label>Текущий баланс карты</label><input class="input" type="number" min="0"></div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:10px">
<div class="field" style="margin:0"><label>День платежа</label><input class="input" type="number" min="1" max="31"></div>
<div class="field" style="margin:0"><label>Следующий платёж</label><input class="input" type="date"></div>
</div>
</div>
<div id="credit-noncard-fields">
<div style="display:flex;gap:10px;margin-top:10px">
<div class="field" style="flex:1;margin:0"><label>Сумма кредита *</label><input class="input" type="number" min="0"></div>
<div style="width:110px"><div class="field" style="margin:0"><label>Валюта</label>
<select class="input"><?php foreach (Currency::LIST as $code => $name): ?><option value="<?= $code ?>"<?= $code === 'RUB' ? ' selected' : '' ?>><?= $code ?></option><?php endforeach; ?></select>
</div></div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-top:10px">
<div class="field" style="margin:0"><label>Ставка годовая, % *</label><input class="input" type="number" min="0" step="0.01"></div>
<div class="field" style="margin:0"><label>Срок, мес.</label><input class="input" type="number" min="0"></div>
<div class="field" style="margin:0"><label>Ежемесячный платёж</label><input class="input" type="number" min="0"></div>
</div>
<div id="credit-existing-fields" style="display:none;grid-template-columns:1fr 1fr;gap:12px;margin-top:10px">
<div class="field" style="margin:0"><label>Остаток долга *</label><input class="input" type="number" min="0"></div>
<div class="field" style="margin:0"><label>Остаток актуален на *</label><input class="input" type="date"></div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:10px">
<div class="field" style="margin:0"><label>День платежа</label><input class="input" type="number" min="1" max="31"></div>
<div class="field" style="margin:0"><label>Следующий платёж</label><input class="input" type="date"></div>
</div>
<div class="field" style="margin-top:10px">
<label>Способ расчёта</label>
<select class="input"><option>Аннуитет</option><option>Дифференцированный</option></select>
</div>
</div>
<div class="field" style="margin-top:10px"><label>Дата выдачи</label><input class="input" type="date"></div>
</div>
<div class="modal-footer" style="border-color:var(--surface-3);align-items:center;justify-content:space-between">
<div class="card-meta" style="font-size:11px" id="credit-min-hint">Минимум нужно: <strong style="color:var(--ink)">название, сумма, ставка и срок или платёж</strong></div>
<div style="display:flex;gap:8px">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Создать кредит</button>
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="archive-modal" tabindex="-1" data-bs-theme="dark">
<div class="modal-dialog">
<div class="modal-content" style="background:var(--surface-2);color:var(--ink);">

View File

@ -0,0 +1,73 @@
<?php
/**
* @package Bicycle
* @description Settings/index.html — профиль и управление.
*
* @var array $auth_user
* @var object $user
*/
?>
<h1 style="margin:0 0 20px;font-size:24px">Настройки</h1>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px">
<div class="card elev-md" style="padding:20px">
<div style="font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin-bottom:14px">Профиль</div>
<div style="display:flex;flex-direction:column;gap:10px">
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:2px">Логин</div>
<div style="font-size:14px;font-weight:500"><?= htmlspecialchars($auth_user['login'], ENT_QUOTES, 'UTF-8') ?></div>
</div>
<?php if (!empty($auth_user['full_name'])): ?>
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:2px">Имя</div>
<div style="font-size:14px;font-weight:500"><?= htmlspecialchars($auth_user['full_name'], ENT_QUOTES, 'UTF-8') ?></div>
</div>
<?php endif; ?>
<?php if (!empty($auth_user['email'])): ?>
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:2px">E-mail</div>
<div style="font-size:14px"><?= htmlspecialchars($auth_user['email'], ENT_QUOTES, 'UTF-8') ?></div>
</div>
<?php endif; ?>
<?php if (!empty($auth_user['role'])): ?>
<div>
<div style="font-size:11px;color:var(--muted);margin-bottom:2px">Роль</div>
<div style="font-size:14px"><?= htmlspecialchars($auth_user['role'], ENT_QUOTES, 'UTF-8') ?></div>
</div>
<?php endif; ?>
</div>
<div style="margin-top:18px;padding-top:14px;border-top:1px solid var(--surface-3)">
<a href="/login/logout" class="btn btn-secondary" style="font-size:13px">Выйти из аккаунта</a>
</div>
</div>
<div class="card elev-md" style="padding:20px">
<div style="font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin-bottom:14px">Управление</div>
<div style="display:flex;flex-direction:column;gap:8px">
<a href="/budgets" class="card elev-md" style="padding:14px 16px;display:flex;align-items:center;justify-content:space-between;text-decoration:none;color:inherit;border-radius:var(--radius-md)">
<div>
<div style="font-size:14px;font-weight:500">Статьи доходов и расходов</div>
<div style="font-size:12px;color:var(--muted);margin-top:2px">Создание категорий и групп — кнопка «Статьи» на плане бюджета</div>
</div>
<span style="color:var(--muted);font-size:18px">›</span>
</a>
<a href="/" class="card elev-md" style="padding:14px 16px;display:flex;align-items:center;justify-content:space-between;text-decoration:none;color:inherit;border-radius:var(--radius-md)">
<div>
<div style="font-size:14px;font-weight:500">Счета</div>
<div style="font-size:12px;color:var(--muted);margin-top:2px">Управление счетами — на дашборде</div>
</div>
<span style="color:var(--muted);font-size:18px">›</span>
</a>
<a href="/budgets" class="card elev-md" style="padding:14px 16px;display:flex;align-items:center;justify-content:space-between;text-decoration:none;color:inherit;border-radius:var(--radius-md)">
<div>
<div style="font-size:14px;font-weight:500">Бюджет</div>
<div style="font-size:12px;color:var(--muted);margin-top:2px">Годовой план по статьям</div>
</div>
<span style="color:var(--muted);font-size:18px">›</span>
</a>
</div>
</div>
</div>

View File

@ -0,0 +1,60 @@
<?php
/**
* @package Bicycle
* @description Transactions/index.html — список последних 50 транзакций.
*
* @var array $user
* @var object[] $transactions
*/
$month_names_gen = [
1 => 'января', 2 => 'февраля', 3 => 'марта', 4 => 'апреля', 5 => 'мая', 6 => 'июня',
7 => 'июля', 8 => 'августа', 9 => 'сентября', 10 => 'октября', 11 => 'ноября', 12 => 'декабря',
];
// В дизайне доход подсвечен зелёным, а расход — обычным текстом (не красным); тут же приводим
// зелёный к общему токену --positive-text вместо отдельного литерала oklch(...).
$type_colors = [
'income' => 'var(--positive-text)',
'expense' => 'var(--ink)',
'transfer' => 'var(--muted)',
];
$type_signs = [
'income' => '+',
'expense' => '−',
'transfer' => '',
];
?>
<h1 style="margin:0 0 20px;font-size:24px">Транзакции</h1>
<?php if (empty($transactions)): ?>
<div class="empty-state">Нет операций — транзакции появятся здесь после добавления</div>
<?php else: ?>
<div class="card elev-md pm-wrap" style="padding:6px 0">
<?php foreach ($transactions as $tx): ?>
<?php
$d = new DateTime($tx->date);
$dateLabel = (int)$d->format('j') . ' ' . $month_names_gen[(int)$d->format('n')];
$amount = (float)$tx->summa * ((float)($tx->rate_to_rub ?? 1));
$color = $type_colors[$tx->type] ?? 'var(--muted)';
$sign = $type_signs[$tx->type] ?? '';
$category = $tx->category_title ?? ($tx->type === 'transfer' ? 'Перевод' : '—');
$account = $tx->account_f_title ?? $tx->account_in_title ?? '';
?>
<div class="stat-row">
<div style="width:auto;flex:1">
<div style="font-size:13px;font-weight:600"><?= htmlspecialchars($category, ENT_QUOTES, 'UTF-8') ?></div>
<div class="card-meta" style="font-size:11px;margin-top:2px">
<?= htmlspecialchars($dateLabel, ENT_QUOTES, 'UTF-8') ?>
<?php if ($account): ?> · <?= htmlspecialchars($account, ENT_QUOTES, 'UTF-8') ?><?php endif; ?>
</div>
</div>
<div class="num" style="width:auto;flex-shrink:0;font-size:14px;font-weight:600;color:<?= $color ?>">
<?= $sign . number_format($amount, 0, ',', ' ') ?> ₽
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>

View File

@ -9,8 +9,10 @@
use Services\Auth;
use System\Classes\ProfilerToolbar;
use System\Classes\Request;
$auth_user = Auth::instance()->getUser();
$auth_user = Auth::instance()->getUser();
$controller = Request::$current ? Request::$current->controller() : '';
?>
<!DOCTYPE html>
<html lang="ru">
@ -20,11 +22,14 @@ $auth_user = Auth::instance()->getUser();
<title>Бюджет</title>
<link rel="stylesheet" href="/vendor/twbs/bootstrap/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="/vendor/twbs/bootstrap-icons/font/bootstrap-icons.css">
<link rel="stylesheet" href="/App/media/css/fonts.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="/App/media/css/dashboard.css">
<?php $this->getStyles(); ?>
</head>
<body>
<div class="page-wrap">
<svg width="0" height="0" style="position:absolute">
<defs>
<symbol id="ic-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@ -61,6 +66,17 @@ $auth_user = Auth::instance()->getUser();
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06A2 2 0 1 1 7.04 4.29l.06.06A1.65 1.65 0 0 0 8.92 4.68 1.65 1.65 0 0 0 9.93 3.17V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.32 9c.14.36.4.66.74.85.34.19.72.27 1.09.24H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</symbol>
<symbol id="ic-sliders" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" y1="21" x2="4" y2="14"/>
<line x1="4" y1="10" x2="4" y2="3"/>
<line x1="12" y1="21" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12" y2="3"/>
<line x1="20" y1="21" x2="20" y2="16"/>
<line x1="20" y1="12" x2="20" y2="3"/>
<line x1="1" y1="14" x2="7" y2="14"/>
<line x1="9" y1="8" x2="15" y2="8"/>
<line x1="17" y1="16" x2="23" y2="16"/>
</symbol>
<symbol id="ic-logout" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
@ -79,13 +95,14 @@ $auth_user = Auth::instance()->getUser();
</main>
<?php if ($auth_user !== null): ?>
<nav class="dock">
<a href="/" class="dock-item active" title="Дашборд"><svg class="ico"><use href="#ic-home"/></svg></a>
<a href="/budgets" class="dock-item" title="Бюджет"><svg class="ico"><use href="#ic-chart"/></svg></a>
<div class="dock-item" title="Транзакции"><svg class="ico"><use href="#ic-swap"/></svg></div>
<div class="dock-item" title="Аналитика"><svg class="ico"><use href="#ic-analytics"/></svg></div>
<div class="dock-item" title="Настройки"><svg class="ico"><use href="#ic-gear"/></svg></div>
<a href="/" class="dock-item<?= $controller === 'Index' ? ' active' : '' ?>" title="Дашборд"><svg class="ico"><use href="#ic-home"/></svg></a>
<a href="/budgets" class="dock-item<?= $controller === 'Budgets' ? ' active' : '' ?>" title="Бюджет"><svg class="ico"><use href="#ic-chart"/></svg></a>
<a href="/transactions" class="dock-item<?= $controller === 'Transactions' ? ' active' : '' ?>" title="Транзакции"><svg class="ico"><use href="#ic-sliders"/></svg></a>
<a href="/analytics" class="dock-item<?= $controller === 'Analytics' ? ' active' : '' ?>" title="Аналитика"><svg class="ico"><use href="#ic-analytics"/></svg></a>
<a href="/settings" class="dock-item<?= $controller === 'Settings' ? ' active' : '' ?>" title="Настройки"><svg class="ico"><use href="#ic-gear"/></svg></a>
</nav>
<?php endif; ?>
</div><!-- .page-wrap -->
<script src="/vendor/components/jquery/jquery.min.js"></script>
<script src="/vendor/twbs/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<?php $this->getScripts(); ?>

47
Services/CurrencyRate.php Normal file
View File

@ -0,0 +1,47 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description CurrencyRate.php
* @copyright (c) 15/08/2026
*/
namespace Services;
use Services\CurrencyRate\Client;
use System\Classes\Config;
use System\Classes\MyException;
/**
* Точка входа в курсы валют (ЦБ РФ). Имя аргумента — имя подключения
* (ключ в Config::get('currency_rate', $name), по умолчанию 'default').
*
* Services\CurrencyRate::instance()->rate('EUR'); // float|null
*/
class CurrencyRate
{
/** @var array<string,Client> Кэш клиентов по имени подключения */
protected static array $_instances = [];
/**
* @param string|null $name Имя подключения из конфига; null — 'default'
* @return Client
* @throws MyException Если подключение не описано в конфиге
*/
public static function instance(?string $name = null): Client
{
$name ??= 'default';
if (!isset(self::$_instances[$name])) {
$config = Config::get('currency_rate', $name);
if ($config === null) {
throw new MyException('Подключение currency_rate не описано в конфиге: :name', [':name' => $name]);
}
self::$_instances[$name] = new Client($config);
}
return self::$_instances[$name];
}
}

View File

@ -0,0 +1,141 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description CurrencyRate/Client.php
* @copyright (c) 15/08/2026
*/
namespace Services\CurrencyRate;
use RuntimeException;
use System\Classes\HTTP\Client\Curl;
use System\Classes\HTTP\Client\Request as HttpRequest;
use Throwable;
/**
* Курсы валют ЦБ РФ (XML_daily.asp, без авторизации). Кэшируется файлом на сутки — курс ЦБ
* публикуется раз в день, дёргать внешний сервис на каждый рендер страницы незачем. Любая
* ошибка (сеть/парсинг) — просто null, страница не должна падать из-за недоступности ЦБ
* (тот же принцип graceful degradation, что и у Elasticsearch\Client::ping()).
*/
class Client
{
/** @var array<string,float>|null Разобранные курсы текущего запроса (в памяти процесса) */
protected ?array $rates = null;
/**
* @param array $config url (XML_daily.asp), timeout (сек, по умолчанию 10),
* cache_path (файл кэша), ttl (сек, по умолчанию 86400)
*/
public function __construct(protected array $config)
{
}
/**
* Курс за 1 единицу валюты в рублях.
*
* @param string $code ISO 4217, например 'EUR'
* @return float|null null — RUB (курс 1 не нужен, но метод его не отдаёт отдельно, см. ниже),
* код не найден в ответе ЦБ, либо курс сейчас недоступен (сеть/парсинг)
*/
public function rate(string $code): ?float
{
$code = strtoupper($code);
if ($code === 'RUB') {
return 1.0;
}
return $this->rates()[$code] ?? null;
}
/**
* @return array<string,float> code => курс за 1 единицу в рублях; пустой массив при ошибке
*/
protected function rates(): array
{
if ($this->rates !== null) {
return $this->rates;
}
$cache_path = $this->config['cache_path'] ?? (APPPATH . '/cache/currency_rate.json');
$ttl = (int)($this->config['ttl'] ?? 86400);
if (is_file($cache_path) && (time() - filemtime($cache_path)) < $ttl) {
$cached = json_decode((string)file_get_contents($cache_path), true);
if (is_array($cached)) {
return $this->rates = $cached;
}
}
$rates = $this->fetch();
if ($rates !== null) {
@file_put_contents($cache_path, json_encode($rates));
return $this->rates = $rates;
}
// Сеть недоступна — отдаём протухший кэш, если он вообще есть, чем ничего.
if (is_file($cache_path)) {
$cached = json_decode((string)file_get_contents($cache_path), true);
if (is_array($cached)) {
return $this->rates = $cached;
}
}
return $this->rates = [];
}
/**
* @return array<string,float>|null null при любой ошибке сети/парсинга
*/
protected function fetch(): ?array
{
try {
$response = (new Curl())->execute(
HttpRequest::factory($this->config['url'] ?? 'https://www.cbr.ru/scripts/XML_daily.asp')
->method('GET')
->timeout((int)($this->config['timeout'] ?? 10))
);
} catch (RuntimeException) {
return null;
}
if (!$response->isSuccess()) {
return null;
}
// Ответ ЦБ — windows-1251, не UTF-8.
$xml_body = @iconv('windows-1251', 'UTF-8//IGNORE', $response->body());
if ($xml_body === false) {
return null;
}
try {
$xml = new \SimpleXMLElement($xml_body);
} catch (Throwable) {
return null;
}
$rates = [];
foreach ($xml->Valute as $valute) {
$code = (string)$valute->CharCode;
$nominal = (float)str_replace(',', '.', (string)$valute->Nominal);
$value = (float)str_replace(',', '.', (string)$valute->Value);
if ($code === '' || $nominal <= 0) {
continue;
}
$rates[$code] = $value / $nominal;
}
return $rates;
}
}

18
composer.lock generated
View File

@ -1608,20 +1608,20 @@
"packages-dev": [
{
"name": "myclabs/deep-copy",
"version": "1.13.4",
"version": "1.14.0",
"source": {
"type": "git",
"url": "https://github.com/myclabs/DeepCopy.git",
"reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
"reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
"reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
"url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae",
"reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
"php": "^8.0"
},
"conflict": {
"doctrine/collections": "<1.6.8",
@ -1656,15 +1656,15 @@
],
"support": {
"issues": "https://github.com/myclabs/DeepCopy/issues",
"source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
"source": "https://github.com/myclabs/DeepCopy/tree/1.14.0"
},
"funding": [
{
"url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
"type": "tidelift"
"url": "https://github.com/mnapoli",
"type": "github"
}
],
"time": "2025-08-01T08:46:24+00:00"
"time": "2026-08-11T10:17:44+00:00"
},
{
"name": "nikic/php-parser",

View File

@ -23,6 +23,8 @@ CREATE TABLE accounts (
summa DECIMAL(15, 2) NOT NULL DEFAULT 0,
currency CHAR(3) NOT NULL DEFAULT 'RUB', -- ISO 4217; курс — Services\CurrencyRate (ЦБ РФ), не хранится тут
description VARCHAR(255) NULL, -- свободное поле, например "счёт жены" — без отдельной структуры
color VARCHAR(20) NULL, -- пользовательский акцент счёта (oklch(...) строкой); NULL — цвет по умолчанию для type_acc
privacy ENUM ('shared', 'personal') NOT NULL DEFAULT 'shared', -- визуальный признак из дизайн-хендоффа; логики доступа за ним нет (мультипользовательский режим не в приоритете)
include_in_total TINYINT(1) NOT NULL DEFAULT 1, -- чисто ручной переключатель пользователя, не системное правило по типу/валюте (дефолт 0 для savings — просто стартовое значение)
is_delete TINYINT(1) NOT NULL DEFAULT 0,
`order` INT NOT NULL DEFAULT 0,
@ -55,10 +57,28 @@ CREATE TABLE goals (
CONSTRAINT fk_goals_account FOREIGN KEY (account_id) REFERENCES accounts (id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
-- Группа статей — самостоятельная сущность, не статья с детьми. Раньше группой считалась
-- обычная строка categories, на которую другие строки ссылались через parent_id — это позволяло
-- одной и той же записи «случайно» становиться то листом (со своим планом в budgets), то группой,
-- и при добавлении первого ребёнка старый план листа не чистился, оставаясь мусором в budgets.
-- Разделение на две таблицы делает такое структурно невозможным: budgets.categorie_id может
-- указывать только на categories (см. ниже), у group_id там просто нет своей строки в budgets.
CREATE TABLE category_groups (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id INT UNSIGNED NOT 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,
PRIMARY KEY (id),
KEY ix_category_groups_user_id (user_id),
CONSTRAINT fk_category_groups_user FOREIGN KEY (user_id) REFERENCES users (id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
CREATE TABLE categories (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id INT UNSIGNED NOT NULL,
parent_id INT UNSIGNED NULL,
group_id INT UNSIGNED NULL, -- NULL — статья без группы (верхний уровень), см. category_groups
title VARCHAR(128) NOT NULL,
type ENUM ('income', 'expense') NOT NULL,
is_delete TINYINT(1) NOT NULL DEFAULT 0, -- архивация вместо удаления, тот же приём что у accounts — сохраняет историю транзакций
@ -69,10 +89,10 @@ CREATE TABLE categories (
formula_pct DECIMAL(5, 2) NULL,
PRIMARY KEY (id),
KEY ix_categories_user_id (user_id),
KEY ix_categories_parent_id (parent_id),
KEY ix_categories_group_id (group_id),
CONSTRAINT fk_categories_user FOREIGN KEY (user_id) REFERENCES users (id),
-- удаление родителя не должно тянуть за собой подкатегории — они просто станут верхнего уровня
CONSTRAINT fk_categories_parent FOREIGN KEY (parent_id) REFERENCES categories (id) ON DELETE SET NULL,
-- удаление группы не должно тянуть за собой статьи — они просто станут без группы
CONSTRAINT fk_categories_group FOREIGN KEY (group_id) REFERENCES category_groups (id) ON DELETE SET NULL,
CONSTRAINT fk_categories_linked_expense FOREIGN KEY (linked_expense_categorie_id) REFERENCES categories (id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;

View File

@ -17,6 +17,10 @@ use System\Classes\Repository;
* подменяем таблицу на временную через прямой вызов Repository::__construct(), минуя
* CategoryRepository::__construct() (тот же приём, что и RepositoryTestItemRepository
* в RepositoryTest.php, только поверх реального класса, а не заглушки).
*
* Группа (category_groups) — самостоятельная сущность (см. схему), но на уровне
* CategoryRepository это просто INT-колонка group_id без FK — сюда её реальным существованием
* можно не заниматься, getTree()/findDuplicate() работают с сырым значением как есть.
*/
class CategoryRepositoryTestDouble extends CategoryRepository
{
@ -47,7 +51,7 @@ class CategoryRepositoryTest extends TestCase
'CREATE TABLE category_repository_test_categories (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
parent_id INT UNSIGNED NULL,
group_id INT UNSIGNED NULL,
title VARCHAR(128) NOT NULL,
type ENUM(\'income\',\'expense\') NOT NULL,
is_delete TINYINT(1) NOT NULL DEFAULT 0,
@ -77,10 +81,10 @@ class CategoryRepositoryTest extends TestCase
$prop->setValue(null, []);
}
public function testFindActiveDuplicateFindsSameTitleTypeParent(): void
public function testFindActiveDuplicateFindsSameTitleTypeGroup(): void
{
$this->repository->create([
'user_id' => 1, 'parent_id' => null, 'title' => 'Зарплата', 'type' => 'income',
'user_id' => 1, 'group_id' => null, 'title' => 'Зарплата', 'type' => 'income',
'is_delete' => false, 'order' => 0,
]);
@ -93,7 +97,7 @@ class CategoryRepositoryTest extends TestCase
public function testFindActiveDuplicateIgnoresDifferentType(): void
{
$this->repository->create([
'user_id' => 1, 'parent_id' => null, 'title' => 'Прочее', 'type' => 'income',
'user_id' => 1, 'group_id' => null, 'title' => 'Прочее', 'type' => 'income',
'is_delete' => false, 'order' => 0,
]);
@ -103,7 +107,7 @@ class CategoryRepositoryTest extends TestCase
public function testFindActiveDuplicateExcludesGivenId(): void
{
$id = $this->repository->create([
'user_id' => 1, 'parent_id' => null, 'title' => 'Транспорт', 'type' => 'expense',
'user_id' => 1, 'group_id' => null, 'title' => 'Транспорт', 'type' => 'expense',
'is_delete' => false, 'order' => 0,
]);
@ -113,7 +117,7 @@ class CategoryRepositoryTest extends TestCase
public function testFindArchivedDuplicateOnlyMatchesArchived(): void
{
$this->repository->create([
'user_id' => 1, 'parent_id' => null, 'title' => 'Такси', 'type' => 'expense',
'user_id' => 1, 'group_id' => null, 'title' => 'Такси', 'type' => 'expense',
'is_delete' => true, 'order' => 0,
]);
@ -125,28 +129,32 @@ class CategoryRepositoryTest extends TestCase
$this->assertSame('Такси', $archived->title);
}
public function testGetTreeGroupsByParentId(): void
public function testGetTreeGroupsByGroupId(): void
{
$group_id = $this->repository->create([
'user_id' => 1, 'parent_id' => null, 'title' => 'Транспорт', 'type' => 'expense',
// group_id тут условный (42) — CategoryRepository не проверяет, что за ним реально стоит
// строка category_groups, это забота CategoriesController::parseGroupId().
$group_id = 42;
$this->repository->create([
'user_id' => 1, 'group_id' => null, 'title' => 'Без группы', 'type' => 'expense',
'is_delete' => false, 'order' => 0,
]);
$this->repository->create([
'user_id' => 1, 'parent_id' => $group_id, 'title' => 'Такси', 'type' => 'expense',
'user_id' => 1, 'group_id' => $group_id, 'title' => 'Такси', 'type' => 'expense',
'is_delete' => false, 'order' => 0,
]);
// архивная — не должна попасть в дерево
$this->repository->create([
'user_id' => 1, 'parent_id' => $group_id, 'title' => 'Метро', 'type' => 'expense',
'user_id' => 1, 'group_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->assertSame('Без группы', $tree[0][0]->title);
$this->assertCount(1, $tree[$group_id]);
$this->assertSame('Такси', $tree[$group_id][0]->title);
}
@ -154,11 +162,11 @@ class CategoryRepositoryTest extends TestCase
public function testGetTreeScopedByUser(): void
{
$this->repository->create([
'user_id' => 1, 'parent_id' => null, 'title' => 'Моя статья', 'type' => 'expense',
'user_id' => 1, 'group_id' => null, 'title' => 'Моя статья', 'type' => 'expense',
'is_delete' => false, 'order' => 0,
]);
$this->repository->create([
'user_id' => 2, 'parent_id' => null, 'title' => 'Чужая статья', 'type' => 'expense',
'user_id' => 2, 'group_id' => null, 'title' => 'Чужая статья', 'type' => 'expense',
'is_delete' => false, 'order' => 0,
]);

View File

@ -0,0 +1,62 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use Services\CurrencyRate\Client;
/**
* Сеть ЦБ РФ недоступна из dev-контейнера (изолированная песочница) — fetch() гарантированно
* уходит в graceful null, это и проверяем (см. Services\CurrencyRate\Client::fetch()). Кэш
* проверяется отдельно, через подложенный файл, без реального похода в сеть.
*/
class CurrencyRateClientTest extends TestCase
{
private string $cache_path;
protected function setUp(): void
{
$this->cache_path = sys_get_temp_dir() . '/currency_rate_test_' . uniqid() . '.json';
}
protected function tearDown(): void
{
if (is_file($this->cache_path)) {
unlink($this->cache_path);
}
}
public function testRubAlwaysReturnsOne(): void
{
$client = new Client(['url' => 'https://unreachable.invalid/', 'timeout' => 1, 'cache_path' => $this->cache_path]);
$this->assertSame(1.0, $client->rate('rub'));
$this->assertSame(1.0, $client->rate('RUB'));
}
public function testUnreachableSourceReturnsNullWithoutThrowing(): void
{
$client = new Client(['url' => 'https://unreachable.invalid/', 'timeout' => 1, 'cache_path' => $this->cache_path]);
$this->assertNull($client->rate('EUR'));
}
public function testReadsFromFreshCacheWithoutNetwork(): void
{
file_put_contents($this->cache_path, json_encode(['EUR' => 104.5]));
// url заведомо невалиден — если бы клиент реально пошёл в сеть, тест бы не прошёл (null).
$client = new Client(['url' => 'not-a-url', 'timeout' => 1, 'cache_path' => $this->cache_path, 'ttl' => 86400]);
$this->assertSame(104.5, $client->rate('EUR'));
}
public function testUnknownCodeReturnsNull(): void
{
file_put_contents($this->cache_path, json_encode(['EUR' => 104.5]));
$client = new Client(['url' => 'not-a-url', 'timeout' => 1, 'cache_path' => $this->cache_path, 'ttl' => 86400]);
$this->assertNull($client->rate('XXX'));
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use Services\CurrencyRate;
use Services\CurrencyRate\Client;
use System\Classes\Config;
use System\Classes\MyException;
class CurrencyRateTest extends TestCase
{
protected function tearDown(): void
{
$ref = new ReflectionClass(CurrencyRate::class);
$prop = $ref->getProperty('_instances');
$prop->setAccessible(true);
$prop->setValue(null, []);
$ref = new ReflectionClass(Config::class);
$prop = $ref->getProperty('config');
$prop->setAccessible(true);
$prop->setValue(null, null);
}
public function testDefaultConnectionReturnsClient(): void
{
$this->assertInstanceOf(Client::class, CurrencyRate::instance());
}
public function testInstanceIsCachedPerConnectionName(): void
{
$this->assertSame(CurrencyRate::instance('default'), CurrencyRate::instance('default'));
}
public function testUnknownConnectionNameThrows(): void
{
$this->expectException(MyException::class);
CurrencyRate::instance('nonexistent');
}
}