From cff643ebc5331f0cf7f744f1593fe6cabc01b019 Mon Sep 17 00:00:00 2001 From: Egor Isaev Date: Wed, 12 Aug 2026 17:08:46 +0300 Subject: [PATCH] dev --- .../feedback_no_premature_implementation.md | 20 ++ .claude/memory/бюджет_текущий_план.md | 29 ++- App/Classes/Currency.php | 24 ++ App/Controller/AccountsController.php | 219 ++++++++++++++++++ App/Controller/IndexController.php | 12 +- App/media/css/dashboard.css | 13 ++ App/media/js/accounts.js | 126 ++++++++++ App/view/Index/index.html | 148 +++++++++++- CLAUDE.md | 14 +- 9 files changed, 588 insertions(+), 17 deletions(-) create mode 100644 App/Classes/Currency.php create mode 100644 App/Controller/AccountsController.php create mode 100644 App/media/js/accounts.js diff --git a/.claude/memory/feedback_no_premature_implementation.md b/.claude/memory/feedback_no_premature_implementation.md index 001a539..86b2d17 100644 --- a/.claude/memory/feedback_no_premature_implementation.md +++ b/.claude/memory/feedback_no_premature_implementation.md @@ -22,3 +22,23 @@ metadata: реализацию (не путать с уже завершённым слоем БД/фреймворка — там правки кода это часть текущей явно запрошенной задачи, речь именно про фичу «Бюджет», которая всё ещё на стадии обсуждения). Если непонятно, закончилось ли обсуждение — переспросить прямо, не действовать по умолчанию. + +**Второй кейс (2026-08-12, статьи/бюджет по месяцам):** план (`CategoriesController`/`BudgetsController` +и т.д.) был согласован через `ExitPlanMode` («User has approved your plan. You can now start coding.»), +но следующим сообщением пользователь попросил только «обнови память нашего разговора запомни где +закончали» — я вместо этого сразу начал писать код (`CategoryRepository::findActiveDuplicate()` и +т.п.). Пользователь резко возмутился («нафига ты программируешь», «бля нухуя»). Затем при откате я +тоже полез редактировать файл, хотя просили только «память обнови просто и всё». + +**Why:** Одобрение плана через `ExitPlanMode` — это разрешение начать реализацию *в тот момент*, а не +постоянный зелёный свет, который переживает любое следующее сообщение. Если следующее сообщение +пользователя — конкретная более узкая просьба («обнови память», «просто»), она отменяет собой более +широкий контекст одобренного плана для этого хода; план никуда не делся и остаётся выполнимым позже, +но не сейчас и не как «раз уж всё равно тут». + +**How to apply:** Каждое сообщение выполнять буквально по объёму, даже если чуть раньше был одобрен +большой план и по логике «продолжения работы» напрашивается начать кодить. Если просьба — «просто +обнови память и всё» (особенно с усилением «просто»/«и всё»/раздражённым тоном) — трогать **только** +файлы памяти (`.claude/memory/*`), не открывать/не редактировать код вообще, включая «откат» +случайно сделанных правок — откат тоже правка кода, о которой не просили; если код уже случайно +изменён, спросить, откатывать ли, а не делать это самостоятельно. diff --git a/.claude/memory/бюджет_текущий_план.md b/.claude/memory/бюджет_текущий_план.md index fa569d7..cac362b 100644 --- a/.claude/memory/бюджет_текущий_план.md +++ b/.claude/memory/бюджет_текущий_план.md @@ -453,11 +453,13 @@ ALTER TABLE categories ADD CONSTRAINT fk_categories_linked_expense транзакции. **Ставка вводится вручную, без интеграции с банком** (это личный трекер, не агрегатор счетов) — -план, ЕЩЁ НЕ РЕАЛИЗОВАНО, возвращаемся к нему, когда дойдём до формы счёта: +план, часть реализована (см. ниже): - **При создании копилки** (форма счёта, `type_acc = savings`) — доп. поле «Ставка, % годовых». Создаёт первую строку `account_rates`: `valid_from` = дата создания счёта (или явно введённая - пользователем), `valid_to = null`. + пользователем), `valid_to = null`. **Реализовано** — `AccountsController::createAction()`, + `valid_from` = дата создания (сегодня), без отдельного поля даты в форме (ввод задним числом + не обсуждали для самого первого открытия копилки — только для смены ставки ниже). - **При изменении ставки** — отдельное действие на странице счёта (не правка задним числом старой строки — иначе теряется история для прошедших периодов). Пользователь вводит **и новый процент, и дату, с которой он начинает действовать** (`valid_from` — не обязательно «сегодня», банк мог @@ -539,10 +541,31 @@ ALTER TABLE categories ADD CONSTRAINT fk_categories_linked_expense ## Приоритет фич -1. **Цели (план/факт) + анализ бюджета** — сейчас. +1. **Цели (план/факт) + анализ бюджета** — сейчас. **Счета (CRUD) — реализовано** (2026-08-12): + `App/Controller/AccountsController.php` (create/edit/delete=архивация, JSON, CSRF/владение + через `user_id` проверяются), модалка + карточки на дашборде (`App/view/Index/index.html`, + `App/media/js/accounts.js`) — без отдельной страницы `/accounts`, как и решено выше. Дальше по + этому приоритету — Категории/Операции/Цели, тем же паттерном контроллера (см. CLAUDE.md → + Controller hierarchy). 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`). + +**Реальная БД и пользователь** (2026-08-12): `schema.sql` применена к боевой MariaDB +(`192.168.11.247:3306`, база `budget`, все таблицы были пустые на момент применения — пересоздавались +без риска потери данных). Единственный реальный пользователь — `login=mikrit` (и в `Services\Auth`/ +`App/config/auth_users.php`, и в бюджетной таблице `users`, `name='Egor'`), заглушки `admin`/`manager` +в `auth_users.php` оставлены как тестовые логины, в бюджетной `users` для них строк нет. + ## Второй урок (почему эта версия файла переписана) После того как весь код/схема домена бюджета были снесены "с нуля" по прямому требованию пользователя, diff --git a/App/Classes/Currency.php b/App/Classes/Currency.php new file mode 100644 index 0000000..a1384ce --- /dev/null +++ b/App/Classes/Currency.php @@ -0,0 +1,24 @@ + ISO 4217 код => название */ + public const LIST = [ + 'RUB' => 'Рубль', + 'USD' => 'Доллар', + 'EUR' => 'Евро', + 'CNY' => 'Юань', + ]; +} diff --git a/App/Controller/AccountsController.php b/App/Controller/AccountsController.php new file mode 100644 index 0000000..c86f255 --- /dev/null +++ b/App/Controller/AccountsController.php @@ -0,0 +1,219 @@ +currentUser(); + + $validation = $this->buildValidation($request->post()); + + if (!$validation->check()) { + return $this->json(['success' => false, 'errors' => $validation->errors()], 422); + } + + $summa = (float)$request->post('summa'); + + if ($summa < 0) { + return $this->json(['success' => false, 'errors' => ['summa' => 'Сумма не может быть отрицательной']], 422); + } + + $type_acc = $request->post('type_acc'); + $rate_percent = $request->post('rate_percent'); + + $data = [ + 'user_id' => $user->id, + '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, + 'is_delete' => false, + 'order' => 0, + ]; + + $account_repo = new AccountRepository(); + + $account_repo->transaction(static function () use ($account_repo, $data, $type_acc, $rate_percent) { + $account_id = $account_repo->create($data); + + if ($type_acc === 'savings' && $rate_percent !== null && $rate_percent !== '') { + (new AccountRateRepository())->create([ + 'account_id' => $account_id, + 'rate_percent' => (float)$rate_percent, + 'valid_from' => date('Y-m-d'), + 'valid_to' => null, + ]); + } + + return $account_id; + }); + + return $this->json(['success' => true]); + } + + /** + * @return string JSON + * @throws HTTPException|MyException + */ + public function editAction(): string + { + $request = Request::$current; + $account_repo = new AccountRepository(); + $account = $this->ownAccount($account_repo, (int)$request->param(0)); + + $validation = $this->buildValidation($request->post()); + + if (!$validation->check()) { + return $this->json(['success' => false, 'errors' => $validation->errors()], 422); + } + + $summa = (float)$request->post('summa'); + + if ($summa < 0) { + return $this->json(['success' => false, 'errors' => ['summa' => 'Сумма не может быть отрицательной']], 422); + } + + $account_repo->update([ + 'id' => $account->id, + 'type_acc' => $request->post('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, + ]); + + return $this->json(['success' => true]); + } + + /** + * Архивация (is_delete = 1), не физическое удаление — см. бюджет_текущий_план.md → Accounts. + * + * @return string JSON + * @throws HTTPException|MyException + */ + public function deleteAction(): string + { + $request = Request::$current; + $account_repo = new AccountRepository(); + $account = $this->ownAccount($account_repo, (int)$request->param(0)); + + $account_repo->update(['id' => $account->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; + $account_repo = new AccountRepository(); + $account = $this->ownAccount($account_repo, (int)$request->param(0)); + + $account_repo->update(['id' => $account->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_acc', 'Тип') + ->label('summa', 'Сумма') + ->label('currency', 'Валюта') + ->rule('title', 'required') + ->rule('title', 'max_length', [128]) + ->rule('type_acc', 'required') + ->rule('type_acc', 'in', [['cash', 'bank', 'savings']]) + ->rule('summa', 'required') + ->rule('summa', 'numeric') + ->rule('currency', 'required') + ->rule('currency', 'in', [array_keys(Currency::LIST)]) + ->rule('description', 'max_length', [255]); + } + + /** + * Бюджетный пользователь (App\Repositories\UserRepository), связанный с текущим логином + * Services\Auth — см. App\Controller\IndexController за тем же паттерном. + * + * @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, чтобы не подтверждать + * чужому пользователю сам факт существования id). + * + * @param AccountRepository $account_repo + * @param int $id + * @return object + * @throws HTTPException|MyException + */ + protected function ownAccount(AccountRepository $account_repo, int $id): object + { + $user = $this->currentUser(); + $account = $account_repo->get($id); + + if (!$account || (int)$account->user_id !== (int)$user->id) { + throw HTTPException::factory(404); + } + + return $account; + } +} diff --git a/App/Controller/IndexController.php b/App/Controller/IndexController.php index a7687e2..c35a381 100644 --- a/App/Controller/IndexController.php +++ b/App/Controller/IndexController.php @@ -30,7 +30,10 @@ class IndexController extends Controller $auth_user = Auth::instance()->getUser(); $user = (new UserRepository())->getByLogin($auth_user['login']); - $accounts = $user ? (new AccountRepository())->getList('*', ['user_id' => $user->id, 'is_delete' => 0], '`order`') : []; + $account_repo = new AccountRepository(); + + $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') : []; $total = array_sum(array_map( static fn ($account) => $account->include_in_total && $account->currency === 'RUB' ? (float)$account->summa : 0, @@ -38,9 +41,10 @@ class IndexController extends Controller )); return $this->render('index', [ - 'user' => $auth_user, - 'accounts' => $accounts ?: [], - 'total' => $total, + 'user' => $auth_user, + 'accounts' => $accounts ?: [], + 'archived_accounts' => $archived_accounts ?: [], + 'total' => $total, ]); } } diff --git a/App/media/css/dashboard.css b/App/media/css/dashboard.css index 0e9238a..4183b8d 100644 --- a/App/media/css/dashboard.css +++ b/App/media/css/dashboard.css @@ -233,6 +233,19 @@ main { .btn-add:hover { background: var(--blue); } +/* ---------- архив счетов (модалка) ---------- */ + +.archive-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.7rem 0; + border-bottom: 1px solid var(--surface-3); +} + +.archive-row:last-child { border-bottom: none; } + /* ---------- нижний dock-навбар ---------- */ .dock { diff --git a/App/media/js/accounts.js b/App/media/js/accounts.js new file mode 100644 index 0000000..91cedb9 --- /dev/null +++ b/App/media/js/accounts.js @@ -0,0 +1,126 @@ +$(function () { + var modalEl = document.getElementById('account-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 archiveEl = document.getElementById('archive-modal'); + var archiveModal = archiveEl ? new bootstrap.Modal(archiveEl) : null; + + function toggleRateField() { + $rate.toggle($form.find('[name=type_acc]').val() === 'savings'); + } + + function resetForm() { + $form[0].reset(); + $form.find('[name=id]').val(''); + $error.hide().text(''); + toggleRateField(); + } + + $(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(); + }); + + $(document).on('click', '.js-account-archive-open', function () { + if (archiveModal) { + archiveModal.show(); + } + }); + + $(document).on('click', '.js-account-restore', function () { + var $row = $(this).closest('.archive-row'); + var csrf = $form.find('[name=csrf_token]').val(); + + $.post('/accounts/restore/' + $row.data('id'), {csrf_token: csrf}) + .done(function () { + window.location.reload(); + }) + .fail(function () { + window.alert('Не удалось восстановить счёт'); + }); + }); + + $(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(); + }); + + $(document).on('click', '.js-account-delete', function (e) { + e.preventDefault(); + + var $card = $(this).closest('.room-card'); + var title = $card.data('title'); + var csrf = $form.find('[name=csrf_token]').val(); + + if (!window.confirm('Архивировать счёт «' + title + '»?')) { + return; + } + + $.post('/accounts/delete/' + $card.data('id'), {csrf_token: csrf}) + .done(function () { + window.location.reload(); + }) + .fail(function () { + window.alert('Не удалось архивировать счёт'); + }); + }); + + $(document).on('change', '[name=type_acc]', function () { + toggleRateField(); + + if ($form.data('mode') === 'create') { + $('#account-include').prop('checked', $(this).val() !== 'savings'); + } + }); + + $(document).on('submit', '#account-form', function (e) { + e.preventDefault(); + $error.hide().text(''); + + $.post($form.attr('action'), $form.serialize()) + .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(); + }); + }); + + $(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/Index/index.html b/App/view/Index/index.html index 39fe244..dc245aa 100644 --- a/App/view/Index/index.html +++ b/App/view/Index/index.html @@ -5,17 +5,29 @@ * согласованному макету (тёмная тема, room-card, dock-навбар), рендерится * внутри layout.html через Controller::render(). * - * @var array $user Текущий авторизованный пользователь (без пароля) - * @var object[] $accounts Счета пользователя (accounts, не архивные) - * @var float $total Сумма счетов с include_in_total=1 в рублях + * @var array $user Текущий авторизованный пользователь (без пароля) + * @var object[] $accounts Счета пользователя (accounts, не архивные) + * @var object[] $archived_accounts Архивные счета пользователя (is_delete=1) + * @var float $total Сумма счетов с include_in_total=1 в рублях */ +use App\Classes\Currency; +use System\Classes\CSRF; + $type_icons = [ 'cash' => 'ic-wallet', 'bank' => 'ic-card', 'savings' => 'ic-piggy', ]; +$type_accents = [ + 'cash' => 'var(--teal)', + 'bank' => 'var(--blue)', + 'savings' => 'var(--purple)', +]; + +$this->setScript('accounts.js'); + ?> @@ -69,6 +81,15 @@ $type_icons = [ + + + + + + + + + @@ -82,7 +103,10 @@ $type_icons = [
Счета - +
+ + +
@@ -90,16 +114,24 @@ $type_icons = [
-
+
@@ -112,3 +144,103 @@ $type_icons = [
+ + + + diff --git a/CLAUDE.md b/CLAUDE.md index 57bf6f9..128d54d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,10 @@ tools/sort_html_attrs.php — CLI-утилита сортировки H tests/ — PHPUnit тесты ``` -> Каталог `App/Classes/` и часть `Services/*` (Mail, PDF) ещё не созданы — namespace-конвенции ниже описывают, *куда* класть код, когда он появится, а не существующие файлы. +> Часть `Services/*` (Mail, PDF) ещё не созданы. `App/Classes/` — уже не пуст: `App\Classes\Currency` +> (`const LIST` — код ISO 4217 => название, сейчас RUB/USD/EUR/CNY), используется и в форме счёта +> (`App/view/Index/index.html`, `