This commit is contained in:
Egor Isaev 2026-08-12 17:08:46 +03:00
parent 8021c86e7b
commit cff643ebc5
9 changed files with 588 additions and 17 deletions

View File

@ -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/*`), не открывать/не редактировать код вообще, включая «откат»
случайно сделанных правок — откат тоже правка кода, о которой не просили; если код уже случайно
изменён, спросить, откатывать ли, а не делать это самостоятельно.

View File

@ -453,11 +453,13 @@ ALTER TABLE categories ADD CONSTRAINT fk_categories_linked_expense
транзакции. транзакции.
**Ставка вводится вручную, без интеграции с банком** (это личный трекер, не агрегатор счетов) — **Ставка вводится вручную, без интеграции с банком** (это личный трекер, не агрегатор счетов) —
план, ЕЩЁ НЕ РЕАЛИЗОВАНО, возвращаемся к нему, когда дойдём до формы счёта: план, часть реализована (см. ниже):
- **При создании копилки** (форма счёта, `type_acc = savings`) — доп. поле «Ставка, % годовых». - **При создании копилки** (форма счёта, `type_acc = savings`) — доп. поле «Ставка, % годовых».
Создаёт первую строку `account_rates`: `valid_from` = дата создания счёта (или явно введённая Создаёт первую строку `account_rates`: `valid_from` = дата создания счёта (или явно введённая
пользователем), `valid_to = null`. пользователем), `valid_to = null`. **Реализовано** — `AccountsController::createAction()`,
`valid_from` = дата создания (сегодня), без отдельного поля даты в форме (ввод задним числом
не обсуждали для самого первого открытия копилки — только для смены ставки ниже).
- **При изменении ставки** — отдельное действие на странице счёта (не правка задним числом старой - **При изменении ставки** — отдельное действие на странице счёта (не правка задним числом старой
строки — иначе теряется история для прошедших периодов). Пользователь вводит **и новый процент, и строки — иначе теряется история для прошедших периодов). Пользователь вводит **и новый процент, и
дату, с которой он начинает действовать** (`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. **Инвестиции** (вклады/акции/облигации) — следующий слой, схему делать с запасом. 2. **Инвестиции** (вклады/акции/облигации) — следующий слой, схему делать с запасом.
3. **Кредиты** — отложены, но `account_rates` намеренно универсальна для переиспользования позже. 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` для них строк нет.
## Второй урок (почему эта версия файла переписана) ## Второй урок (почему эта версия файла переписана)
После того как весь код/схема домена бюджета были снесены "с нуля" по прямому требованию пользователя, После того как весь код/схема домена бюджета были снесены "с нуля" по прямому требованию пользователя,

24
App/Classes/Currency.php Normal file
View File

@ -0,0 +1,24 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description Currency.php
* @copyright (c) 12/08/2026
*/
namespace App\Classes;
/**
* Список валют, доступных при создании/редактировании счёта (App\Controller\AccountsController)
* и в форме — единственное место, которое нужно поправить, чтобы добавить новую валюту.
*/
class Currency
{
/** @var array<string,string> ISO 4217 код => название */
public const LIST = [
'RUB' => 'Рубль',
'USD' => 'Доллар',
'EUR' => 'Евро',
'CNY' => 'Юань',
];
}

View File

@ -0,0 +1,219 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description AccountsController.php
* @copyright (c) 12/08/2026
*/
namespace App\Controller;
use App\Classes\Currency;
use App\Repositories\AccountRateRepository;
use App\Repositories\AccountRepository;
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 счетов (accounts) — создание/правка/архивация. Ответы — JSON, вызывается
* ajax'ом с модалки на дашборде (см. App/view/Index/index.html, App/media/js/accounts.js) —
* своей страницы у счетов нет, см. бюджет_текущий_план.md → Goals → "UI — на дашборде".
*/
class AccountsController extends Controller
{
protected bool $_auth_protection = true;
/**
* @return string JSON
* @throws MyException
*/
public function createAction(): string
{
$request = Request::$current;
$user = $this->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;
}
}

View File

@ -30,7 +30,10 @@ class IndexController extends Controller
$auth_user = Auth::instance()->getUser(); $auth_user = Auth::instance()->getUser();
$user = (new UserRepository())->getByLogin($auth_user['login']); $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( $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 && $account->currency === 'RUB' ? (float)$account->summa : 0,
@ -38,9 +41,10 @@ class IndexController extends Controller
)); ));
return $this->render('index', [ return $this->render('index', [
'user' => $auth_user, 'user' => $auth_user,
'accounts' => $accounts ?: [], 'accounts' => $accounts ?: [],
'total' => $total, 'archived_accounts' => $archived_accounts ?: [],
'total' => $total,
]); ]);
} }
} }

