dev
This commit is contained in:
parent
ac1334c784
commit
913e3eec38
3
.gitignore
vendored
3
.gitignore
vendored
@ -11,6 +11,9 @@ App/config/auth_users.php
|
||||
/App/logs/*
|
||||
!/App/logs/.gitkeep
|
||||
|
||||
/App/cache/*
|
||||
!/App/cache/.gitkeep
|
||||
|
||||
**/files/**/*
|
||||
!**/files/**/
|
||||
!**/files/**/.gitkeep
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
||||
@ -101,21 +118,44 @@ class AccountsController extends Controller
|
||||
}
|
||||
|
||||
$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];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
71
App/Controller/AnalyticsController.php
Normal file
71
App/Controller/AnalyticsController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
$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] ?? [],
|
||||
'groups' => $groups,
|
||||
'plan' => $plan,
|
||||
'year' => $year,
|
||||
'archived' => $archived ?: [],
|
||||
'archived_groups' => $archived_groups ?: [],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -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 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
200
App/Controller/CategoryGroupsController.php
Normal file
200
App/Controller/CategoryGroupsController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
70
App/Controller/GoalsController.php
Normal file
70
App/Controller/GoalsController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
42
App/Controller/SettingsController.php
Normal file
42
App/Controller/SettingsController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
45
App/Controller/TransactionsController.php
Normal file
45
App/Controller/TransactionsController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
90
App/Repositories/CategoryGroupRepository.php
Normal file
90
App/Repositories/CategoryGroupRepository.php
Normal 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`') ?: [];
|
||||
}
|
||||
}
|
||||
@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
0
App/cache/.gitkeep
vendored
Normal 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,
|
||||
|
||||
@ -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.
|
||||
1911
App/design_handoff_personal_finance/support.js
Normal file
1911
App/design_handoff_personal_finance/support.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
return;
|
||||
}
|
||||
|
||||
var modal = new bootstrap.Modal(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 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 () {
|
||||
@ -42,32 +85,44 @@ $(function () {
|
||||
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) {
|
||||
@ -82,20 +137,84 @@ $(function () {
|
||||
}
|
||||
|
||||
$.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 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;
|
||||
});
|
||||
});
|
||||
|
||||
@ -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') || '');
|
||||
@ -138,9 +155,9 @@ $(function () {
|
||||
|
||||
$(document).on('change', '.js-category-move', function () {
|
||||
var $select = $(this);
|
||||
var newParent = $select.val();
|
||||
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');
|
||||
});
|
||||
|
||||
235
App/view/Analytics/index.html
Normal file
235
App/view/Analytics/index.html
Normal 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>
|
||||
@ -8,10 +8,12 @@
|
||||
* (все 12 месяцев по тапу). Ни один режим не даёт горизонтальный скролл.
|
||||
* Факт (реальные transactions) здесь не показывается.
|
||||
*
|
||||
* @var array<int,object[]> $tree Активные статьи пользователя, сгруппированные по parent_id
|
||||
* @var object[] $groups Верхнеуровневые группы (tree[0])
|
||||
* @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 ?>">←</a>
|
||||
<div class="balance num" style="font-size:2rem;"><?= $year ?></div>
|
||||
<a class="btn btn-outline-light btn-sm" href="?year=<?= $year + 1 ?>">→</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-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-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">→ формула <?= 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">→ формула <?= 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>
|
||||
|
||||
@ -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">→ формула <?= 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>
|
||||
@ -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',
|
||||
];
|
||||
|
||||
$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-block">
|
||||
<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 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>
|
||||
<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;
|
||||
|
||||
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">
|
||||
<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="var(--purple)" stroke-width="5" stroke-linecap="round"
|
||||
<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="12" fill="var(--ink)"><?= round($pct) ?>%</text>
|
||||
<text x="26" y="30" text-anchor="middle" font-size="13" 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') ?>
|
||||
<?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);
|
||||
$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);
|
||||
$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>
|
||||
</select>
|
||||
<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 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">
|
||||
<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' : '' ?> title="<?= htmlspecialchars($name, ENT_QUOTES, 'UTF-8') ?>"><?= $code ?></option>
|
||||
<option value="<?= $code ?>"<?= $code === 'RUB' ? ' selected' : '' ?>><?= $code ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</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 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">
|
||||
<label class="form-label">Описание</label>
|
||||
<input type="text" class="form-control" name="description" maxlength="255">
|
||||
<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="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 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>
|
||||
<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);">
|
||||
|
||||
73
App/view/Settings/index.html
Normal file
73
App/view/Settings/index.html
Normal 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>
|
||||
60
App/view/Transactions/index.html
Normal file
60
App/view/Transactions/index.html
Normal 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; ?>
|
||||
@ -9,8 +9,10 @@
|
||||
|
||||
use Services\Auth;
|
||||
use System\Classes\ProfilerToolbar;
|
||||
use System\Classes\Request;
|
||||
|
||||
$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
47
Services/CurrencyRate.php
Normal 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];
|
||||
}
|
||||
}
|
||||
141
Services/CurrencyRate/Client.php
Normal file
141
Services/CurrencyRate/Client.php
Normal 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
18
composer.lock
generated
@ -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",
|
||||
|
||||
28
schema.sql
28
schema.sql
@ -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;
|
||||
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
|
||||
|
||||
62
tests/Unit/CurrencyRateClientTest.php
Normal file
62
tests/Unit/CurrencyRateClientTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
42
tests/Unit/CurrencyRateTest.php
Normal file
42
tests/Unit/CurrencyRateTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user