dev
This commit is contained in:
parent
8021c86e7b
commit
cff643ebc5
@ -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/*`), не открывать/не редактировать код вообще, включая «откат»
|
||||
случайно сделанных правок — откат тоже правка кода, о которой не просили; если код уже случайно
|
||||
изменён, спросить, откатывать ли, а не делать это самостоятельно.
|
||||
|
||||
@ -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` для них строк нет.
|
||||
|
||||
## Второй урок (почему эта версия файла переписана)
|
||||
|
||||
После того как весь код/схема домена бюджета были снесены "с нуля" по прямому требованию пользователя,
|
||||
|
||||
24
App/Classes/Currency.php
Normal file
24
App/Classes/Currency.php
Normal 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' => 'Юань',
|
||||
];
|
||||
}
|
||||
219
App/Controller/AccountsController.php
Normal file
219
App/Controller/AccountsController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
@ -40,6 +43,7 @@ class IndexController extends Controller
|
||||
return $this->render('index', [
|
||||
'user' => $auth_user,
|
||||
'accounts' => $accounts ?: [],
|
||||
'archived_accounts' => $archived_accounts ?: [],
|
||||
'total' => $total,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
126
App/media/js/accounts.js
Normal file
126
App/media/js/accounts.js
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -7,15 +7,27 @@
|
||||
*
|
||||
* @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');
|
||||
|
||||
?>
|
||||
|
||||
<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="1.5"/>
|
||||
</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>
|
||||
</svg>
|
||||
|
||||
@ -82,7 +103,10 @@ $type_icons = [
|
||||
|
||||
<div class="section-label">
|
||||
<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>
|
||||
|
||||
<?php if (empty($accounts)): ?>
|
||||
@ -90,16 +114,24 @@ $type_icons = [
|
||||
<?php else: ?>
|
||||
<div class="grid-cards">
|
||||
<?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">
|
||||
<span class="room-icon"><svg class="ico"><use href="#<?= $type_icons[$account->type_acc] ?? 'ic-wallet' ?>"/></svg></span>
|
||||
<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>
|
||||
</button>
|
||||
<div class="room-menu-list">
|
||||
<a href="#"><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="js-account-edit"><svg class="ico ico-sm"><use href="#ic-pencil"/></svg>Редактировать</a>
|
||||
<a href="#" class="danger js-account-delete"><svg class="ico ico-sm"><use href="#ic-trash"/></svg>Удалить</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -112,3 +144,103 @@ $type_icons = [
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?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>
|
||||
|
||||
14
CLAUDE.md
14
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`, `<select>`), и в валидации `App\Controller\AccountsController` —
|
||||
> единственное место, которое нужно поправить, чтобы добавить валюту.
|
||||
|
||||
### Autoload (composer.json)
|
||||
|
||||
@ -603,7 +606,14 @@ $this->setScript('about.js');
|
||||
|
||||
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()` вручную.
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user