View File

@ -233,6 +233,19 @@ main {
.btn-add:hover { background: var(--blue); } .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-навбар ---------- */
.dock { .dock {

126
App/media/js/accounts.js Normal file
View File

@ -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');
}
});
});

View File

@ -5,17 +5,29 @@
* согласованному макету (тёмная тема, room-card, dock-навбар), рендерится * согласованному макету (тёмная тема, room-card, dock-навбар), рендерится
* внутри layout.html через Controller::render(). * внутри layout.html через Controller::render().
* *
* @var array $user Текущий авторизованный пользователь (без пароля) * @var array $user Текущий авторизованный пользователь (без пароля)
* @var object[] $accounts Счета пользователя (accounts, не архивные) * @var object[] $accounts Счета пользователя (accounts, не архивные)
* @var float $total Сумма счетов с include_in_total=1 в рублях * @var object[] $archived_accounts Архивные счета пользователя (is_delete=1)
* @var float $total Сумма счетов с include_in_total=1 в рублях
*/ */
use App\Classes\Currency;
use System\Classes\CSRF;
$type_icons = [ $type_icons = [
'cash' => 'ic-wallet', 'cash' => 'ic-wallet',
'bank' => 'ic-card', 'bank' => 'ic-card',
'savings' => 'ic-piggy', 'savings' => 'ic-piggy',
]; ];
$type_accents = [
'cash' => 'var(--teal)',
'bank' => 'var(--blue)',
'savings' => 'var(--purple)',
];
$this->setScript('accounts.js');
?> ?>
<svg width="0" height="0" style="position:absolute"> <svg width="0" height="0" style="position:absolute">
@ -69,6 +81,15 @@ $type_icons = [
<circle cx="12" cy="12" r="5"/> <circle cx="12" cy="12" r="5"/>
<circle cx="12" cy="12" r="1.5"/> <circle cx="12" cy="12" r="1.5"/>
</symbol> </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>
</defs> </defs>
</svg> </svg>
@ -82,7 +103,10 @@ $type_icons = [
<div class="section-label"> <div class="section-label">
<span>Счета</span> <span>Счета</span>
<button type="button" class="btn-add" title="Добавить счёт"><svg class="ico"><use href="#ic-plus"/></svg></button> <div style="display:flex;gap:0.5rem;">
<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> </div>
<?php if (empty($accounts)): ?> <?php if (empty($accounts)): ?>
@ -90,16 +114,24 @@ $type_icons = [
<?php else: ?> <?php else: ?>
<div class="grid-cards"> <div class="grid-cards">
<?php foreach ($accounts as $account): ?> <?php foreach ($accounts as $account): ?>
<div class="room-card" style="--card-accent:var(--blue)"> <div class="room-card"
style="--card-accent:<?= $type_accents[$account->type_acc] ?? 'var(--blue)' ?>"
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' : '' ?>">
<div class="room-card-head"> <div class="room-card-head">
<span class="room-icon"><svg class="ico"><use href="#<?= $type_icons[$account->type_acc] ?? 'ic-wallet' ?>"/></svg></span> <span class="room-icon"><svg class="ico"><use href="#<?= $type_icons[$account->type_acc] ?? 'ic-wallet' ?>"/></svg></span>
<div class="room-menu"> <div class="room-menu">
<button type="button" class="room-menu-btn" onclick="this.parentElement.classList.toggle('open')"> <button type="button" class="room-menu-btn">
<svg class="ico"><use href="#ic-dots"/></svg> <svg class="ico"><use href="#ic-dots"/></svg>
</button> </button>
<div class="room-menu-list"> <div class="room-menu-list">
<a href="#"><svg class="ico ico-sm"><use href="#ic-pencil"/></svg>Редактировать</a> <a href="#" class="js-account-edit"><svg class="ico ico-sm"><use href="#ic-pencil"/></svg>Редактировать</a>
<a href="#" class="danger"><svg class="ico ico-sm"><use href="#ic-trash"/></svg>Удалить</a> <a href="#" class="danger js-account-delete"><svg class="ico ico-sm"><use href="#ic-trash"/></svg>Удалить</a>
</div> </div>
</div> </div>
</div> </div>
@ -112,3 +144,103 @@ $type_icons = [
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<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);">
<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 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="type_acc">
<option value="cash">Кошелёк (наличные)</option>
<option value="bank">Банковский счёт</option>
<option value="savings">Копилка</option>
</select>
</div>
<div class="row">
<div class="col-7 mb-3">
<label class="form-label">Сумма</label>
<input type="number" step="0.01" min="0" class="form-control" name="summa" required>
</div>
<div class="col-5 mb-3">
<label class="form-label">Валюта</label>
<select class="form-select" name="currency">
<?php foreach (Currency::LIST as $code => $name): ?>
<option value="<?= $code ?>"<?= $code === 'RUB' ? ' selected' : '' ?> title="<?= htmlspecialchars($name, ENT_QUOTES, 'UTF-8') ?>"><?= $code ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="mb-3" id="account-rate-group">
<div id="account-rate-field" style="display:none">
<label class="form-label">Ставка, % годовых</label>
<input type="number" step="0.01" min="0" class="form-control" name="rate_percent">
</div>
</div>
<div class="mb-3">
<label class="form-label">Описание</label>
<input type="text" class="form-control" name="description" maxlength="255">
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" name="include_in_total" value="1" id="account-include" checked>
<label class="form-check-label" for="account-include">Учитывать в «Доступно сейчас»</label>
</div>
</div>
<div class="modal-footer" style="border-color:var(--surface-3)">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" class="btn btn-primary">Сохранить</button>
</div>
</form>
</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);">
<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_accounts)): ?>
<div class="empty-state">Архив пуст</div>
<?php else: ?>
<?php foreach ($archived_accounts as $account): ?>
<div class="archive-row" data-id="<?= (int)$account->id ?>" data-title="<?= htmlspecialchars($account->title, ENT_QUOTES, 'UTF-8') ?>">
<div>
<div class="room-title"><?= htmlspecialchars($account->title, ENT_QUOTES, 'UTF-8') ?></div>
<div class="room-sub num">
<?= number_format((float)$account->summa, 2, ',', ' ') ?>
<?= htmlspecialchars($account->currency, ENT_QUOTES, 'UTF-8') ?>
</div>
</div>
<button type="button" class="btn btn-sm btn-outline-light js-account-restore" title="Восстановить">
<svg class="ico"><use href="#ic-undo"/></svg>
Восстановить
</button>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div>
</div>

