diff --git a/.claude/memory/бюджет_текущий_план.md b/.claude/memory/бюджет_текущий_план.md index cac362b..ccb8eee 100644 --- a/.claude/memory/бюджет_текущий_план.md +++ b/.claude/memory/бюджет_текущий_план.md @@ -550,15 +550,21 @@ ALTER TABLE categories ADD CONSTRAINT fk_categories_linked_expense 2. **Инвестиции** (вклады/акции/облигации) — следующий слой, схему делать с запасом. 3. **Кредиты** — отложены, но `account_rates` намеренно универсальна для переиспользования позже. -**Статьи (categories) + годовой план (budgets)** — план утверждён (2026-08-12), реализация ещё не -начата. Полный план — `/home/isaevea/.claude/plans/rippling-hatching-rabbit.md` (Часть A — -`CategoriesController`/`categories.js`/`categories.html`, отдельная страница `/categories`, защита -от дублей активных/архивных статей; Часть B — `BudgetsController`/`budgets.js`/`budgets.html`, сетка -статьи×12 месяцев сохраняется одной кнопкой одним POST, автозаполнение связанной expense-статьи по -`formula_pct`). `CategoryRepository`/`BudgetRepository` пока пустые обёртки — методы -(`findActiveDuplicate`/`findArchivedDuplicate`/`getTree`, `getYear`/`upsert`) ещё не добавлены. -Начинать реализацию — с `CategoryRepository`, затем `CategoriesController` (по образцу -`AccountsController`). +**Статьи (categories) + годовой план (budgets)** — реализовано (2026-08-13), план +`/home/isaevea/.claude/plans/rippling-hatching-rabbit.md` выполнен целиком: `CategoryRepository` +(`findActiveDuplicate`/`findArchivedDuplicate`/`getTree`), `CategoriesController` +(index/create/edit/delete/restore, защита от дублей активных/архивных статей), `categories.html` + +`categories.js` (страница `/categories`, группы+подкатегории, формула income→expense в модалке); +`BudgetRepository` (`getYear`/`upsert`, JOIN-таблица categories вынесена в свойство +`$categories_table` — ради тестируемости без риска для боевой таблицы), `BudgetsController` +(сетка статьи×12 месяцев, один POST, автопересчёт формульной expense-статьи отдельным проходом +после прямых значений — не зависит от порядка обхода), `budgets.html` + `budgets.js`. Ссылка +«Категории» в доке (`layout.html`) — теперь реальная, с подсветкой активного пункта по +`Request::$current->controller()`. Тесты — `tests/Unit/CategoryRepositoryTest.php`, +`tests/Unit/BudgetRepositoryTest.php` (реальная MariaDB, временные таблицы с отдельными именами — +не боевые `categories`/`budgets`). 259 тестов, всё зелено (8 skip — Elasticsearch/Mongo). +Живой CRUD-флоу под `mikrit` в браузере — не проверен мной (нет учётных данных), маршруты +проверены curl (302 на /login без сессии, без 500). **Реальная БД и пользователь** (2026-08-12): `schema.sql` применена к боевой MariaDB (`192.168.11.247:3306`, база `budget`, все таблицы были пустые на момент применения — пересоздавались diff --git a/App/Controller/BudgetsController.php b/App/Controller/BudgetsController.php new file mode 100644 index 0000000..5b8d5b5 --- /dev/null +++ b/App/Controller/BudgetsController.php @@ -0,0 +1,149 @@ +currentUser(); + $year = (int)(Request::$current->query('year') ?: (int)date('Y')); + + $category_repo = new CategoryRepository(); + $budget_repo = new BudgetRepository(); + + $tree = $category_repo->getTree($user->id); + $plan = $budget_repo->getYear($user->id, $year); + + return $this->render('budgets', [ + 'tree' => $tree, + 'groups' => $tree[0] ?? [], + 'plan' => $plan, + 'year' => $year, + ]); + } + + /** + * Сохраняет всю сетку одним запросом: plan[categorie_id][month] = сумма. Для статей дохода + * со связанной статьёй расхода (formula_pct) — автоматически пересчитывает и перезаписывает + * план связанной статьи расхода тем же процентом (см. бюджет_текущий_план.md → Categories). + * + * @return string JSON + * @throws HTTPException|MyException + */ + public function saveAction(): string + { + $request = Request::$current; + $user = $this->currentUser(); + + $year = (int)$request->post('year'); + $plan_input = $request->post('plan'); + + if ($year < 2000 || $year > 2100 || !is_array($plan_input)) { + return $this->json(['success' => false, 'errors' => ['year' => 'Некорректный год']], 422); + } + + $category_repo = new CategoryRepository(); + $budget_repo = new BudgetRepository(); + + $categories = []; + + foreach (array_keys($plan_input) as $categorie_id) { + $category = $category_repo->get((int)$categorie_id); + + if (!$category || (int)$category->user_id !== (int)$user->id) { + throw HTTPException::factory(404); + } + + $categories[(int)$categorie_id] = $category; + } + + // Пересчитанные формулой ячейки связанной статьи расхода — возвращаются клиенту, чтобы + // JS обновил их в сетке сразу, без перезагрузки страницы (см. budgets.js). + $recalculated = []; + + $budget_repo->transaction(static function () use ($categories, $plan_input, $year, $budget_repo, &$recalculated) { + // 1. Прямые значения из формы — как есть. + foreach ($categories as $categorie_id => $category) { + foreach ($plan_input[$categorie_id] ?? [] as $month => $summ) { + $month = (int)$month; + + if ($month < 1 || $month > 12) { + continue; + } + + $budget_repo->upsert($categorie_id, $year, $month, (float)$summ, $category->type); + } + } + + // 2. Формульные статьи дохода — пересчитывают связанную статью расхода поверх, + // последним шагом, чтобы формула всегда побеждала независимо от порядка обхода. + foreach ($categories as $category) { + if ($category->linked_expense_categorie_id === null || $category->formula_pct === null) { + continue; + } + + foreach ($plan_input[(int)$category->id] ?? [] as $month => $summ) { + $month = (int)$month; + + if ($month < 1 || $month > 12) { + continue; + } + + $expense_summ = round((float)$summ * (float)$category->formula_pct / 100, 2); + $target_id = (int)$category->linked_expense_categorie_id; + + $budget_repo->upsert($target_id, $year, $month, $expense_summ, 'expense', (float)$category->formula_pct); + + $recalculated[$target_id][$month] = $expense_summ; + } + } + }); + + return $this->json(['success' => true, 'recalculated' => $recalculated]); + } + + /** + * Бюджетный пользователь, связанный с текущим логином Services\Auth. + * + * @return object + * @throws HTTPException|MyException + */ + protected function currentUser(): object + { + $auth_user = Auth::instance()->getUser(); + $user = (new UserRepository())->getByLogin($auth_user['login']); + + if (!$user) { + throw HTTPException::factory(404); + } + + return $user; + } +} diff --git a/App/Controller/CategoriesController.php b/App/Controller/CategoriesController.php new file mode 100644 index 0000000..720dbd2 --- /dev/null +++ b/App/Controller/CategoriesController.php @@ -0,0 +1,321 @@ +currentUser(); + $category_repo = new CategoryRepository(); + + $tree = $category_repo->getTree($user->id); + $archived = $category_repo->getList('*', ['user_id' => $user->id, 'is_delete' => 1], 'title'); + + return $this->render('categories', [ + 'tree' => $tree, + 'archived' => $archived ?: [], + ]); + } + + /** + * @return string JSON + * @throws MyException + */ + public function createAction(): string + { + $request = Request::$current; + $user = $this->currentUser(); + $category_repo = new CategoryRepository(); + + $validation = $this->buildValidation($request->post()); + + if (!$validation->check()) { + return $this->json(['success' => false, 'errors' => $validation->errors()], 422); + } + + $type = $request->post('type'); + $title = $request->post('title'); + + $parent_id = $this->parseParentId($category_repo, $user, $type, $request->post('parent_id')); + + if ($parent_id === false) { + return $this->json(['success' => false, 'errors' => ['parent_id' => 'Родительская статья недоступна']], 422); + } + + [$linked_id, $formula_pct, $link_error] = $this->parseFormulaLink( + $category_repo, $user, $type, $request->post('linked_expense_categorie_id'), $request->post('formula_pct') + ); + + if ($link_error !== null) { + return $this->json(['success' => false, 'errors' => ['linked_expense_categorie_id' => $link_error]], 422); + } + + if ($category_repo->findActiveDuplicate($user->id, $type, $parent_id, $title)) { + return $this->json(['success' => false, 'errors' => ['title' => 'Такая статья уже есть']], 422); + } + + $archived_duplicate = $category_repo->findArchivedDuplicate($user->id, $type, $parent_id, $title); + + if ($archived_duplicate) { + return $this->json([ + 'success' => false, + 'archived' => ['id' => $archived_duplicate->id, 'title' => $archived_duplicate->title], + ], 422); + } + + $category_repo->create([ + 'user_id' => $user->id, + 'parent_id' => $parent_id, + 'title' => $title, + 'type' => $type, + 'is_delete' => false, + 'order' => 0, + 'linked_expense_categorie_id' => $linked_id, + 'formula_pct' => $formula_pct, + ]); + + return $this->json(['success' => true]); + } + + /** + * @return string JSON + * @throws HTTPException|MyException + */ + public function editAction(): string + { + $request = Request::$current; + $user = $this->currentUser(); + $category_repo = new CategoryRepository(); + $category = $this->ownCategory($category_repo, $user, (int)$request->param(0)); + + $validation = $this->buildValidation($request->post()); + + if (!$validation->check()) { + return $this->json(['success' => false, 'errors' => $validation->errors()], 422); + } + + $type = $request->post('type'); + $title = $request->post('title'); + + $parent_id = $this->parseParentId($category_repo, $user, $type, $request->post('parent_id')); + + if ($parent_id === false || $parent_id === (int)$category->id) { + return $this->json(['success' => false, 'errors' => ['parent_id' => 'Родительская статья недоступна']], 422); + } + + [$linked_id, $formula_pct, $link_error] = $this->parseFormulaLink( + $category_repo, $user, $type, $request->post('linked_expense_categorie_id'), $request->post('formula_pct') + ); + + if ($link_error !== null) { + return $this->json(['success' => false, 'errors' => ['linked_expense_categorie_id' => $link_error]], 422); + } + + if ($category_repo->findActiveDuplicate($user->id, $type, $parent_id, $title, (int)$category->id)) { + return $this->json(['success' => false, 'errors' => ['title' => 'Такая статья уже есть']], 422); + } + + $archived_duplicate = $category_repo->findArchivedDuplicate($user->id, $type, $parent_id, $title, (int)$category->id); + + if ($archived_duplicate) { + return $this->json([ + 'success' => false, + 'archived' => ['id' => $archived_duplicate->id, 'title' => $archived_duplicate->title], + ], 422); + } + + $category_repo->update([ + 'id' => $category->id, + 'parent_id' => $parent_id, + 'title' => $title, + 'type' => $type, + 'linked_expense_categorie_id' => $linked_id, + 'formula_pct' => $formula_pct, + ]); + + return $this->json(['success' => true]); + } + + /** + * Архивация (is_delete = 1), не физическое удаление — история транзакций может ссылаться на статью. + * + * @return string JSON + * @throws HTTPException|MyException + */ + public function deleteAction(): string + { + $request = Request::$current; + $user = $this->currentUser(); + $category_repo = new CategoryRepository(); + $category = $this->ownCategory($category_repo, $user, (int)$request->param(0)); + + $category_repo->update(['id' => $category->id, 'is_delete' => true]); + + return $this->json(['success' => true]); + } + + /** + * Возврат из архива (is_delete = 0). + * + * @return string JSON + * @throws HTTPException|MyException + */ + public function restoreAction(): string + { + $request = Request::$current; + $user = $this->currentUser(); + $category_repo = new CategoryRepository(); + $category = $this->ownCategory($category_repo, $user, (int)$request->param(0)); + + $category_repo->update(['id' => $category->id, 'is_delete' => false]); + + return $this->json(['success' => true]); + } + + /** + * Общие правила валидации формы статьи (create/edit). + * + * @param array $data + * @return Validation + */ + protected function buildValidation(array $data): Validation + { + return Validation::factory($data) + ->label('title', 'Название') + ->label('type', 'Тип') + ->rule('title', 'required') + ->rule('title', 'max_length', [128]) + ->rule('type', 'required') + ->rule('type', 'in', [['income', 'expense']]) + ->rule('formula_pct', 'numeric'); + } + + /** + * Родительская статья (группа) из сырого значения формы — с проверкой владения/уровня/типа. + * + * @param CategoryRepository $category_repo + * @param object $user + * @param string $type Тип создаваемой/правимой статьи — родитель должен совпадать + * @param mixed $raw Сырое значение поля parent_id + * @return int|false|null null — верхний уровень (родителя нет); false — родитель недопустим + */ + protected function parseParentId(CategoryRepository $category_repo, object $user, string $type, mixed $raw): int|false|null + { + if ($raw === null || $raw === '') { + return null; + } + + $parent = $category_repo->get((int)$raw); + + if (!$parent || (int)$parent->user_id !== (int)$user->id || $parent->parent_id !== null || $parent->type !== $type) { + return false; + } + + return (int)$parent->id; + } + + /** + * Формульная связь income-статьи со статьёй расхода — оба поля (linked+pct) заполняются вместе, + * только для type=income, с проверкой владения и типа связанной статьи. + * + * @param CategoryRepository $category_repo + * @param object $user + * @param string $type + * @param mixed $raw_linked + * @param mixed $raw_pct + * @return array{0:int|null,1:float|null,2:string|null} [linked_id, formula_pct, error] + */ + protected function parseFormulaLink(CategoryRepository $category_repo, object $user, string $type, mixed $raw_linked, mixed $raw_pct): array + { + $has_linked = $raw_linked !== null && $raw_linked !== ''; + $has_pct = $raw_pct !== null && $raw_pct !== ''; + + if (!$has_linked && !$has_pct) { + return [null, null, null]; + } + + if ($type !== 'income') { + return [null, null, 'Формула доступна только для статей дохода']; + } + + if ($has_linked !== $has_pct) { + return [null, null, 'Укажите и связанную статью, и процент']; + } + + if (!is_numeric($raw_pct) || (float)$raw_pct < 0 || (float)$raw_pct > 100) { + return [null, null, 'Процент должен быть от 0 до 100']; + } + + $linked = $category_repo->get((int)$raw_linked); + + if (!$linked || (int)$linked->user_id !== (int)$user->id || $linked->type !== 'expense') { + return [null, null, 'Связанная статья недоступна']; + } + + return [(int)$linked->id, (float)$raw_pct, null]; + } + + /** + * Бюджетный пользователь, связанный с текущим логином Services\Auth. + * + * @return object + * @throws HTTPException|MyException + */ + protected function currentUser(): object + { + $auth_user = Auth::instance()->getUser(); + $user = (new UserRepository())->getByLogin($auth_user['login']); + + if (!$user) { + throw HTTPException::factory(404); + } + + return $user; + } + + /** + * Статья по id, принадлежащая текущему пользователю — иначе 404 (не 403, как и у счетов). + * + * @param CategoryRepository $category_repo + * @param object $user + * @param int $id + * @return object + * @throws HTTPException|MyException + */ + protected function ownCategory(CategoryRepository $category_repo, object $user, int $id): object + { + $category = $category_repo->get($id); + + if (!$category || (int)$category->user_id !== (int)$user->id) { + throw HTTPException::factory(404); + } + + return $category; + } +} diff --git a/App/Controller/IndexController.php b/App/Controller/IndexController.php index c35a381..195b3e1 100644 --- a/App/Controller/IndexController.php +++ b/App/Controller/IndexController.php @@ -9,6 +9,8 @@ namespace App\Controller; use App\Repositories\AccountRepository; +use App\Repositories\BudgetRepository; +use App\Repositories\CategoryRepository; use App\Repositories\UserRepository; use Services\Auth; use System\Classes\Controller; @@ -40,11 +42,29 @@ class IndexController extends Controller $accounts ?: [] )); + $month = (int)date('n'); + $year = (int)date('Y'); + + $category_repo = new CategoryRepository(); + $budget_repo = new BudgetRepository(); + + // Факта (реальных transactions) в проекте ещё нет — это отдельная будущая фича + // (см. .claude/memory/бюджет_текущий_план.md), поэтому fact = 0 везде; прогресс-бары + // покажут 0%, пока операции не появятся, и заполнятся сами без правки этого кода. + $tree = $user ? $category_repo->getTree($user->id) : []; + $plan_month = $user ? array_map( + static fn ($months) => $months[$month]['plan_summ'] ?? 0.0, + $budget_repo->getYear($user->id, $year) + ) : []; + return $this->render('index', [ 'user' => $auth_user, 'accounts' => $accounts ?: [], 'archived_accounts' => $archived_accounts ?: [], 'total' => $total, + 'tree' => $tree, + 'plan_month' => $plan_month, + 'month' => $month, ]); } } diff --git a/App/Repositories/BudgetRepository.php b/App/Repositories/BudgetRepository.php index 6e2e7b9..56bf07e 100644 --- a/App/Repositories/BudgetRepository.php +++ b/App/Repositories/BudgetRepository.php @@ -8,6 +8,7 @@ namespace App\Repositories; +use PDO; use System\Classes\Repository; /** @@ -15,6 +16,12 @@ use System\Classes\Repository; */ class BudgetRepository extends Repository { + /** + * @var string Таблица categories для JOIN в getYear() — вынесена в свойство (не хардкод в SQL), + * чтобы тесты могли подменить её на временную, не трогая боевую таблицу categories. + */ + protected string $categories_table = 'categories'; + /** * @param string|null $connection Имя подключения (Database::instance($connection)); null — по умолчанию */ @@ -22,4 +29,65 @@ class BudgetRepository extends Repository { parent::__construct('budgets', connection: $connection); } + + /** + * План на год пользователя, сгруппированный по статье и месяцу — для сетки на странице плана. + * + * @param int $user_id + * @param int $year + * @return array> + */ + public function getYear(int $user_id, int $year): array + { + $sql = "SELECT b.categorie_id, b.month, b.plan_summ, b.formula_pct_used + FROM $this->table b + INNER JOIN $this->categories_table c ON c.id = b.categorie_id + WHERE c.user_id = :user_id AND b.year = :year"; + + $stmt = $this->pdo->prepare($sql); + $stmt->execute(['user_id' => $user_id, 'year' => $year]); + + $result = []; + + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $result[(int)$row['categorie_id']][(int)$row['month']] = [ + 'plan_summ' => (float)$row['plan_summ'], + 'formula_pct_used' => $row['formula_pct_used'] !== null ? (float)$row['formula_pct_used'] : null, + ]; + } + + return $result; + } + + /** + * Создать или обновить план статьи на конкретный месяц (по UNIQUE categorie_id+year+month). + * + * @param int $categorie_id + * @param int $year + * @param int $month + * @param float $plan_summ + * @param string $type income|expense + * @param float|null $formula_pct_used Только для строк, посчитанных по формуле связанной статьи + * @return void + */ + public function upsert(int $categorie_id, int $year, int $month, float $plan_summ, string $type, ?float $formula_pct_used = null): void + { + $existing = $this->getItemWhere("categorie_id = $categorie_id AND year = $year AND month = $month"); + + $data = [ + 'categorie_id' => $categorie_id, + 'plan_summ' => $plan_summ, + 'year' => $year, + 'month' => $month, + 'type' => $type, + 'formula_pct_used' => $formula_pct_used, + ]; + + if ($existing) { + $data['id'] = $existing->id; + $this->update($data); + } else { + $this->create($data); + } + } } diff --git a/App/Repositories/CategoryRepository.php b/App/Repositories/CategoryRepository.php index 53e041d..06c9369 100644 --- a/App/Repositories/CategoryRepository.php +++ b/App/Repositories/CategoryRepository.php @@ -22,4 +22,80 @@ class CategoryRepository extends Repository { parent::__construct('categories', connection: $connection); } + + /** + * Активная (is_delete=0) статья с тем же названием у пользователя, в рамках type+parent_id. + * + * @param int $user_id + * @param string $type income|expense + * @param int|null $parent_id null — верхний уровень + * @param string $title + * @param int|null $exclude_id Исключить этот id (правка своего же названия — не дубль) + * @return object|false + */ + public function findActiveDuplicate(int $user_id, string $type, ?int $parent_id, string $title, ?int $exclude_id = null): mixed + { + return $this->findDuplicate($user_id, $type, $parent_id, $title, false, $exclude_id); + } + + /** + * Архивная (is_delete=1) статья с тем же названием у пользователя. + * + * @param int $user_id + * @param string $type income|expense + * @param int|null $parent_id + * @param string $title + * @param int|null $exclude_id + * @return object|false + */ + public function findArchivedDuplicate(int $user_id, string $type, ?int $parent_id, string $title, ?int $exclude_id = null): mixed + { + return $this->findDuplicate($user_id, $type, $parent_id, $title, true, $exclude_id); + } + + /** + * @param int $user_id + * @param string $type + * @param int|null $parent_id + * @param string $title + * @param bool $is_delete + * @param int|null $exclude_id + * @return object|false + */ + protected function findDuplicate(int $user_id, string $type, ?int $parent_id, string $title, bool $is_delete, ?int $exclude_id): mixed + { + $pdo = $this->pdo->pdo(); + + $where = "user_id = $user_id" + . ' AND type = ' . $pdo->quote($type) + . ' AND is_delete = ' . ($is_delete ? 1 : 0) + . ' AND title = ' . $pdo->quote($title) + . ' AND parent_id ' . ($parent_id === null ? 'IS NULL' : "= $parent_id"); + + if ($exclude_id !== null) { + $where .= " AND id != $exclude_id"; + } + + return $this->getItemWhere($where); + } + + /** + * Активные статьи пользователя, сгруппированные по parent_id (0 — верхний уровень, вместо null, + * чтобы использовать как ключ массива) — для рендера дерева на странице статей. + * + * @param int $user_id + * @return array + */ + public function getTree(int $user_id): array + { + $categories = $this->getList('*', ['user_id' => $user_id, 'is_delete' => 0], '`order`'); + $tree = []; + + foreach ($categories ?: [] as $category) { + $key = $category->parent_id === null ? 0 : (int)$category->parent_id; + $tree[$key][] = $category; + } + + return $tree; + } } diff --git a/App/media/css/dashboard.css b/App/media/css/dashboard.css index 4183b8d..662efeb 100644 --- a/App/media/css/dashboard.css +++ b/App/media/css/dashboard.css @@ -208,6 +208,73 @@ main { .room-menu-list a:hover { background: var(--surface-3); color: var(--ink); } .room-menu-list a.danger { color: var(--red); } +.room-menu-move { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.6rem; + border-radius: 8px; + font-size: 0.82rem; + color: var(--ink-soft); + cursor: pointer; +} + +.room-menu-move:hover { background: var(--surface-3); color: var(--ink); } + +.room-menu-move select { + flex: 1; + background: transparent; + border: none; + color: inherit; + font-size: inherit; + padding: 0; +} + +/* ---------- бюджет по категориям — компактный список (дашборд, макет "Бюджет — обзор") ---------- */ + +.budget-list { + background: var(--surface); + border-radius: 16px; + box-shadow: 0 2px 10px var(--shadow); + overflow: hidden; + margin-bottom: 1.5rem; +} + +.budget-row { + padding: 0.65rem 1.1rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.budget-row + .budget-row { border-top: 1px solid rgba(255, 255, 255, 0.06); } + +.budget-group-head { + display: flex; + align-items: baseline; + justify-content: space-between; + padding: 0.55rem 1.1rem; + background: var(--surface-2); +} + +.budget-group-head + .budget-row { border-top: none; } +.budget-row + .budget-group-head { border-top: 1px solid rgba(255, 255, 255, 0.06); } + +.budget-group-name { + font-size: 0.74rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--card-accent, var(--ink-soft)); +} + +.budget-group-sum { font-size: 0.72rem; color: var(--muted); } + +.budget-name { font-size: 0.84rem; font-weight: 600; } + +.budget-plan-amount { font-size: 0.84rem; font-weight: 600; color: var(--ink-soft); white-space: nowrap; } + /* пустое состояние + кнопка добавить */ .empty-state { @@ -246,6 +313,163 @@ main { .archive-row:last-child { border-bottom: none; } +/* ---------- список статей (категории) ---------- */ + +.category-list { + display: flex; + flex-direction: column; + gap: 0.4rem; + margin-bottom: 1.5rem; +} + +.category-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + background: var(--surface); + border-radius: 14px; + padding: 0.7rem 0.9rem; +} + +.category-row-main { + display: flex; + align-items: center; + gap: 0.6rem; + min-width: 0; +} + +.category-group { font-weight: 600; } + +.category-sub { + margin-left: 1.6rem; + background: var(--surface-2); +} + +/* ---------- план на год: карточки (комп/планшет) + аккордeон (телефон) ---------- + Своя раскладка на каждую ширину, ни одна не даёт горизонтальный скролл (см. обсуждение + дизайна в .claude/memory/бюджет_текущий_план.md): + - комп: карточка статьи — все 12 месяцев в один ряд; + - планшет: та же карточка, но сеткой 6×2 — через container query, ничего не жмётся; + - телефон: карточки-таблицы пропадают совсем, вместо них — раскрывающиеся аккордeоны + (тап по статье показывает все 12 месяцев в два столбика). + .budget-zoom — container для container query (не завязано на реальный viewport, а на + ширину самого блока — так раскладка одинаково работает и в узком, и в широком main). */ + +.budget-zoom { container-type: inline-size; } + +.budget-plan-group { margin-bottom: 0.9rem; } + +.budget-cards-wrap { display: block; } +.budget-accordion { display: none; } + +.budget-cards { display: flex; flex-direction: column; gap: 0.5rem; } + +.budget-card { + background: var(--surface); + border-radius: 14px; + padding: 0.75rem 0.85rem; + box-shadow: 0 2px 8px var(--shadow); +} + +.budget-card-name { font-size: 0.84rem; font-weight: 600; margin-bottom: 0.5rem; } + +.budget-months-inline { display: flex; gap: 0.35rem; } + +.budget-cell { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.15rem; + background: var(--surface-2); + border-radius: 8px; + padding: 0.35rem 0.15rem; + min-width: 0; +} + +.budget-cell-label { font-size: 0.6rem; color: var(--muted); text-transform: uppercase; } + +.plan-input { + width: 100%; + background: transparent; + border: none; + color: var(--ink); + text-align: center; + font-size: 0.72rem; + font-family: inherit; + padding: 0; +} + +.plan-input:focus-visible { outline: 1px solid var(--teal); border-radius: 3px; } +.plan-input::-webkit-outer-spin-button, .plan-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } +.plan-input { -moz-appearance: textfield; } + +/* планшет: та же карточка, но 12 месяцев — сеткой 6×2, не в один сжатый ряд */ +@container (max-width: 740px) { + .budget-months-inline { display: grid; grid-template-columns: repeat(6, 1fr); gap: 0.4rem; } +} + +/* телефон: карточки меняются на другой компонент — раскрывающиеся аккордeоны */ +@container (max-width: 420px) { + .budget-cards-wrap { display: none; } + .budget-accordion { display: block; } +} + +.budget-acc-card { + background: var(--surface); + border-radius: 12px; + margin-bottom: 0.5rem; + overflow: hidden; +} + +.budget-acc-card summary { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.7rem 0.9rem; + font-size: 0.84rem; + font-weight: 600; + cursor: pointer; + list-style: none; +} + +.budget-acc-name { flex: 1; min-width: 0; } + +.budget-acc-card summary::-webkit-details-marker { display: none; } +.budget-acc-card summary::after { content: "▾"; color: var(--muted); font-size: 0.7rem; margin-left: 0.3rem; } +.budget-acc-card details[open] summary::after { content: "▴"; } + +.budget-acc-total { font-size: 0.76rem; color: var(--muted); font-weight: 500; white-space: nowrap; } + +.budget-acc-caption { + padding: 0 0.9rem; + margin: 0.5rem 0 0.3rem; + font-size: 0.7rem; + color: var(--muted); +} + +.budget-acc-body { + padding: 0 0.9rem 0.8rem; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.4rem 0.7rem; +} + +.budget-acc-month { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.4rem; + background: var(--surface-2); + border-radius: 8px; + padding: 0.3rem 0.5rem; +} + +.budget-acc-month span { font-size: 0.68rem; color: var(--muted); } + +.budget-acc-month .plan-input { width: 4rem; flex-shrink: 0; text-align: right; } + /* ---------- нижний dock-навбар ---------- */ .dock { @@ -254,14 +478,15 @@ main { bottom: 1.1rem; transform: translateX(-50%); display: flex; - align-items: center; - gap: 0.15rem; - background: rgba(23, 23, 29, 0.92); - backdrop-filter: blur(12px); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 999px; - padding: 0.4rem; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + align-items: stretch; + gap: 0.2rem; + background: rgba(23, 23, 29, 0.86); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 26px; + padding: 0.5rem; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.04); z-index: 30; max-width: calc(100vw - 2rem); overflow-x: auto; @@ -269,19 +494,30 @@ main { .dock-item { display: flex; - flex-direction: column; align-items: center; - gap: 0.15rem; - padding: 0.45rem 0.9rem; - border-radius: 999px; - font-size: 0.68rem; + justify-content: center; + padding: 0.7rem; + border-radius: 18px; color: var(--muted); - white-space: nowrap; + text-decoration: none; + flex-shrink: 0; + transition: color 0.15s ease, background 0.15s ease; } -.dock-item.active { background: var(--ink); color: #0b0b0f; font-weight: 700; } +.dock-item .ico { width: 1.35rem; height: 1.35rem; transition: transform 0.15s ease; } + +a.dock-item:hover { color: var(--ink-soft); } + +.dock-item.active { + color: var(--teal); + background: color-mix(in srgb, var(--teal) 16%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--teal) 35%, transparent); +} + +.dock-item.active .ico { transform: translateY(-1px); } @media (max-width: 640px) { .top-row { flex-direction: column; } .balance { font-size: 2.4rem; } + .dock-item { padding: 0.5rem 0.65rem; } } diff --git a/App/media/css/fonts.css b/App/media/css/fonts.css new file mode 100644 index 0000000..58b7361 --- /dev/null +++ b/App/media/css/fonts.css @@ -0,0 +1,314 @@ +@font-face { + font-family: 'SF Pro Display'; + font-style: normal; + font-weight: 100; + src: url('../fonts/sf-pro-display_ultralight.woff2') format('woff2'), url('../fonts/sf-pro-display_ultralight.woff') format('woff'), url('../fonts/sf-pro-display_ultralight.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Display'; + font-style: italic; + font-weight: 100; + src: url('../fonts/sf-pro-display_ultralight-italic.woff2') format('woff2'), url('../fonts/sf-pro-display_ultralight-italic.woff') format('woff'), url('../fonts/sf-pro-display_ultralight-italic.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Display'; + font-style: normal; + font-weight: 200; + src: url('../fonts/sf-pro-display_thin.woff2') format('woff2'), url('../fonts/sf-pro-display_thin.woff') format('woff'), url('../fonts/sf-pro-display_thin.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Display'; + font-style: italic; + font-weight: 200; + src: url('../fonts/sf-pro-display_thin-italic.woff2') format('woff2'), url('../fonts/sf-pro-display_thin-italic.woff') format('woff'), url('../fonts/sf-pro-display_thin-italic.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Display'; + font-style: normal; + font-weight: 300; + src: url('../fonts/sf-pro-display_light.woff2') format('woff2'), url('../fonts/sf-pro-display_light.woff') format('woff'), url('../fonts/sf-pro-display_light.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Display'; + font-style: italic; + font-weight: 300; + src: url('../fonts/sf-pro-display_light-italic.woff2') format('woff2'), url('../fonts/sf-pro-display_light-italic.woff') format('woff'), url('../fonts/sf-pro-display_light-italic.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Display'; + font-style: normal; + font-weight: 400; + src: url('../fonts/sf-pro-display_regular.woff2') format('woff2'), url('../fonts/sf-pro-display_regular.woff') format('woff'), url('../fonts/sf-pro-display_regular.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Display'; + font-style: italic; + font-weight: 400; + src: url('../fonts/sf-pro-display_regular-italic.woff2') format('woff2'), url('../fonts/sf-pro-display_regular-italic.woff') format('woff'), url('../fonts/sf-pro-display_regular-italic.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Display'; + font-style: normal; + font-weight: 500; + src: url('../fonts/sf-pro-display_medium.woff2') format('woff2'), url('../fonts/sf-pro-display_medium.woff') format('woff'), url('../fonts/sf-pro-display_medium.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Display'; + font-style: italic; + font-weight: 500; + src: url('../fonts/sf-pro-display_medium-italic.woff2') format('woff2'), url('../fonts/sf-pro-display_medium-italic.woff') format('woff'), url('../fonts/sf-pro-display_medium-italic.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Display'; + font-style: normal; + font-weight: 600; + src: url('../fonts/sf-pro-display_semibold.woff2') format('woff2'), url('../fonts/sf-pro-display_semibold.woff') format('woff'), url('../fonts/sf-pro-display_semibold.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Display'; + font-style: italic; + font-weight: 600; + src: url('../fonts/sf-pro-display_semibold-italic.woff2') format('woff2'), url('../fonts/sf-pro-display_semibold-italic.woff') format('woff'), url('../fonts/sf-pro-display_semibold-italic.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Display'; + font-style: normal; + font-weight: 700; + src: url('../fonts/sf-pro-display_bold.woff2') format('woff2'), url('../fonts/sf-pro-display_bold.woff') format('woff'), url('../fonts/sf-pro-display_bold.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Display'; + font-style: italic; + font-weight: 700; + src: url('../fonts/sf-pro-display_bold-italic.woff2') format('woff2'), url('../fonts/sf-pro-display_bold-italic.woff') format('woff'), url('../fonts/sf-pro-display_bold-italic.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Display'; + font-style: normal; + font-weight: 800; + src: url('../fonts/sf-pro-display_heavy.woff2') format('woff2'), url('../fonts/sf-pro-display_heavy.woff') format('woff'), url('../fonts/sf-pro-display_heavy.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Display'; + font-style: italic; + font-weight: 800; + src: url('../fonts/sf-pro-display_heavy-italic.woff2') format('woff2'), url('../fonts/sf-pro-display_heavy-italic.woff') format('woff'), url('../fonts/sf-pro-display_heavy-italic.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Display'; + font-style: normal; + font-weight: 900; + src: url('../fonts/sf-pro-display_black.woff2') format('woff2'), url('../fonts/sf-pro-display_black.woff') format('woff'), url('../fonts/sf-pro-display_black.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Display'; + font-style: italic; + font-weight: 900; + src: url('../fonts/sf-pro-display_black-italic.woff2') format('woff2'), url('../fonts/sf-pro-display_black-italic.woff') format('woff'), url('../fonts/sf-pro-display_black-italic.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Text'; + font-style: normal; + font-weight: 100; + src: url('../fonts/sf-pro-text_ultralight.woff2') format('woff2'), url('../fonts/sf-pro-text_ultralight.woff') format('woff'), url('../fonts/sf-pro-text_ultralight.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Text'; + font-style: italic; + font-weight: 100; + src: url('../fonts/sf-pro-text_ultralight-italic.woff2') format('woff2'), url('../fonts/sf-pro-text_ultralight-italic.woff') format('woff'), url('../fonts/sf-pro-text_ultralight-italic.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Text'; + font-style: normal; + font-weight: 200; + src: url('../fonts/sf-pro-text_thin.woff2') format('woff2'), url('../fonts/sf-pro-text_thin.woff') format('woff'), url('../fonts/sf-pro-text_thin.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Text'; + font-style: italic; + font-weight: 200; + src: url('../fonts/sf-pro-text_thin-italic.woff2') format('woff2'), url('../fonts/sf-pro-text_thin-italic.woff') format('woff'), url('../fonts/sf-pro-text_thin-italic.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Text'; + font-style: normal; + font-weight: 300; + src: url('../fonts/sf-pro-text_light.woff2') format('woff2'), url('../fonts/sf-pro-text_light.woff') format('woff'), url('../fonts/sf-pro-text_light.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Text'; + font-style: italic; + font-weight: 300; + src: url('../fonts/sf-pro-text_light-italic.woff2') format('woff2'), url('../fonts/sf-pro-text_light-italic.woff') format('woff'), url('../fonts/sf-pro-text_light-italic.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Text'; + font-style: normal; + font-weight: 400; + src: url('../fonts/sf-pro-text_regular.woff2') format('woff2'), url('../fonts/sf-pro-text_regular.woff') format('woff'), url('../fonts/sf-pro-text_regular.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Text'; + font-style: italic; + font-weight: 400; + src: url('../fonts/sf-pro-text_regular-italic.woff2') format('woff2'), url('../fonts/sf-pro-text_regular-italic.woff') format('woff'), url('../fonts/sf-pro-text_regular-italic.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Text'; + font-style: normal; + font-weight: 500; + src: url('../fonts/sf-pro-text_medium.woff2') format('woff2'), url('../fonts/sf-pro-text_medium.woff') format('woff'), url('../fonts/sf-pro-text_medium.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Text'; + font-style: italic; + font-weight: 500; + src: url('../fonts/sf-pro-text_medium-italic.woff2') format('woff2'), url('../fonts/sf-pro-text_medium-italic.woff') format('woff'), url('../fonts/sf-pro-text_medium-italic.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Text'; + font-style: normal; + font-weight: 600; + src: url('../fonts/sf-pro-text_semibold.woff2') format('woff2'), url('../fonts/sf-pro-text_semibold.woff') format('woff'), url('../fonts/sf-pro-text_semibold.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Text'; + font-style: italic; + font-weight: 600; + src: url('../fonts/sf-pro-text_semibold-italic.woff2') format('woff2'), url('../fonts/sf-pro-text_semibold-italic.woff') format('woff'), url('../fonts/sf-pro-text_semibold-italic.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Text'; + font-style: normal; + font-weight: 700; + src: url('../fonts/sf-pro-text_bold.woff2') format('woff2'), url('../fonts/sf-pro-text_bold.woff') format('woff'), url('../fonts/sf-pro-text_bold.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Text'; + font-style: italic; + font-weight: 700; + src: url('../fonts/sf-pro-text_bold-italic.woff2') format('woff2'), url('../fonts/sf-pro-text_bold-italic.woff') format('woff'), url('../fonts/sf-pro-text_bold-italic.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Text'; + font-style: normal; + font-weight: 800; + src: url('../fonts/sf-pro-text_heavy.woff2') format('woff2'), url('../fonts/sf-pro-text_heavy.woff') format('woff'), url('../fonts/sf-pro-text_heavy.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Text'; + font-style: italic; + font-weight: 800; + src: url('../fonts/sf-pro-text_heavy-italic.woff2') format('woff2'), url('../fonts/sf-pro-text_heavy-italic.woff') format('woff'), url('../fonts/sf-pro-text_heavy-italic.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Text'; + font-style: normal; + font-weight: 900; + src: url('../fonts/sf-pro-text_black.woff2') format('woff2'), url('../fonts/sf-pro-text_black.woff') format('woff'), url('../fonts/sf-pro-text_black.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Text'; + font-style: italic; + font-weight: 900; + src: url('../fonts/sf-pro-text_black-italic.woff2') format('woff2'), url('../fonts/sf-pro-text_black-italic.woff') format('woff'), url('../fonts/sf-pro-text_black-italic.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Icons'; + font-style: normal; + font-weight: 100; + src: url('../fonts/sf-pro-icons_ultralight.woff2') format('woff2'), url('../fonts/sf-pro-icons_ultralight.woff') format('woff'), url('../fonts/sf-pro-icons_ultralight.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Icons'; + font-style: normal; + font-weight: 200; + src: url('../fonts/sf-pro-icons_thin.woff2') format('woff2'), url('../fonts/sf-pro-icons_thin.woff') format('woff'), url('../fonts/sf-pro-icons_thin.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Icons'; + font-style: normal; + font-weight: 300; + src: url('../fonts/sf-pro-icons_light.woff2') format('woff2'), url('../fonts/sf-pro-icons_light.woff') format('woff'), url('../fonts/sf-pro-icons_light.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Icons'; + font-style: normal; + font-weight: 400; + src: url('../fonts/sf-pro-icons_regular.woff2') format('woff2'), url('../fonts/sf-pro-icons_regular.woff') format('woff'), url('../fonts/sf-pro-icons_regular.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Icons'; + font-style: normal; + font-weight: 500; + src: url('../fonts/sf-pro-icons_medium.woff2') format('woff2'), url('../fonts/sf-pro-icons_medium.woff') format('woff'), url('../fonts/sf-pro-icons_medium.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Icons'; + font-style: normal; + font-weight: 600; + src: url('../fonts/sf-pro-icons_semibold.woff2') format('woff2'), url('../fonts/sf-pro-icons_semibold.woff') format('woff'), url('../fonts/sf-pro-icons_semibold.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Icons'; + font-style: normal; + font-weight: 700; + src: url('../fonts/sf-pro-icons_bold.woff2') format('woff2'), url('../fonts/sf-pro-icons_bold.woff') format('woff'), url('../fonts/sf-pro-icons_bold.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Icons'; + font-style: normal; + font-weight: 800; + src: url('../fonts/sf-pro-icons_heavy.woff2') format('woff2'), url('../fonts/sf-pro-icons_heavy.woff') format('woff'), url('../fonts/sf-pro-icons_heavy.ttf') format('truetype'); +} + +@font-face { + font-family: 'SF Pro Icons'; + font-style: normal; + font-weight: 900; + src: url('../fonts/sf-pro-icons_black.woff2') format('woff2'), url('../fonts/sf-pro-icons_black.woff') format('woff'), url('../fonts/sf-pro-icons_black.ttf') format('truetype'); +} diff --git a/App/media/fonts/sf-pro-display_bold.woff2 b/App/media/fonts/sf-pro-display_bold.woff2 new file mode 100644 index 0000000..e4aa815 Binary files /dev/null and b/App/media/fonts/sf-pro-display_bold.woff2 differ diff --git a/App/media/fonts/sf-pro-display_regular.woff2 b/App/media/fonts/sf-pro-display_regular.woff2 new file mode 100644 index 0000000..d2c8944 Binary files /dev/null and b/App/media/fonts/sf-pro-display_regular.woff2 differ diff --git a/App/media/fonts/sf-pro-display_semibold.woff2 b/App/media/fonts/sf-pro-display_semibold.woff2 new file mode 100644 index 0000000..4928036 Binary files /dev/null and b/App/media/fonts/sf-pro-display_semibold.woff2 differ diff --git a/App/media/fonts/sf-pro-icons_light.woff2 b/App/media/fonts/sf-pro-icons_light.woff2 new file mode 100644 index 0000000..fd5fd0f Binary files /dev/null and b/App/media/fonts/sf-pro-icons_light.woff2 differ diff --git a/App/media/fonts/sf-pro-text_light.woff2 b/App/media/fonts/sf-pro-text_light.woff2 new file mode 100644 index 0000000..2e1e10e Binary files /dev/null and b/App/media/fonts/sf-pro-text_light.woff2 differ diff --git a/App/media/fonts/sf-pro-text_regular.woff2 b/App/media/fonts/sf-pro-text_regular.woff2 new file mode 100644 index 0000000..292b7cc Binary files /dev/null and b/App/media/fonts/sf-pro-text_regular.woff2 differ diff --git a/App/media/fonts/sf-pro-text_semibold.woff2 b/App/media/fonts/sf-pro-text_semibold.woff2 new file mode 100644 index 0000000..46d44c9 Binary files /dev/null and b/App/media/fonts/sf-pro-text_semibold.woff2 differ diff --git a/App/media/js/budgets.js b/App/media/js/budgets.js new file mode 100644 index 0000000..b8d28df --- /dev/null +++ b/App/media/js/budgets.js @@ -0,0 +1,51 @@ +$(function () { + var $form = $('#budgets-form'); + + if (!$form.length) { + return; + } + + var $error = $('#budgets-form-error'); + var csrf = $form.find('[name=csrf_token]').val(); + var year = $form.find('[name=year]').val(); + + // Кнопки «Сохранить» больше нет — каждая ячейка сохраняется сама по себе при потере + // фокуса (change), формульная связанная статья пересчитывается на сервере и подставляется + // в свою (readonly) ячейку сразу, без перезагрузки страницы. + $(document).on('submit', '#budgets-form', function (e) { + e.preventDefault(); + }); + + $(document).on('change', '.plan-input:not([readonly])', function () { + var $input = $(this); + var match = ($input.attr('name') || '').match(/^plan\[(\d+)\]\[(\d+)\]$/); + + if (!match) { + return; + } + + $error.hide().text(''); + + var data = {csrf_token: csrf, year: year}; + data['plan[' + match[1] + '][' + match[2] + ']'] = $input.val(); + + $.post($form.attr('action'), data) + .done(function (response) { + if (!response.recalculated) { + return; + } + + $.each(response.recalculated, function (categorieId, months) { + $.each(months, function (month, summ) { + $('input[name="plan[' + categorieId + '][' + month + ']"]').val(summ); + }); + }); + }) + .fail(function (xhr) { + var errors = xhr.responseJSON && xhr.responseJSON.errors; + var message = errors ? Object.values(errors).join(' ') : 'Не удалось сохранить значение'; + + $error.text(message).show(); + }); + }); +}); diff --git a/App/media/js/categories.js b/App/media/js/categories.js new file mode 100644 index 0000000..3050a52 --- /dev/null +++ b/App/media/js/categories.js @@ -0,0 +1,179 @@ +$(function () { + var modalEl = document.getElementById('category-modal'); + + if (!modalEl) { + return; + } + + var modal = new bootstrap.Modal(modalEl); + var $form = $('#category-form'); + var $error = $('#category-form-error'); + var archiveEl = document.getElementById('category-archive-modal'); + var archiveModal = archiveEl ? new bootstrap.Modal(archiveEl) : null; + + function filterParentOptions(type) { + $('#category-parent option[data-type]').each(function () { + var match = $(this).data('type') === type; + $(this).prop('hidden', !match).prop('disabled', !match); + }); + + var $selected = $('#category-parent option:selected'); + if ($selected.data('type') && $selected.data('type') !== type) { + $('#category-parent').val(''); + } + } + + function toggleFormulaGroup(type) { + $('#category-formula-group').toggle(type === 'income'); + } + + function resetForm() { + $form[0].reset(); + $form.find('[name=id]').val(''); + $error.hide().text(''); + } + + $(document).on('click', '.js-category-add', function () { + var type = $(this).data('type'); + + resetForm(); + $form.data('mode', 'create').attr('action', '/categories/create'); + $('#category-modal-title').text(type === 'income' ? 'Новая статья дохода' : 'Новая статья расхода'); + $('#category-type').val(type); + filterParentOptions(type); + toggleFormulaGroup(type); + modal.show(); + }); + + $(document).on('click', '.js-category-edit', function (e) { + e.preventDefault(); + + var $row = $(this).closest('.category-row'); + var type = $row.data('type'); + + resetForm(); + $form.data('mode', 'edit').attr('action', '/categories/edit/' + $row.data('id')); + $form.find('[name=title]').val($row.data('title')); + $('#category-type').val(type); + $('#category-modal-title').text('Редактировать статью'); + filterParentOptions(type); + $('#category-parent').val($row.data('parent_id') || ''); + toggleFormulaGroup(type); + $('#category-linked').val($row.data('linked') || ''); + $form.find('[name=formula_pct]').val($row.data('pct') || ''); + modal.show(); + }); + + $(document).on('click', '.js-category-archive-open', function () { + if (archiveModal) { + archiveModal.show(); + } + }); + + $(document).on('click', '.js-category-restore', function () { + var $row = $(this).closest('.archive-row'); + var csrf = $form.find('[name=csrf_token]').val(); + + $.post('/categories/restore/' + $row.data('id'), {csrf_token: csrf}) + .done(function () { + window.location.reload(); + }) + .fail(function () { + window.alert('Не удалось восстановить статью'); + }); + }); + + $(document).on('click', '.js-category-delete', function (e) { + e.preventDefault(); + + var $row = $(this).closest('.category-row'); + var title = $row.data('title'); + var csrf = $form.find('[name=csrf_token]').val(); + + if (!window.confirm('Архивировать статью «' + title + '»?')) { + return; + } + + $.post('/categories/delete/' + $row.data('id'), {csrf_token: csrf}) + .done(function () { + window.location.reload(); + }) + .fail(function () { + window.alert('Не удалось архивировать статью'); + }); + }); + + $(document).on('submit', '#category-form', function (e) { + e.preventDefault(); + $error.hide().text(''); + + $.post($form.attr('action'), $form.serialize()) + .done(function () { + window.location.reload(); + }) + .fail(function (xhr) { + var data = xhr.responseJSON; + + if (data && data.archived) { + if (window.confirm('Статья «' + data.archived.title + '» уже есть в архиве. Восстановить её вместо создания новой?')) { + var csrf = $form.find('[name=csrf_token]').val(); + + $.post('/categories/restore/' + data.archived.id, {csrf_token: csrf}) + .done(function () { + window.location.reload(); + }) + .fail(function () { + window.alert('Не удалось восстановить статью'); + }); + } + return; + } + + var errors = data && data.errors; + var message = errors ? Object.values(errors).join(' ') : 'Не удалось сохранить статью'; + + $error.text(message).show(); + }); + }); + + $(document).on('change', '.js-category-move', function () { + var $select = $(this); + var newParent = $select.val(); + + if (newParent === '') { + return; + } + + var $row = $select.closest('.category-row'); + var csrf = $form.find('[name=csrf_token]').val(); + + $.post('/categories/edit/' + $row.data('id'), { + csrf_token: csrf, + title: $row.data('title'), + type: $row.data('type'), + parent_id: newParent, + linked_expense_categorie_id: $row.data('linked') || '', + formula_pct: $row.data('pct') || '' + }) + .done(function () { + window.location.reload(); + }) + .fail(function (xhr) { + var errors = xhr.responseJSON && xhr.responseJSON.errors; + var message = errors ? Object.values(errors).join(' ') : 'Не удалось перенести статью'; + + window.alert(message); + $select.val(''); + }); + }); + + $(document).on('click', '.room-menu-btn', function () { + $(this).closest('.room-menu').toggleClass('open'); + }); + + $(document).on('click', function (e) { + if (!$(e.target).closest('.room-menu').length) { + $('.room-menu.open').removeClass('open'); + } + }); +}); diff --git a/App/view/Budgets/budgets.html b/App/view/Budgets/budgets.html new file mode 100644 index 0000000..9aa6d53 --- /dev/null +++ b/App/view/Budgets/budgets.html @@ -0,0 +1,150 @@ + $tree Активные статьи пользователя, сгруппированные по parent_id + * @var object[] $groups Верхнеуровневые группы (tree[0]) + * @var array $plan [categorie_id => [month => ['plan_summ'=>float,'formula_pct_used'=>?float]]] + * @var int $year + */ + +use System\Classes\CSRF; + +$months = [ + 1 => 'Янв', 2 => 'Фев', 3 => 'Мар', 4 => 'Апр', 5 => 'Май', 6 => 'Июн', + 7 => 'Июл', 8 => 'Авг', 9 => 'Сен', 10 => 'Окт', 11 => 'Ноя', 12 => 'Дек', +]; + +$sections = [ + 'income' => 'Доходы', + 'expense' => 'Расходы', +]; + +// Категории, чей план считается автоматически по формуле связанной income-статьи — +// у них своя ячейка readonly (см. CategoriesController::parseFormulaLink()). +$formula_targets = []; +foreach ($tree as $items) { + foreach ($items as $c) { + if ($c->linked_expense_categorie_id) { + $formula_targets[(int)$c->linked_expense_categorie_id] = true; + } + } +} + +/** + * Одна ячейка ввода — общая и для карточек (комп/планшет), и для аккордeона (телефон), + * чтобы имя поля/значение/readonly не разъезжались между двумя разметками одних данных. + */ +$planInput = static function (int $categorie_id, int $month, array $plan, bool $readonly) { + $value = $plan[$categorie_id][$month]['plan_summ'] ?? ''; + echo ''; +}; + +$this->setScript('budgets.js'); +?> + +
+
+
План бюджета на год
+
+
+ ← +
+ → +
+
+ +
+ + + + $label): ?> + + + $g->type === $type); ?> + + +
Нет статей — сначала добавьте их на странице «Статьи»
+ +
+ + +
+ + id] ?? []; ?> +
+
+ title, ENT_QUOTES, 'UTF-8') ?> +
+
+ + id]); ?> +
+ +
title, ENT_QUOTES, 'UTF-8') ?>
+ +
+ $mlabel): ?> +
+
+ id, $m, $plan, $is_target); ?> +
+ +
+
+ +
+
+ +
+ + +
+ + id] ?? []; + $rows = $children !== [] ? $children : [$group]; + $year_sum = 0.0; + foreach ($rows as $row_category) { + foreach ($months as $m => $_) { + $year_sum += (float)($plan[$row_category->id][$m]['plan_summ'] ?? 0); + } + } + ?> +
+ + title, ENT_QUOTES, 'UTF-8') ?> + ₽/год + + + id]); ?> + +
title, ENT_QUOTES, 'UTF-8') ?>
+ +
+ $mlabel): ?> +
+ + id, $m, $plan, $is_target); ?> +
+ +
+ +
+ +
+ +
+ + + + +
diff --git a/App/view/Categories/categories.html b/App/view/Categories/categories.html new file mode 100644 index 0000000..3217014 --- /dev/null +++ b/App/view/Categories/categories.html @@ -0,0 +1,219 @@ + $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'); +?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + $label): ?> + + + $g->type === $type); ?> + + +
Пока нет ни одной статьи — нажмите «+», чтобы добавить первую
+ +
+ +
+
+ + title, ENT_QUOTES, 'UTF-8') ?> +
+ +
+ + id] ?? [] as $sub): ?> +
+
+ title, ENT_QUOTES, 'UTF-8') ?> + linked_expense_categorie_id): ?> + → формула formula_pct, ENT_QUOTES, 'UTF-8') ?>% + +
+
+ +
+ Редактировать + + Архивировать +
+
+
+ + +
+ + + + + + + diff --git a/App/view/Index/index.html b/App/view/Index/index.html index dc245aa..80b992b 100644 --- a/App/view/Index/index.html +++ b/App/view/Index/index.html @@ -145,6 +145,67 @@ $this->setScript('accounts.js'); + 'январь', 2 => 'февраль', 3 => 'март', 4 => 'апрель', 5 => 'май', 6 => 'июнь', + 7 => 'июль', 8 => 'август', 9 => 'сентябрь', 10 => 'октябрь', 11 => 'ноябрь', 12 => 'декабрь', +]; + +$groups = $tree[0] ?? []; + +// Строки плана для секции типа $type (income|expense): группа с подкатегориями — заголовком +// группы + строками по ним; группа без подкатегорий — одной строкой на саму группу (тот же +// приём, что и в App/view/Budgets/budgets.html). Показываем только план — факта (реальных +// transactions) в проекте ещё нет, а прогресс-бар, который всегда 0%, только запутывает +// (см. фидбек пользователя); добавим план/факт, когда появятся операции. Замыкание, не +// именованная функция — шаблон может быть include'ится не один раз за процесс (например +// в тестах), именованная функция дала бы "Cannot redeclare". +$renderBudgetList = static function (array $groups, array $tree, array $plan_month, string $type): void { + $type_groups = array_filter($groups, static fn ($g) => $g->type === $type); + + if ($type_groups === []) { + echo '
Нет статей — добавьте их на странице «Статьи»
'; + return; + } + + echo '
'; + + foreach ($type_groups as $group) { + $children = $tree[$group->id] ?? []; + + // Заголовок-плашка — ВСЕГДА, и у настоящей группы, и у самостоятельной статьи (тогда + // плашка — единственная строка на неё, показывает сразу и название, и сумму). Без этого + // самостоятельная статья визуально не отличалась от рядового члена предыдущей группы + // (см. фидбек пользователя — тонкой разницы фона недостаточно, нужна однозначная граница). + $head_plan = $children !== [] + ? array_sum(array_map(static fn ($c) => $plan_month[$c->id] ?? 0.0, $children)) + : ($plan_month[$group->id] ?? 0.0); + + echo '
' + . '' . htmlspecialchars($group->title, ENT_QUOTES, 'UTF-8') . '' + . '' . number_format($head_plan, 0, ',', ' ') . ' ₽' + . '
'; + + foreach ($children as $category) { + $plan = $plan_month[$category->id] ?? 0.0; + + echo '
' + . '
' . htmlspecialchars($category->title, ENT_QUOTES, 'UTF-8') . '
' + . '
' . number_format($plan, 0, ',', ' ') . ' ₽
' + . '
'; + } + } + + echo '
'; +}; +?> + + + + + + +