diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md
index 43143ab..0994a63 100644
--- a/.claude/memory/MEMORY.md
+++ b/.claude/memory/MEMORY.md
@@ -18,3 +18,5 @@
- [Независимость движка от приложения](feedback_engine_app_independence.md) — Services/System не должны импортировать App\* или знать схему конкретного проекта
- [Бюджет — актуальный план (2026)](бюджет_текущий_план.md) — что реально согласовано в разговоре; старый бюджет_проект_план.md — только источник идей, не требований
- [Не переиспользовать память молча после сброса](feedback_no_silent_memory_reuse_after_reset.md) — после "снеси всё, начнём заново" каждое решение переподтверждается заново, даже если уже записано
+- [Не лезть в реализацию раньше времени](feedback_no_premature_implementation.md) — пока фича «Бюджет» обсуждается, решения только в план-файле, schema.sql/код не трогать без явного запроса
+- [Макет дашборда (Claude Artifact)](reference_dashboard_design_artifact.md) — ссылка на согласованный визуальный дизайн (тёмная тема, room-card, dock-навбар), сверяться при работе над UI
diff --git a/.claude/memory/feedback_no_premature_implementation.md b/.claude/memory/feedback_no_premature_implementation.md
new file mode 100644
index 0000000..001a539
--- /dev/null
+++ b/.claude/memory/feedback_no_premature_implementation.md
@@ -0,0 +1,24 @@
+---
+name: feedback-no-premature-implementation
+description: Не трогать schema.sql/код, пока обсуждение фичи не согласовано целиком — фиксировать решения только в план-файле до явного запроса реализовать
+metadata:
+ type: feedback
+---
+
+Пользователь прямо сказал: «ты же любишь в пекло лезть не обсудив сначала» — опасение, что при
+обсуждении фичи (пример — разбивка операции на статьи, см. [[бюджет_текущий_план]]) я уже мог
+незаметно поправить `schema.sql` или начать писать код, не дождавшись, пока обсуждение реально
+закончится и будет явное «давай теперь пиши».
+
+**Why:** Обсуждение бюджета идёт по частям, решения по одной фиче иногда меняются на лету (пример —
+формульные статьи: сначала предполагалась дочерняя таблица `transaction_items`/сумма нескольких
+источников дохода, потом это отклонили). Если параллельно с обсуждением редактировать `schema.sql`
+или код, придётся откатывать реализацию под каждый разворот мысли — то же самое хуже: изменения в
+реальной схеме/коде физически труднее откатить незаметно, чем строчку в план-файле.
+
+**How to apply:** Пока фича обсуждается — фиксировать решения только в
+`.claude/memory/бюджет_текущий_план.md` (план-файл, не код). Не трогать `schema.sql`,
+`App/Repositories/*`, контроллеры и т.п. под эту фичу, пока пользователь явно не попросит начать
+реализацию (не путать с уже завершённым слоем БД/фреймворка — там правки кода это часть текущей
+явно запрошенной задачи, речь именно про фичу «Бюджет», которая всё ещё на стадии обсуждения).
+Если непонятно, закончилось ли обсуждение — переспросить прямо, не действовать по умолчанию.
diff --git a/.claude/memory/feedback_no_unprompted_scope.md b/.claude/memory/feedback_no_unprompted_scope.md
index 59bfa1f..a3a48c3 100644
--- a/.claude/memory/feedback_no_unprompted_scope.md
+++ b/.claude/memory/feedback_no_unprompted_scope.md
@@ -23,4 +23,11 @@ metadata:
не расширять объём работы (лишние методы, лишний рефакторинг, лишние файлы) сверх того, что запросили.
Если вопрос пользователя похож на «а мы это ещё где-то используем/будем использовать?» — это, как
правило, не команда действовать, а проверка моей логики; сначала ответить прямо, не хвататься за
-редактирование кода.
\ No newline at end of file
+редактирование кода.
+
+**Второй кейс (2026-08-12, реализация логина/2FA):** на шаге ввода кода из письма сам добавил кнопку
+«Назад ко входу» (плюс обработку `back` в `LoginController`) — этого никто не просил, ни в обсуждении
+2FA, ни в макете дашборда. Пользователь: «просто убери эта кнопка не нужна, она же ни где не пишется
+ты сам это придумал?». Тот же паттерн, что и с `contentDir()`, но уже не про код-абстракцию, а про
+UI-элемент — вывод: правило касается не только рефакторинга/абстракций, а вообще любого добавления
+сверх того, что обсуждалось (лишняя кнопка/поле формы/пункт меню — тот же случай, что и лишний метод).
\ No newline at end of file
diff --git a/.claude/memory/reference_dashboard_design_artifact.md b/.claude/memory/reference_dashboard_design_artifact.md
new file mode 100644
index 0000000..6ed8664
--- /dev/null
+++ b/.claude/memory/reference_dashboard_design_artifact.md
@@ -0,0 +1,27 @@
+---
+name: reference-dashboard-design-artifact
+description: Ссылка на согласованный визуальный макет дашборда «Бюджет» (тёмная тема, room-card, dock-навбар) — сверяться при любой работе над UI
+metadata:
+ type: reference
+---
+
+Макет дашборда согласован заранее (в другой сессии) как Claude Artifact:
+`https://claude.ai/code/artifact/55e7f715-2ee7-45e2-acb7-82b883fcad02` («Бюджет — обзор (макет)»).
+
+Ключевые элементы дизайна оттуда, уже перенесённые в `App/media/css/dashboard.css` и
+`App/view/Index/index.html`/`App/view/layout.html`:
+- Тёмная тема (`--bg:#0b0b0f`, `--surface:#17171d`, акцентные цвета per-карточка через
+ `--card-accent`), inline SVG-иконки (не Bootstrap Icons) для навигации/типов счетов.
+- Карточки-«комнаты» (`.room-card`) для счетов, кольца прогресса (`.ring`) для целей копилок,
+ списки `.budget-list`/`.budget-row` с прогресс-баром для план/факта по категориям.
+- Нижний dock-навбар (`.dock`) вместо верхнего меню: Обзор/Счета/Категории/Операции/Цели.
+- Верхний блок — «Доступно сейчас» + «Всего по всем счетам», строка stat-pills (дата/% бюджета/
+ изменение за месяц/курс валюты) — в реализации пока упрощена (нет данных по бюджету/курсу).
+
+Макет — на русском, включает демо-данные (Кошелёк/Карта Тинькофф/Евро, цели «Отпуск»/«Остаток»,
+полный список категорий из [[бюджет_текущий_план]]) — не копировать демо-суммы как реальные, только
+структуру/классы/цветовую схему.
+
+**How to apply**: перед тем как добавлять новый раздел UI (Счета/Категории/Операции/Цели — сейчас
+это просто неактивные пункты dock-навбара) — свериться с этим макетом (`WebFetch` по ссылке выше),
+не изобретать свой стиль карточек/навигации заново.
diff --git a/.claude/memory/бюджет_текущий_план.md b/.claude/memory/бюджет_текущий_план.md
index d91a9c9..fa569d7 100644
--- a/.claude/memory/бюджет_текущий_план.md
+++ b/.claude/memory/бюджет_текущий_план.md
@@ -145,12 +145,41 @@ CREATE TABLE budget_access_restrictions (
**Курс валюты — автоматически, из внешнего источника (ЦБ РФ)**, не вручную (в отличие от процентной
ставки копилки — там осознанно вручную, см. `Account_rates` ниже; для курса валют внешний источник
-есть и он надёжный, незачем дублировать руками). Не спроектировано пока: какой конкретно endpoint ЦБ РФ
-(daily XML/JSON), куда класть клиент — скорее всего новый `Services\CurrencyRate` (по аналогии с
-`Services\Elasticsearch`/`Services\Mongo` — точка входа + REST/HTTP-клиент), поверх уже существующего
-`System\Classes\HTTP\Client\Curl`. Дашборд показывает курс для валют, которые реально встречаются среди
-счетов пользователя (не весь список валют ЦБ РФ огулом). **Для дашборда курс — просто «сегодняшнее
-значение» с кэшем на сутки, историю хранить для этого не нужно** (история нужна в другом месте, см. ниже).
+есть и он надёжный, незачем дублировать руками).
+
+**Endpoint — решено.** `https://www.cbr.ru/scripts/XML_daily.asp?date_req=DD/MM/YYYY` — простой GET,
+плоский XML, без авторизации. Обычный `.asmx` SOAP-веб-сервис ЦБ РФ (`DailyInfoWebServ/DailyInfo.asmx`)
+рассматривали и отклонили — потребовал бы отдельного SOAP-клиента, которого в проекте нет (только
+`Curl`+REST, см. `Services\Elasticsearch\Client` как образец такого же паттерна).
+
+Формат ответа (проверено на реальном запросе):
+```xml
+
+
+ 840
+ USD
+ 1
+ Доллар США
+ 82,6060
+ 82,606
+
+ ...
+
+```
+**Подводные камни при парсинге:**
+- Кодировка ответа — `windows-1251`, не UTF-8 (проект — `utf8mb4`), нужна конвертация.
+- Разделитель дробной части в `Value`/`VunitRate` — запятая, не точка (`82,6060`) — `str_replace(',', '.', …)`
+ перед `(float)`.
+- Нужен `VunitRate` (курс за 1 единицу валюты), не `Value` — у некоторых валют `Nominal` не 1 (например
+ 100 йен), `Value` — курс за весь номинал, не за единицу; `rate_to_rub`/курс на дашборде — всегда за
+ единицу.
+- `CharCode` — это и есть ISO-код, совпадает с форматом `accounts.currency`/`transactions.currency` (`CHAR(3)`).
+
+Клиент — новый `Services\CurrencyRate` (по аналогии с `Services\Elasticsearch`/`Services\Mongo` — точка
+входа + REST/HTTP-клиент), поверх уже существующего `System\Classes\HTTP\Client\Curl`. Дашборд
+показывает курс для валют, которые реально встречаются среди счетов пользователя (не весь список валют
+ЦБ РФ огулом). **Для дашборда курс — просто «сегодняшнее значение» с кэшем на сутки, историю хранить
+для этого не нужно** (история нужна в другом месте, см. ниже).
**Общая сумма на дашборде** — не сумма вообще всех счетов, а сумма счетов с `include_in_total = true`,
сконвертированная в рубли по текущему курсу для не-рублёвых. Рубль — базовая валюта для агрегации
@@ -250,25 +279,78 @@ ALTER TABLE transactions ADD COLUMN rate_to_rub DECIMAL(10, 4) NULL; -- курс
«пересчитать баланс с нуля из истории» — не для обычной работы, а как инструмент сверки/восстановления
при подозрении на расхождение (аналог банковской реконсиляции).
-**Разбивка одной операции на несколько статей — подтверждено, нужно.** Один чек/покупка → несколько
-пар (статья, сумма) в рамках одной операции (пример: поход в магазин — 500 ₽ Питание + 300 ₽
-Хозтовары). Это меняет схему `transactions`: `categorie_id`/`summa` на самой строке `transactions`
-достаточно только для НЕразбитых операций; для разбитых нужна отдельная дочерняя таблица (условно
-`transaction_items`: `id`, `transaction_id`, `categorie_id`, `summa`) — шапка `transactions` держит
-дату/счета/общую сумму/тип, детали разбивки — в дочерних строках. Не спроектировано окончательно:
-нужна ли разбивка для `income`/`transfer` тоже, или только для `expense` (вероятно только `expense` —
-transfer и так без категории, доход обычно один источник). **Два способа ввода:**
-1. **Голосовой** — пользователь надиктовывает через AI API (например «Питание 500 рублей, Хозтовары
- 300 рублей»), система распознаёт речь и разбирает текст на пары статья/сумма автоматически.
-2. **Ручной** — то же самое, но вводится руками, пара за парой.
-Не спроектировано: какой конкретно AI API для голоса (Claude API уже упоминался в старом
-`бюджет_проект_план.md` как ИИ-помощник — возможно, тот же), как обрабатывать ошибки распознавания.
+**Разбивка одной операции на несколько статей — решено, никакой отдельной сущности не нужно.**
+Изначальная гипотеза про дочернюю таблицу `transaction_items` (шапка + детали) — **неверна,
+отклонена**. По факту «разбивка» — это просто **две (или больше) обычных, полностью независимых
+строки в `transactions`** (пример: поход в магазин на 800 ₽ → строка 500 ₽ Питание + строка 300 ₽
+Хозтовары, каждая сама по себе). Никакой связи между ними в БД не хранится — они не привязаны друг к
+другу как «части одного чека», просто совпадают по дате/счёту. Схема `transactions` уже полностью
+это поддерживает как есть, менять её под эту фичу не нужно.
-**Лог правок/удалений — отдельная таблица в БД** (не общий файловый `System\Classes\Log` — тот
-подходит для аудита запросов вообще, но не для точечных «покажи все правки операции №123»). Правка и
-удаление разрешены всегда, без ограничений по давности — но каждое изменение логируется: кто, когда,
-что именно поменялось. Не спроектировано: точная структура (`transaction_history`: id, transaction_id,
-user_id, changed_at, action, old_values/new_values — набросок, не финал).
+**Два способа ввода — оба просто заводят обычные транзакции:**
+1. **Голосовой** — пользователь надиктовывает через ИИ (например «Питание 500 рублей, Хозтовары 300
+ рублей»), ИИ присылает JSON на несколько транзакций, каждая заводится как обычная строка
+ `transactions`.
+2. **Ручной** — то же самое, но пользователь сам вводит несколько отдельных транзакций подряд.
+**AI-провайдер — решено, Claude API, но с возможностью переключиться (например на ChatGPT).**
+Не хардкодить конкретного вендора — по аналогии со `Services\Auth`/`AuthDriver` (интерфейс + сменный
+драйвер, см. CLAUDE.md → раздел Auth): скорее всего новый `Services\AI`/`AIDriver`-интерфейс,
+`ClaudeAIDriver` как реализация по умолчанию, конкретный провайдер — через конфиг (как
+`Config::get('auth', 'driver')`).
+
+**Запись голоса — не наша забота, решено.** Никакого захвата аудио/распознавания речи в приложении
+не нужно — голос в текст переводит штатная голосовая клавиатура Android (диктовка). В приложение
+попадает уже готовый **текст** (обычное текстовое поле, куда пользователь надиктовал через клавиатуру
+телефона) — именно этот текст целиком уходит в AI API на разбор в JSON с транзакциями. Никакой
+интеграции с аудио/speech-to-text на своей стороне не требуется.
+
+**Ошибки разбора — через отложенную проверку (tmp-таблица), решено.** Более раннее решение «без
+подтверждения, сохраняем сразу» — **отменено, заменено этим**. Схема:
+1. ИИ разбирает текст диктовки → результат пишется не в `transactions` напрямую, а во временную
+ таблицу `transaction_drafts` (имя решено, точная структура полей — нет).
+2. При заходе на дашборд или страницу бюджета — если в этой таблице есть непросмотренные записи,
+ всплывает окно с ними.
+3. Пользователь просматривает, при необходимости правит (или отклоняет — не уточнено явно, но
+ логично, что можно и удалить черновик, а не только редактировать).
+4. Подтверждённые записи переносятся в `transactions`, черновик по ним удаляется из tmp-таблицы.
+
+Не спроектировано: точная схема tmp-таблицы, можно ли отклонить/удалить черновик (не только
+редактировать), что происходит, если пользователь долго не заходит на дашборд/бюджет (черновики
+просто копятся до просмотра — видимо ок, не обсуждали иное).
+
+**Промпт с контекстом пользователя — решено, нужно.** В запрос к AI API передаётся не только текст
+диктовки, но и список реальных статей/счетов пользователя (актуальные `categories`/`accounts`) — чтобы
+ИИ подбирал существующие `categorie_id`/`account_id`, а не придумывал названия статей от себя. Точный
+формат промпта/что именно передавать (весь список категорий целиком, или как-то отфильтрованный) —
+не спроектировано, решать при реализации.
+
+**Лог правок/удалений — отдельная таблица в БД, структура решена.** Не общий файловый `System\Classes\Log`
+(тот — для аудита запросов вообще, не для точечных «покажи все правки операции №123»). Правка и удаление
+разрешены всегда, без ограничений по давности. **Цель — возможность откатить операцию по логу**, отсюда
+и структура:
+
+```sql
+CREATE TABLE transaction_history (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ transaction_id INT UNSIGNED NOT NULL,
+ user_id INT UNSIGNED NOT NULL,
+ changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ action ENUM ('create', 'edit', 'delete') NOT NULL,
+ snapshot JSON NULL, -- полный слепок transactions на этот момент; NULL для action='delete'
+ PRIMARY KEY (id),
+ KEY ix_transaction_history_transaction_id (transaction_id)
+);
+```
+
+**Без отдельных `old_values`/`new_values` — не нужно дублировать «было».** Каждая запись `create`/`edit`
+хранит цельный снимок состояния операции **после** действия (`snapshot`). Откат — это переход по цепочке
+снимков одной `transaction_id`:
+- откатить `edit` → взять `snapshot` из **предыдущей** записи истории этой же операции;
+- восстановить после `delete` → взять `snapshot` из записи, предшествующей записи об удалении (сама
+ запись `delete` данных не хранит, только кто/когда её удалил);
+- откатить `create` → просто удалить операцию, данные снимка не нужны.
+
+Собственно механизм отката (кнопка/логика восстановления) — не спроектирован, только структура лога.
## Budgets (план/факт)
@@ -281,13 +363,48 @@ user_id, changed_at, action, old_values/new_values — набросок, не ф
вводится план по «Проезду» на весь 2027-й — 12 чисел за один заход). Влияет на форму ввода — нужна не
«план на текущий месяц», а «план на год» с 12 полями (по одному на месяц) на каждую статью.
-**Открыто: формульные статьи бюджета.** Пользователь пояснил про «10%»: план = ЗП × 0,1, факт =
-факт ЗП × 0,1 — то есть не фиксированное число и не сумма собственных транзакций, а процент от
-другой категории (дохода). Концепция обсуждалась, не спроектирована окончательно: скорее всего
-`budgets` получит необязательные `formula_source_categorie_id` + `formula_pct`, и для таких строк
-план/факт вычисляются на лету от категории-источника, а не читаются напрямую. Не решено: один
-источник или сумма нескольких (у пользователя два дохода — Зарплата Егор/Зарплата Лена), что
-происходит при смене процента задним числом. Не проектировать/не кодировать, пока не обсудим детали.
+## Формульные статьи бюджета
+
+У статьи дохода (например «ЗП Егор») может быть привязана статья расхода (например «Курица») с
+настраиваемым процентом — **связь настраивается со стороны статьи дохода**, не расхода (пример:
+«ЗП Егор» → «Курица» 10%; «ЗП Лена» → ничего не привязано, это нормально, необязательное поле;
+«Халтура» → «Инвестиции» 15% — независимая от первой связь, свой процент). **Строго один-к-одному**:
+одна статья дохода — максимум одна статья расхода; вешать одну и ту же статью расхода на две статьи
+дохода **не нужно** (сумма нескольких источников не предусмотрена — рассматривали и отклонили).
+
+Механика:
+- **Процент — дефолт на статье дохода** (настройка при создании/редактировании категории), не
+ разовое значение только на момент ввода.
+- **При вводе годового плана по статье-источнику** (ЗП, 12 месяцев за один заход — см. `Budgets`
+ выше) план привязанной статьи расхода автозаполняется = дефолтный процент × план ЗП **для каждого
+ месяца отдельно**.
+- **Процент можно менять помесячно** — правится не история/задним числом (в отличие от
+ `account_rates`), а сам месяц: меняешь процент для конкретного месяца — пересчитывается план
+ только этого месяца, остальные 11 не трогаются. Никакой ретроактивности/периодов действия, как у
+ ставки копилки, не нужно — у каждого месяца просто свой процент.
+
+**Факт — обычный, не формула.** Формулой автозаполняется только `plan_summ`; факт по такой статье —
+такие же реальные транзакции, как у любой другой статьи расхода, без ограничения по формуле. Можно
+потратить больше (или меньше) рассчитанного плана — это просто обычное расхождение план/факт, ничем
+не отличается от нессылочных статей.
+
+**Схема — решено:**
+```sql
+ALTER TABLE categories ADD COLUMN linked_expense_categorie_id INT UNSIGNED NULL;
+ALTER TABLE categories ADD COLUMN formula_pct DECIMAL(5, 2) NULL;
+ALTER TABLE categories ADD CONSTRAINT fk_categories_linked_expense
+ FOREIGN KEY (linked_expense_categorie_id) REFERENCES categories (id);
+```
+Оба поля имеют смысл только у статей дохода (`type = 'income'`) с настроенной связью, у остальных
+`NULL`. В `budgets` — новое поле `formula_pct_used DECIMAL(5, 2) NULL`, хранит, каким процентом
+посчитан план конкретного месяца (нужно для показа/правки в форме плана), `NULL` для обычных
+(нессылочных) строк.
+
+**`plan_summ` — хранится как обычно, даже у формульных строк** (как и у обычных статей — план в
+принципе всегда хранимое число в `budgets`, только у формульных он не вводится руками, а
+автоматически пересчитывается и перезаписывается при правке суммы/процента источника, в той же
+транзакции — тот же паттерн кэша, что и `accounts.summa`). Чтение `budgets` одинаково для всех
+строк, формульных и обычных — везде просто число, разница только в том, кто его туда положил.
## Регулярные/автоматические операции — РЕШЕНО НЕ ДЕЛАТЬ
@@ -324,8 +441,16 @@ user_id, changed_at, action, old_values/new_values — набросок, не ф
причина оставить таблицу, а не одно поле `rate_percent` на `accounts` (более ранняя версия этого
раздела ошибочно связывала историю только с будущим кредитом — кредит по-прежнему вне скоупа, но
раз есть более близкий, реальный повод, обоснование хранения истории теперь этот, не кредитный).
-Сам расчёт начисленных процентов за период (простой или сложный, как разбивать по датам) — ещё не
-спроектирован, отдельная задача на будущее.
+
+**Период капитализации — день** (решено, актуально и для расчёта начисленных процентов, и для
+формулы прогноза в `Goals` ниже — один и тот же вопрос на обе задачи).
+
+**Фактическое начисление процентов на копилку — без автоматического расчёта.** Пользователь сам
+вводит сумму вручную по кнопке «начислить проценты» (см. `Goals` ниже) — система не считает и не
+подставляет её сама. Расчёт «сколько капнуло за период» по дневной ставке (с разбивкой по
+`account_rates`, если ставка менялась внутри периода) нужен только для **прогноза/отчёта**
+(показать пользователю ориентир перед тем, как он введёт сумму сам), не для автозаполнения
+транзакции.
**Ставка вводится вручную, без интеграции с банком** (это личный трекер, не агрегатор счетов) —
план, ЕЩЁ НЕ РЕАЛИЗОВАНО, возвращаемся к нему, когда дойдём до формы счёта:
@@ -343,9 +468,8 @@ user_id, changed_at, action, old_values/new_values — набросок, не ф
- Метод под это — `AccountRateRepository::setRate(int $account_id, float $rate_percent, string $valid_from)`.
Сейчас в `AccountRateRepository` есть только `getCurrentRate()` (чтение, уже реализовано и
протестировано на реальной БД) — `setRate()` ещё не написан.
-- Прогноз «надо ≈X ₽/мес» у цели — открытый вопрос: линейно (без процента, проще) или честной
- формулой аннуитета со сложным процентом (то, что буквально написано в `Goals` ниже — «по текущей
- ставке») — не решено, обсудить перед реализацией расчёта.
+- Прогноз «надо ≈X ₽/мес» у цели — **решено**: формула аннуитета со сложным процентом по текущей
+ ставке, дневная капитализация (не линейно) — см. выше.
## Goals (цель для копилки)
@@ -354,16 +478,64 @@ user_id, changed_at, action, old_values/new_values — набросок, не ф
что она не изменится до дедлайна; пересчитывается при просмотре/смене ставки.
**Проценты, начисленные на копилку** — `income`-транзакция на счёт-копилку с одной системной
-категорией «Проценты по вкладам/целям» (одна на все копилки, не по одной на каждую) — заводится
-пользователю автоматически при первом использовании.
+категорией «Проценты по вкладам/целям» (одна на все копилки, не по одной на каждую, заводится
+пользователю автоматически при первом использовании). **Сумма — вручную, кнопкой «начислить
+проценты»**: пользователь сам вписывает число (система не считает и не подставляет его сама, см.
+`Account_rates` выше — расчёт по дневной ставке нужен только как ориентир для прогноза, не для
+автозаполнения этой транзакции).
+
+**Просроченный `deadline`, цель не достигнута — без особой обработки.** Просто показываем прогресс
+как есть (например «10 000 из 15 000»), без подсветки/предупреждения и без предложения сдвинуть
+дедлайн.
+
+**UI — на дашборде, не отдельная страница/форма.** Карточки счетов на дашборде: кнопка «+» —
+добавить счёт; на каждой карточке-копилке — меню «три точки» → карандаш (редактировать) / корзина
+(удалить), как в типичных современных интерфейсах. Создание и редактирование — через модалку, не
+отдельную страницу. Раз цель — надстройка над `savings`-счётом (не отдельная сущность), вероятно
+поля цели (`target_summa`/`deadline`) — в той же модалке, что и сам счёт; отдельной модалки под
+цель не обсуждали.
+
+**Корзина на карточке копилки — архивация счёта целиком** (`is_delete = 1`, тот же приём, что и у
+обычных счетов), не просто снятие цели. **Если на счёте остались деньги** (`summa > 0`) — перед
+архивацией спросить, на какой счёт перевести остаток.
+
+**В форме закрытия — два отдельных поля суммы: «остаток» и «проценты»**, не одно. Перевод на
+счёт-получатель уходит одной суммой (сложение обеих), но перед этим переводом система заводит на
+саму копилку `income`-транзакцию на сумму из поля «проценты» с категорией «Проценты по вкладам/целям»
+(та же, что и при обычном ручном начислении, см. выше) — это финальное начисление процентов перед
+закрытием, совмещённое с самим переводом в один шаг формы. Раздельные поля нужны именно ради этого:
+не потерять проценты как отдельную статью дохода в отчётах (transfer — всегда без категории, см.
+`Transactions`), просто слив всё в одну сумму перевода это бы стёр.
## Быстрый ввод операции — замена идее Telegram-бота
В старом `бюджет_проект_план.md` (заметки ДО сброса, источник идей) был пункт «Telegram-бот для
быстрого ввода трат». **Переиграно**: вместо внешнего бота — страница быстрого добавления операции
внутри самого Bicycle (тот же стек, без бот-токена/вебхука/парсинга свободного текста от Telegram API).
-**Не решено** — закреплять ли её как PWA-иконку на экране телефона (`View::setManifest()` уже
-поддержан в проекте, см. CLAUDE.md) — пользователь пока не уверен, обсудить отдельно перед реализацией.
+**PWA-иконка — решено, не делаем.** Обычная страница в браузере, без `View::setManifest()`.
+
+## Онбординг нового пользователя — создание из шаблона
+
+Когда у только что созданного пользователя всё пусто (0 счетов) — предложить создать данные не с
+нуля, а из готового шаблона (пока только один шаблон, без выбора):
+
+**Решено:**
+- **1 счёт**: тип `bank`, название «Карта» (дефолт шаблона; тип и название свободно правятся позже —
+ название всегда, `cash`↔`bank`↔`savings` тоже просто смена одного поля `type_acc`, без каскадных
+ правок `account_rates`/`goals` — это необязательные доп. таблицы, не часть самого счёта, см.
+ `Accounts` выше). **Сумма — не хардкодится в шаблоне, спрашивается у пользователя** прямо в процессе
+ создания (как и при обычном создании любого счёта — `summa` вводится как есть).
+- **1 статья дохода**: «Зарплата».
+- **4 статьи расхода**: «Питание», «ЖКХ», «Проезд», «Развлечение».
+- Схема БД под это **менять не нужно** — шаблон это просто набор `INSERT` в уже существующие
+ `accounts`/`categories` через `AccountRepository`/`CategoryRepository`, никаких новых полей/таблиц.
+
+**Не решено:**
+- **Где кнопка/предложение** — в пустом состоянии дашборда рядом с «+» (сейчас там просто текст «Пока
+ нет ни одного счёта»), или отдельный экран сразу после первого входа/регистрации?
+- **План (`budgets`)** — шаблон заводит только счёт + 5 статей без сумм плана, или сразу дефолтные
+ плановые суммы на текущий месяц по каждой статье?
+- Сама кнопка/логика создания из шаблона — не реализована, только идея и часть решений выше.
## Приоритет фич
diff --git a/App/Controller/IndexController.php b/App/Controller/IndexController.php
index 11c0264..a7687e2 100644
--- a/App/Controller/IndexController.php
+++ b/App/Controller/IndexController.php
@@ -8,41 +8,39 @@
namespace App\Controller;
+use App\Repositories\AccountRepository;
+use App\Repositories\UserRepository;
use Services\Auth;
use System\Classes\Controller;
-use System\Classes\HTTP\Request as HTTPRequest;
use System\Classes\MyException;
-use System\Classes\Request;
-use System\Classes\Validation;
/**
- * Главная страница = страница входа (пока в проекте нет ничего, кроме авторизации).
+ * Дашборд — главная страница после входа (защищена, см. $_auth_protection).
*/
class IndexController extends Controller
{
+ protected bool $_auth_protection = true;
+
/**
* @return string
* @throws MyException
*/
public function indexAction(): string
{
- $request = Request::$current;
- $error = null;
+ $auth_user = Auth::instance()->getUser();
+ $user = (new UserRepository())->getByLogin($auth_user['login']);
- if ($request->method() === HTTPRequest::POST) {
- $validation = Validation::factory($request->post())
- ->label('login', 'Логин')
- ->label('password', 'Пароль')
- ->rule('login', 'required')
- ->rule('password', 'required');
+ $accounts = $user ? (new AccountRepository())->getList('*', ['user_id' => $user->id, 'is_delete' => 0], '`order`') : [];
- if ($validation->check() && Auth::instance()->login($request->post('login'), $request->post('password'))) {
- return $this->renderContent('index', ['error' => null, 'user' => Auth::instance()->getUser()]);
- }
+ $total = array_sum(array_map(
+ static fn ($account) => $account->include_in_total && $account->currency === 'RUB' ? (float)$account->summa : 0,
+ $accounts ?: []
+ ));
- $error = 'Неверный логин или пароль';
- }
-
- return $this->renderContent('index', ['error' => $error, 'user' => Auth::instance()->getUser()]);
+ return $this->render('index', [
+ 'user' => $auth_user,
+ 'accounts' => $accounts ?: [],
+ 'total' => $total,
+ ]);
}
}
diff --git a/App/Controller/LoginController.php b/App/Controller/LoginController.php
new file mode 100644
index 0000000..e3660dd
--- /dev/null
+++ b/App/Controller/LoginController.php
@@ -0,0 +1,118 @@
+loggedIn()) {
+ HTTP::redirect('/');
+ }
+
+ $session = Session::instance();
+ $request = Request::$current;
+
+ if ($request->method() === HTTPRequest::POST) {
+ return $session->get(self::PENDING_KEY) !== null
+ ? $this->handleCodeStep($session)
+ : $this->handleCredentialsStep($session);
+ }
+
+ return $this->renderContent('login', [
+ 'error' => null,
+ 'step' => $session->get(self::PENDING_KEY) !== null ? 'code' : 'credentials',
+ ]);
+ }
+
+ /**
+ * Первый шаг — логин/пароль. При успехе не авторизует сразу, а переводит
+ * на шаг с кодом (см. Auth::verifyCredentials() — проверка без сессии).
+ *
+ * @param Session $session
+ * @return string
+ * @throws MyException
+ */
+ protected function handleCredentialsStep(Session $session): string
+ {
+ $request = Request::$current;
+ $validation = Validation::factory($request->post())
+ ->label('login', 'Логин')
+ ->label('password', 'Пароль')
+ ->rule('login', 'required')
+ ->rule('password', 'required');
+
+ if ($validation->check() && Auth::instance()->verifyCredentials($request->post('login'), $request->post('password'))) {
+ $session->set(self::PENDING_KEY, $request->post('login'));
+ $session->set(self::REMEMBER_KEY, $request->post('remember') !== null);
+
+ return $this->renderContent('login', ['error' => null, 'step' => 'code']);
+ }
+
+ return $this->renderContent('login', ['error' => 'Неверный логин или пароль', 'step' => 'credentials']);
+ }
+
+ /**
+ * Второй шаг — код с почты (заглушка STUB_CODE). При успехе — Auth::forceLogin()
+ * (второй фактор пройден, пароль повторно не проверяется), с учётом «запомнить меня».
+ *
+ * @param Session $session
+ * @return string
+ * @throws MyException
+ */
+ protected function handleCodeStep(Session $session): string
+ {
+ $request = Request::$current;
+ $login = $session->get(self::PENDING_KEY);
+
+ if ($request->post('code') === self::STUB_CODE) {
+ Auth::instance()->forceLogin($login, (bool)$session->get(self::REMEMBER_KEY, false));
+ $session->delete(self::PENDING_KEY)->delete(self::REMEMBER_KEY);
+
+ HTTP::redirect('/');
+ }
+
+ return $this->renderContent('login', ['error' => 'Неверный код', 'step' => 'code']);
+ }
+
+ /**
+ * @return never
+ */
+ public function logoutAction(): never
+ {
+ Auth::instance()->logout();
+ HTTP::redirect('/login');
+ }
+}
diff --git a/App/Repositories/UserRepository.php b/App/Repositories/UserRepository.php
index 3375221..ea02d83 100644
--- a/App/Repositories/UserRepository.php
+++ b/App/Repositories/UserRepository.php
@@ -22,4 +22,17 @@ class UserRepository extends Repository
{
parent::__construct('users', connection: $connection);
}
+
+ /**
+ * Строка users по логину Services\Auth (сопоставление авторизованного пользователя
+ * с владельцем бюджета — см. бюджет_текущий_план.md, users.login отдельно от реальной
+ * авторизации, только для связи с этой строкой).
+ *
+ * @param string $login
+ * @return object|false
+ */
+ public function getByLogin(string $login): mixed
+ {
+ return $this->getItemWhere('login = ' . $this->pdo->pdo()->quote($login));
+ }
}
diff --git a/App/media/css/dashboard.css b/App/media/css/dashboard.css
new file mode 100644
index 0000000..0e9238a
--- /dev/null
+++ b/App/media/css/dashboard.css
@@ -0,0 +1,274 @@
+:root {
+ --bg: #0b0b0f;
+ --surface: #17171d;
+ --surface-2: #1e1e26;
+ --surface-3: #24242d;
+ --ink: #f2f2f5;
+ --ink-soft: #c4c4cc;
+ --muted: #85858f;
+ --shadow: rgba(0, 0, 0, 0.5);
+
+ --teal: #2dd4bf;
+ --green: #34d399;
+ --purple: #a78bfa;
+ --blue: #4c8dff;
+ --orange: #fb923c;
+ --pink: #f472b6;
+ --red: #f87171;
+ --amber: #fbbf24;
+ --gray: #6b7280;
+
+ --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+}
+
+body {
+ background: var(--bg);
+ color: var(--ink);
+ font-family: var(--sans);
+ margin: 0;
+}
+
+main {
+ max-width: 72rem;
+ margin: 0 auto;
+ padding: 1.5rem 1.5rem 6.5rem;
+}
+
+.num { font-variant-numeric: tabular-nums; }
+
+.ico { width: 1.05em; height: 1.05em; flex-shrink: 0; }
+
+/* ---------- шапка: приветствие + баланс ---------- */
+
+.top-row {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1.5rem;
+ flex-wrap: wrap;
+ margin-bottom: 2rem;
+}
+
+.hello {
+ font-size: 0.95rem;
+ color: var(--ink-soft);
+}
+
+.balance-label {
+ font-size: 0.72rem;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--muted);
+ margin-top: 0.6rem;
+}
+
+.balance {
+ font-size: 3rem;
+ font-weight: 800;
+ letter-spacing: -0.02em;
+ line-height: 1;
+}
+
+.balance span { font-size: 1.4rem; font-weight: 600; color: var(--muted); }
+
+.balance-total {
+ font-size: 1.15rem;
+ font-weight: 700;
+ margin-top: 0.4rem;
+ color: var(--ink-soft);
+}
+
+.balance-total .label {
+ font-size: 0.78rem;
+ font-weight: 500;
+ color: var(--muted);
+ margin-right: 0.4rem;
+}
+
+/* ---------- заголовок секции ---------- */
+
+.section-label {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--muted);
+ margin: 2rem 0 0.9rem;
+}
+
+.section-label:first-of-type { margin-top: 0; }
+
+/* ---------- карточки счетов ---------- */
+
+.grid-cards {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+ gap: 0.9rem;
+}
+
+.room-card {
+ position: relative;
+ background: var(--surface);
+ border-radius: 18px;
+ padding: 1rem 1.1rem 1.15rem;
+ overflow: hidden;
+ box-shadow: 0 2px 10px var(--shadow);
+}
+
+.room-card::after {
+ content: "";
+ position: absolute;
+ left: 14%;
+ right: 14%;
+ bottom: 0;
+ height: 3px;
+ border-radius: 3px;
+ background: linear-gradient(90deg, transparent, var(--card-accent, var(--blue)), transparent);
+ opacity: 0.9;
+}
+
+.room-card-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+}
+
+.room-icon {
+ width: 2.1rem;
+ height: 2.1rem;
+ border-radius: 10px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1rem;
+ background: color-mix(in srgb, var(--card-accent, var(--blue)) 20%, transparent);
+ color: var(--card-accent, var(--blue));
+ margin-bottom: 0.7rem;
+}
+
+.room-title { font-size: 0.92rem; font-weight: 600; }
+
+.room-sub {
+ font-size: 0.76rem;
+ color: var(--muted);
+ margin-top: 0.15rem;
+}
+
+.room-value {
+ font-size: 1.3rem;
+ font-weight: 700;
+ margin-top: 0.6rem;
+ color: var(--card-accent, var(--ink));
+}
+
+/* три точки на карточке счёта */
+
+.room-menu { position: relative; }
+
+.room-menu-btn {
+ border: none;
+ background: transparent;
+ color: var(--muted);
+ padding: 0.15rem 0.3rem;
+ border-radius: 6px;
+ cursor: pointer;
+}
+
+.room-menu-btn:hover { color: var(--ink); background: var(--surface-3); }
+
+.room-menu-list {
+ display: none;
+ position: absolute;
+ right: 0;
+ top: 1.8rem;
+ z-index: 5;
+ min-width: 160px;
+ background: var(--surface-2);
+ border-radius: 12px;
+ box-shadow: 0 8px 24px var(--shadow);
+ padding: 0.35rem;
+}
+
+.room-menu.open .room-menu-list { display: block; }
+
+.room-menu-list a {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 0.6rem;
+ border-radius: 8px;
+ font-size: 0.82rem;
+ color: var(--ink-soft);
+}
+
+.room-menu-list a:hover { background: var(--surface-3); color: var(--ink); }
+.room-menu-list a.danger { color: var(--red); }
+
+/* пустое состояние + кнопка добавить */
+
+.empty-state {
+ border: 1px dashed var(--surface-3);
+ border-radius: 18px;
+ padding: 2.5rem 1rem;
+ text-align: center;
+ color: var(--muted);
+}
+
+.btn-add {
+ width: 2.1rem;
+ height: 2.1rem;
+ border-radius: 50%;
+ border: none;
+ background: var(--surface-3);
+ color: var(--ink);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+}
+
+.btn-add:hover { background: var(--blue); }
+
+/* ---------- нижний dock-навбар ---------- */
+
+.dock {
+ position: fixed;
+ left: 50%;
+ 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);
+ z-index: 30;
+ max-width: calc(100vw - 2rem);
+ overflow-x: auto;
+}
+
+.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;
+ color: var(--muted);
+ white-space: nowrap;
+}
+
+.dock-item.active { background: var(--ink); color: #0b0b0f; font-weight: 700; }
+
+@media (max-width: 640px) {
+ .top-row { flex-direction: column; }
+ .balance { font-size: 2.4rem; }
+}
diff --git a/App/media/js/login-code.js b/App/media/js/login-code.js
new file mode 100644
index 0000000..802a468
--- /dev/null
+++ b/App/media/js/login-code.js
@@ -0,0 +1,33 @@
+$(function () {
+ var $resend = $('#resend-code');
+
+ if ($resend.length === 0) {
+ return;
+ }
+
+ var seconds = 30;
+
+ function tick() {
+ if (seconds <= 0) {
+ $resend.prop('disabled', false).text('Отправить код ещё раз');
+ return;
+ }
+
+ $resend.prop('disabled', true).text('Отправить код ещё раз через ' + seconds + ' сек');
+ seconds--;
+ setTimeout(tick, 1000);
+ }
+
+ $resend.on('click', function (e) {
+ e.preventDefault();
+
+ if ($resend.prop('disabled')) {
+ return;
+ }
+
+ seconds = 30;
+ tick();
+ });
+
+ tick();
+});
diff --git a/App/view/Index/index.html b/App/view/Index/index.html
index b68b375..39fe244 100644
--- a/App/view/Index/index.html
+++ b/App/view/Index/index.html
@@ -1,56 +1,114 @@
'ic-wallet',
+ 'bank' => 'ic-card',
+ 'savings' => 'ic-piggy',
+];
?>
-
-
-
-
-
- Бюджет
-
-
-
-
-
-
Вход
-
-
Вы вошли как = htmlspecialchars($user['login'], ENT_QUOTES, 'UTF-8') ?>.