View File

@ -52,7 +52,10 @@ tools/sort_html_attrs.php — CLI-утилита сортировки H
tests/ — PHPUnit тесты 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`, `<select>`), и в валидации `App\Controller\AccountsController` —
> единственное место, которое нужно поправить, чтобы добавить валюту.
### Autoload (composer.json) ### Autoload (composer.json)
@ -603,7 +606,14 @@ $this->setScript('about.js');
API-контроллер делается не отдельным классом, а флагом: `extends Controller` + `$_csrf_protection = false` + ответы через `json()` (так же, как в eoffice_v3). API-контроллер делается не отдельным классом, а флагом: `extends Controller` + `$_csrf_protection = false` + ответы через `json()` (так же, как в eoffice_v3).
> Проект (`App/*`) сейчас пуст — снесён и переписывается с нуля, см. `.claude/memory/бюджет_текущий_план.md`. Упоминания конкретных контроллеров приложения (админка, демо-страницы и т.п.) ниже по файлу могут быть устаревшими до следующего прохода по документации. **Текущие контроллеры приложения** (пересобираются заново после сброса, см.
`.claude/memory/бюджет_текущий_план.md`): `LoginController` (вход/выход, см. раздел Auth),
`IndexController` (дашборд/«Обзор», защищён `$_auth_protection`), `AccountsController` (CRUD
счетов — `createAction()/editAction()/deleteAction()`, JSON-ответы, без своего `indexAction()`/
страницы — вызывается ajax'ом с модалки прямо на дашборде, см. `App/view/Index/index.html` +
`App/media/js/accounts.js`; шаблон для будущих `CategoriesController`/`TransactionsController`/
`GoalsController` — тот же паттерн: `currentUser()`/владение записью через `user_id` → 404, а не
403, если чужой id; архивация (`is_delete=1`) вместо физического `delete()`, где это в схеме).
`Request::execute()` вызывает `executeAction({action}Action)`, поэтому `before()/after()` работают для любого контроллера прозрачно. Авторизация конкретного контроллера включается/выключается флагом `$_auth_protection` (см. раздел Auth), а не переопределением `before()` вручную. `Request::execute()` вызывает `executeAction({action}Action)`, поэтому `before()/after()` работают для любого контроллера прозрачно. Авторизация конкретного контроллера включается/выключается флагом `$_auth_protection` (см. раздел Auth), а не переопределением `before()` вручную.