dev
This commit is contained in:
parent
69ce89a695
commit
8021c86e7b
@ -18,3 +18,5 @@
|
|||||||
- [Независимость движка от приложения](feedback_engine_app_independence.md) — Services/System не должны импортировать App\* или знать схему конкретного проекта
|
- [Независимость движка от приложения](feedback_engine_app_independence.md) — Services/System не должны импортировать App\* или знать схему конкретного проекта
|
||||||
- [Бюджет — актуальный план (2026)](бюджет_текущий_план.md) — что реально согласовано в разговоре; старый бюджет_проект_план.md — только источник идей, не требований
|
- [Бюджет — актуальный план (2026)](бюджет_текущий_план.md) — что реально согласовано в разговоре; старый бюджет_проект_план.md — только источник идей, не требований
|
||||||
- [Не переиспользовать память молча после сброса](feedback_no_silent_memory_reuse_after_reset.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
|
||||||
|
|||||||
24
.claude/memory/feedback_no_premature_implementation.md
Normal file
24
.claude/memory/feedback_no_premature_implementation.md
Normal file
@ -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/*`, контроллеры и т.п. под эту фичу, пока пользователь явно не попросит начать
|
||||||
|
реализацию (не путать с уже завершённым слоем БД/фреймворка — там правки кода это часть текущей
|
||||||
|
явно запрошенной задачи, речь именно про фичу «Бюджет», которая всё ещё на стадии обсуждения).
|
||||||
|
Если непонятно, закончилось ли обсуждение — переспросить прямо, не действовать по умолчанию.
|
||||||
@ -24,3 +24,10 @@ metadata:
|
|||||||
Если вопрос пользователя похож на «а мы это ещё где-то используем/будем использовать?» — это, как
|
Если вопрос пользователя похож на «а мы это ещё где-то используем/будем использовать?» — это, как
|
||||||
правило, не команда действовать, а проверка моей логики; сначала ответить прямо, не хвататься за
|
правило, не команда действовать, а проверка моей логики; сначала ответить прямо, не хвататься за
|
||||||
редактирование кода.
|
редактирование кода.
|
||||||
|
|
||||||
|
**Второй кейс (2026-08-12, реализация логина/2FA):** на шаге ввода кода из письма сам добавил кнопку
|
||||||
|
«Назад ко входу» (плюс обработку `back` в `LoginController`) — этого никто не просил, ни в обсуждении
|
||||||
|
2FA, ни в макете дашборда. Пользователь: «просто убери эта кнопка не нужна, она же ни где не пишется
|
||||||
|
ты сам это придумал?». Тот же паттерн, что и с `contentDir()`, но уже не про код-абстракцию, а про
|
||||||
|
UI-элемент — вывод: правило касается не только рефакторинга/абстракций, а вообще любого добавления
|
||||||
|
сверх того, что обсуждалось (лишняя кнопка/поле формы/пункт меню — тот же случай, что и лишний метод).
|
||||||
27
.claude/memory/reference_dashboard_design_artifact.md
Normal file
27
.claude/memory/reference_dashboard_design_artifact.md
Normal file
@ -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` по ссылке выше),
|
||||||
|
не изобретать свой стиль карточек/навигации заново.
|
||||||
@ -145,12 +145,41 @@ CREATE TABLE budget_access_restrictions (
|
|||||||
|
|
||||||
**Курс валюты — автоматически, из внешнего источника (ЦБ РФ)**, не вручную (в отличие от процентной
|
**Курс валюты — автоматически, из внешнего источника (ЦБ РФ)**, не вручную (в отличие от процентной
|
||||||
ставки копилки — там осознанно вручную, см. `Account_rates` ниже; для курса валют внешний источник
|
ставки копилки — там осознанно вручную, см. `Account_rates` ниже; для курса валют внешний источник
|
||||||
есть и он надёжный, незачем дублировать руками). Не спроектировано пока: какой конкретно endpoint ЦБ РФ
|
есть и он надёжный, незачем дублировать руками).
|
||||||
(daily XML/JSON), куда класть клиент — скорее всего новый `Services\CurrencyRate` (по аналогии с
|
|
||||||
`Services\Elasticsearch`/`Services\Mongo` — точка входа + REST/HTTP-клиент), поверх уже существующего
|
**Endpoint — решено.** `https://www.cbr.ru/scripts/XML_daily.asp?date_req=DD/MM/YYYY` — простой GET,
|
||||||
`System\Classes\HTTP\Client\Curl`. Дашборд показывает курс для валют, которые реально встречаются среди
|
плоский XML, без авторизации. Обычный `.asmx` SOAP-веб-сервис ЦБ РФ (`DailyInfoWebServ/DailyInfo.asmx`)
|
||||||
счетов пользователя (не весь список валют ЦБ РФ огулом). **Для дашборда курс — просто «сегодняшнее
|
рассматривали и отклонили — потребовал бы отдельного SOAP-клиента, которого в проекте нет (только
|
||||||
значение» с кэшем на сутки, историю хранить для этого не нужно** (история нужна в другом месте, см. ниже).
|
`Curl`+REST, см. `Services\Elasticsearch\Client` как образец такого же паттерна).
|
||||||
|
|
||||||
|
Формат ответа (проверено на реальном запросе):
|
||||||
|
```xml
|
||||||
|
<ValCurs Date="11.08.2026" name="Foreign Currency Market">
|
||||||
|
<Valute ID="R01235">
|
||||||
|
<NumCode>840</NumCode>
|
||||||
|
<CharCode>USD</CharCode>
|
||||||
|
<Nominal>1</Nominal>
|
||||||
|
<Name>Доллар США</Name>
|
||||||
|
<Value>82,6060</Value>
|
||||||
|
<VunitRate>82,606</VunitRate>
|
||||||
|
</Valute>
|
||||||
|
...
|
||||||
|
</ValCurs>
|
||||||
|
```
|
||||||
|
**Подводные камни при парсинге:**
|
||||||
|
- Кодировка ответа — `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`,
|
**Общая сумма на дашборде** — не сумма вообще всех счетов, а сумма счетов с `include_in_total = true`,
|
||||||
сконвертированная в рубли по текущему курсу для не-рублёвых. Рубль — базовая валюта для агрегации
|
сконвертированная в рубли по текущему курсу для не-рублёвых. Рубль — базовая валюта для агрегации
|
||||||
@ -250,25 +279,78 @@ ALTER TABLE transactions ADD COLUMN rate_to_rub DECIMAL(10, 4) NULL; -- курс
|
|||||||
«пересчитать баланс с нуля из истории» — не для обычной работы, а как инструмент сверки/восстановления
|
«пересчитать баланс с нуля из истории» — не для обычной работы, а как инструмент сверки/восстановления
|
||||||
при подозрении на расхождение (аналог банковской реконсиляции).
|
при подозрении на расхождение (аналог банковской реконсиляции).
|
||||||
|
|
||||||
**Разбивка одной операции на несколько статей — подтверждено, нужно.** Один чек/покупка → несколько
|
**Разбивка одной операции на несколько статей — решено, никакой отдельной сущности не нужно.**
|
||||||
пар (статья, сумма) в рамках одной операции (пример: поход в магазин — 500 ₽ Питание + 300 ₽
|
Изначальная гипотеза про дочернюю таблицу `transaction_items` (шапка + детали) — **неверна,
|
||||||
Хозтовары). Это меняет схему `transactions`: `categorie_id`/`summa` на самой строке `transactions`
|
отклонена**. По факту «разбивка» — это просто **две (или больше) обычных, полностью независимых
|
||||||
достаточно только для НЕразбитых операций; для разбитых нужна отдельная дочерняя таблица (условно
|
строки в `transactions`** (пример: поход в магазин на 800 ₽ → строка 500 ₽ Питание + строка 300 ₽
|
||||||
`transaction_items`: `id`, `transaction_id`, `categorie_id`, `summa`) — шапка `transactions` держит
|
Хозтовары, каждая сама по себе). Никакой связи между ними в БД не хранится — они не привязаны друг к
|
||||||
дату/счета/общую сумму/тип, детали разбивки — в дочерних строках. Не спроектировано окончательно:
|
другу как «части одного чека», просто совпадают по дате/счёту. Схема `transactions` уже полностью
|
||||||
нужна ли разбивка для `income`/`transfer` тоже, или только для `expense` (вероятно только `expense` —
|
это поддерживает как есть, менять её под эту фичу не нужно.
|
||||||
transfer и так без категории, доход обычно один источник). **Два способа ввода:**
|
|
||||||
1. **Голосовой** — пользователь надиктовывает через AI API (например «Питание 500 рублей, Хозтовары
|
|
||||||
300 рублей»), система распознаёт речь и разбирает текст на пары статья/сумма автоматически.
|
|
||||||
2. **Ручной** — то же самое, но вводится руками, пара за парой.
|
|
||||||
Не спроектировано: какой конкретно AI API для голоса (Claude API уже упоминался в старом
|
|
||||||
`бюджет_проект_план.md` как ИИ-помощник — возможно, тот же), как обрабатывать ошибки распознавания.
|
|
||||||
|
|
||||||
**Лог правок/удалений — отдельная таблица в БД** (не общий файловый `System\Classes\Log` — тот
|
**Два способа ввода — оба просто заводят обычные транзакции:**
|
||||||
подходит для аудита запросов вообще, но не для точечных «покажи все правки операции №123»). Правка и
|
1. **Голосовой** — пользователь надиктовывает через ИИ (например «Питание 500 рублей, Хозтовары 300
|
||||||
удаление разрешены всегда, без ограничений по давности — но каждое изменение логируется: кто, когда,
|
рублей»), ИИ присылает JSON на несколько транзакций, каждая заводится как обычная строка
|
||||||
что именно поменялось. Не спроектировано: точная структура (`transaction_history`: id, transaction_id,
|
`transactions`.
|
||||||
user_id, changed_at, action, old_values/new_values — набросок, не финал).
|
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 (план/факт)
|
## Budgets (план/факт)
|
||||||
|
|
||||||
@ -281,13 +363,48 @@ user_id, changed_at, action, old_values/new_values — набросок, не ф
|
|||||||
вводится план по «Проезду» на весь 2027-й — 12 чисел за один заход). Влияет на форму ввода — нужна не
|
вводится план по «Проезду» на весь 2027-й — 12 чисел за один заход). Влияет на форму ввода — нужна не
|
||||||
«план на текущий месяц», а «план на год» с 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` (более ранняя версия этого
|
причина оставить таблицу, а не одно поле `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::setRate(int $account_id, float $rate_percent, string $valid_from)`.
|
||||||
Сейчас в `AccountRateRepository` есть только `getCurrentRate()` (чтение, уже реализовано и
|
Сейчас в `AccountRateRepository` есть только `getCurrentRate()` (чтение, уже реализовано и
|
||||||
протестировано на реальной БД) — `setRate()` ещё не написан.
|
протестировано на реальной БД) — `setRate()` ещё не написан.
|
||||||
- Прогноз «надо ≈X ₽/мес» у цели — открытый вопрос: линейно (без процента, проще) или честной
|
- Прогноз «надо ≈X ₽/мес» у цели — **решено**: формула аннуитета со сложным процентом по текущей
|
||||||
формулой аннуитета со сложным процентом (то, что буквально написано в `Goals` ниже — «по текущей
|
ставке, дневная капитализация (не линейно) — см. выше.
|
||||||
ставке») — не решено, обсудить перед реализацией расчёта.
|
|
||||||
|
|
||||||
## Goals (цель для копилки)
|
## Goals (цель для копилки)
|
||||||
|
|
||||||
@ -354,16 +478,64 @@ user_id, changed_at, action, old_values/new_values — набросок, не ф
|
|||||||
что она не изменится до дедлайна; пересчитывается при просмотре/смене ставки.
|
что она не изменится до дедлайна; пересчитывается при просмотре/смене ставки.
|
||||||
|
|
||||||
**Проценты, начисленные на копилку** — `income`-транзакция на счёт-копилку с одной системной
|
**Проценты, начисленные на копилку** — `income`-транзакция на счёт-копилку с одной системной
|
||||||
категорией «Проценты по вкладам/целям» (одна на все копилки, не по одной на каждую) — заводится
|
категорией «Проценты по вкладам/целям» (одна на все копилки, не по одной на каждую, заводится
|
||||||
пользователю автоматически при первом использовании.
|
пользователю автоматически при первом использовании). **Сумма — вручную, кнопкой «начислить
|
||||||
|
проценты»**: пользователь сам вписывает число (система не считает и не подставляет его сама, см.
|
||||||
|
`Account_rates` выше — расчёт по дневной ставке нужен только как ориентир для прогноза, не для
|
||||||
|
автозаполнения этой транзакции).
|
||||||
|
|
||||||
|
**Просроченный `deadline`, цель не достигнута — без особой обработки.** Просто показываем прогресс
|
||||||
|
как есть (например «10 000 из 15 000»), без подсветки/предупреждения и без предложения сдвинуть
|
||||||
|
дедлайн.
|
||||||
|
|
||||||
|
**UI — на дашборде, не отдельная страница/форма.** Карточки счетов на дашборде: кнопка «+» —
|
||||||
|
добавить счёт; на каждой карточке-копилке — меню «три точки» → карандаш (редактировать) / корзина
|
||||||
|
(удалить), как в типичных современных интерфейсах. Создание и редактирование — через модалку, не
|
||||||
|
отдельную страницу. Раз цель — надстройка над `savings`-счётом (не отдельная сущность), вероятно
|
||||||
|
поля цели (`target_summa`/`deadline`) — в той же модалке, что и сам счёт; отдельной модалки под
|
||||||
|
цель не обсуждали.
|
||||||
|
|
||||||
|
**Корзина на карточке копилки — архивация счёта целиком** (`is_delete = 1`, тот же приём, что и у
|
||||||
|
обычных счетов), не просто снятие цели. **Если на счёте остались деньги** (`summa > 0`) — перед
|
||||||
|
архивацией спросить, на какой счёт перевести остаток.
|
||||||
|
|
||||||
|
**В форме закрытия — два отдельных поля суммы: «остаток» и «проценты»**, не одно. Перевод на
|
||||||
|
счёт-получатель уходит одной суммой (сложение обеих), но перед этим переводом система заводит на
|
||||||
|
саму копилку `income`-транзакцию на сумму из поля «проценты» с категорией «Проценты по вкладам/целям»
|
||||||
|
(та же, что и при обычном ручном начислении, см. выше) — это финальное начисление процентов перед
|
||||||
|
закрытием, совмещённое с самим переводом в один шаг формы. Раздельные поля нужны именно ради этого:
|
||||||
|
не потерять проценты как отдельную статью дохода в отчётах (transfer — всегда без категории, см.
|
||||||
|
`Transactions`), просто слив всё в одну сумму перевода это бы стёр.
|
||||||
|
|
||||||
## Быстрый ввод операции — замена идее Telegram-бота
|
## Быстрый ввод операции — замена идее Telegram-бота
|
||||||
|
|
||||||
В старом `бюджет_проект_план.md` (заметки ДО сброса, источник идей) был пункт «Telegram-бот для
|
В старом `бюджет_проект_план.md` (заметки ДО сброса, источник идей) был пункт «Telegram-бот для
|
||||||
быстрого ввода трат». **Переиграно**: вместо внешнего бота — страница быстрого добавления операции
|
быстрого ввода трат». **Переиграно**: вместо внешнего бота — страница быстрого добавления операции
|
||||||
внутри самого Bicycle (тот же стек, без бот-токена/вебхука/парсинга свободного текста от Telegram API).
|
внутри самого Bicycle (тот же стек, без бот-токена/вебхука/парсинга свободного текста от Telegram API).
|
||||||
**Не решено** — закреплять ли её как PWA-иконку на экране телефона (`View::setManifest()` уже
|
**PWA-иконка — решено, не делаем.** Обычная страница в браузере, без `View::setManifest()`.
|
||||||
поддержан в проекте, см. CLAUDE.md) — пользователь пока не уверен, обсудить отдельно перед реализацией.
|
|
||||||
|
## Онбординг нового пользователя — создание из шаблона
|
||||||
|
|
||||||
|
Когда у только что созданного пользователя всё пусто (0 счетов) — предложить создать данные не с
|
||||||
|
нуля, а из готового шаблона (пока только один шаблон, без выбора):
|
||||||
|
|
||||||
|
**Решено:**
|
||||||
|
- **1 счёт**: тип `bank`, название «Карта» (дефолт шаблона; тип и название свободно правятся позже —
|
||||||
|
название всегда, `cash`↔`bank`↔`savings` тоже просто смена одного поля `type_acc`, без каскадных
|
||||||
|
правок `account_rates`/`goals` — это необязательные доп. таблицы, не часть самого счёта, см.
|
||||||
|
`Accounts` выше). **Сумма — не хардкодится в шаблоне, спрашивается у пользователя** прямо в процессе
|
||||||
|
создания (как и при обычном создании любого счёта — `summa` вводится как есть).
|
||||||
|
- **1 статья дохода**: «Зарплата».
|
||||||
|
- **4 статьи расхода**: «Питание», «ЖКХ», «Проезд», «Развлечение».
|
||||||
|
- Схема БД под это **менять не нужно** — шаблон это просто набор `INSERT` в уже существующие
|
||||||
|
`accounts`/`categories` через `AccountRepository`/`CategoryRepository`, никаких новых полей/таблиц.
|
||||||
|
|
||||||
|
**Не решено:**
|
||||||
|
- **Где кнопка/предложение** — в пустом состоянии дашборда рядом с «+» (сейчас там просто текст «Пока
|
||||||
|
нет ни одного счёта»), или отдельный экран сразу после первого входа/регистрации?
|
||||||
|
- **План (`budgets`)** — шаблон заводит только счёт + 5 статей без сумм плана, или сразу дефолтные
|
||||||
|
плановые суммы на текущий месяц по каждой статье?
|
||||||
|
- Сама кнопка/логика создания из шаблона — не реализована, только идея и часть решений выше.
|
||||||
|
|
||||||
## Приоритет фич
|
## Приоритет фич
|
||||||
|
|
||||||
|
|||||||
@ -8,41 +8,39 @@
|
|||||||
|
|
||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Repositories\AccountRepository;
|
||||||
|
use App\Repositories\UserRepository;
|
||||||
use Services\Auth;
|
use Services\Auth;
|
||||||
use System\Classes\Controller;
|
use System\Classes\Controller;
|
||||||
use System\Classes\HTTP\Request as HTTPRequest;
|
|
||||||
use System\Classes\MyException;
|
use System\Classes\MyException;
|
||||||
use System\Classes\Request;
|
|
||||||
use System\Classes\Validation;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Главная страница = страница входа (пока в проекте нет ничего, кроме авторизации).
|
* Дашборд — главная страница после входа (защищена, см. $_auth_protection).
|
||||||
*/
|
*/
|
||||||
class IndexController extends Controller
|
class IndexController extends Controller
|
||||||
{
|
{
|
||||||
|
protected bool $_auth_protection = true;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return string
|
* @return string
|
||||||
* @throws MyException
|
* @throws MyException
|
||||||
*/
|
*/
|
||||||
public function indexAction(): string
|
public function indexAction(): string
|
||||||
{
|
{
|
||||||
$request = Request::$current;
|
$auth_user = Auth::instance()->getUser();
|
||||||
$error = null;
|
$user = (new UserRepository())->getByLogin($auth_user['login']);
|
||||||
|
|
||||||
if ($request->method() === HTTPRequest::POST) {
|
$accounts = $user ? (new AccountRepository())->getList('*', ['user_id' => $user->id, 'is_delete' => 0], '`order`') : [];
|
||||||
$validation = Validation::factory($request->post())
|
|
||||||
->label('login', 'Логин')
|
|
||||||
->label('password', 'Пароль')
|
|
||||||
->rule('login', 'required')
|
|
||||||
->rule('password', 'required');
|
|
||||||
|
|
||||||
if ($validation->check() && Auth::instance()->login($request->post('login'), $request->post('password'))) {
|
$total = array_sum(array_map(
|
||||||
return $this->renderContent('index', ['error' => null, 'user' => Auth::instance()->getUser()]);
|
static fn ($account) => $account->include_in_total && $account->currency === 'RUB' ? (float)$account->summa : 0,
|
||||||
}
|
$accounts ?: []
|
||||||
|
));
|
||||||
|
|
||||||
$error = 'Неверный логин или пароль';
|
return $this->render('index', [
|
||||||
}
|
'user' => $auth_user,
|
||||||
|
'accounts' => $accounts ?: [],
|
||||||
return $this->renderContent('index', ['error' => $error, 'user' => Auth::instance()->getUser()]);
|
'total' => $total,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
118
App/Controller/LoginController.php
Normal file
118
App/Controller/LoginController.php
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @author Egor Isaev
|
||||||
|
* @description LoginController.php
|
||||||
|
* @copyright (c) 12/08/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use Services\Auth;
|
||||||
|
use System\Classes\Controller;
|
||||||
|
use System\Classes\HTTP;
|
||||||
|
use System\Classes\HTTP\Request as HTTPRequest;
|
||||||
|
use System\Classes\MyException;
|
||||||
|
use System\Classes\Request;
|
||||||
|
use System\Classes\Session;
|
||||||
|
use System\Classes\Validation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вход/выход, в два шага (логин/пароль → код с почты, пока заглушка '1234' —
|
||||||
|
* реальной отправки письма нет). /login — форма и обработка, /login/logout — выход.
|
||||||
|
*/
|
||||||
|
class LoginController extends Controller
|
||||||
|
{
|
||||||
|
/** @var string Ключ сессии — логин, прошедший первый шаг, ждёт код */
|
||||||
|
protected const PENDING_KEY = 'pending_2fa_login';
|
||||||
|
|
||||||
|
/** @var string Ключ сессии — «запомнить меня», выбрано на первом шаге, применяется на втором */
|
||||||
|
protected const REMEMBER_KEY = 'pending_2fa_remember';
|
||||||
|
|
||||||
|
/** @var string Заглушка кода из письма — реальная отправка не реализована */
|
||||||
|
protected const STUB_CODE = '1234';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
* @throws MyException
|
||||||
|
*/
|
||||||
|
public function indexAction(): string
|
||||||
|
{
|
||||||
|
if (Auth::instance()->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -22,4 +22,17 @@ class UserRepository extends Repository
|
|||||||
{
|
{
|
||||||
parent::__construct('users', connection: $connection);
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
274
App/media/css/dashboard.css
Normal file
274
App/media/css/dashboard.css
Normal file
@ -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; }
|
||||||
|
}
|
||||||
33
App/media/js/login-code.js
Normal file
33
App/media/js/login-code.js
Normal file
@ -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();
|
||||||
|
});
|
||||||
@ -1,56 +1,114 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* @package Bicycle
|
* @package Bicycle
|
||||||
* @description index.html — главная страница = вход. Без общего layout: пока единственная страница проекта, своя HTML-обвязка прямо тут.
|
* @description index.html — дашборд/«Обзор» (главная страница после входа). Дизайн — по
|
||||||
|
* согласованному макету (тёмная тема, room-card, dock-навбар), рендерится
|
||||||
|
* внутри layout.html через Controller::render().
|
||||||
*
|
*
|
||||||
* @var string|null $error Сообщение об ошибке (неверные данные)
|
* @var array $user Текущий авторизованный пользователь (без пароля)
|
||||||
* @var array|null $user Текущий авторизованный пользователь (без пароля), null — гость
|
* @var object[] $accounts Счета пользователя (accounts, не архивные)
|
||||||
|
* @var float $total Сумма счетов с include_in_total=1 в рублях
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use System\Classes\CSRF;
|
$type_icons = [
|
||||||
use System\Classes\ProfilerToolbar;
|
'cash' => 'ic-wallet',
|
||||||
|
'bank' => 'ic-card',
|
||||||
|
'savings' => 'ic-piggy',
|
||||||
|
];
|
||||||
|
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Бюджет</title>
|
|
||||||
<link rel="stylesheet" href="/vendor/twbs/bootstrap/dist/css/bootstrap.min.css">
|
|
||||||
</head>
|
|
||||||
<body class="d-flex align-items-center justify-content-center bg-body-secondary" style="min-height: 100vh;">
|
|
||||||
<div class="card shadow-sm" style="width: 100%; max-width: 400px;">
|
|
||||||
<div class="card-body p-4">
|
|
||||||
<h1 class="h3 mb-3">Вход</h1>
|
|
||||||
|
|
||||||
<?php if ($user !== null): ?>
|
<svg width="0" height="0" style="position:absolute">
|
||||||
<p>Вы вошли как <strong><?= htmlspecialchars($user['login'], ENT_QUOTES, 'UTF-8') ?></strong>.</p>
|
<defs>
|
||||||
<?= ProfilerToolbar::render() ?>
|
<symbol id="ic-wallet" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<?php else: ?>
|
<rect x="2" y="6" width="20" height="14" rx="2"/>
|
||||||
<?php if ($error !== null): ?>
|
<path d="M16 12h4v4h-4a2 2 0 0 1 0-4Z"/>
|
||||||
<div class="alert alert-danger"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
|
</symbol>
|
||||||
<?php endif; ?>
|
<symbol id="ic-card" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="2" y="5" width="20" height="14" rx="2"/>
|
||||||
|
<line x1="2" y1="10" x2="22" y2="10"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="ic-piggy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="9" width="18" height="9" rx="4.5"/>
|
||||||
|
<line x1="9" y1="13.5" x2="14" y2="13.5"/>
|
||||||
|
<circle cx="17" cy="6" r="2"/>
|
||||||
|
<line x1="17" y1="8" x2="17" y2="9"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="ic-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="ic-dots" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<circle cx="12" cy="5" r="1.6"/>
|
||||||
|
<circle cx="12" cy="12" r="1.6"/>
|
||||||
|
<circle cx="12" cy="19" r="1.6"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="ic-pencil" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 20h9"/>
|
||||||
|
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="ic-trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 6h18"/>
|
||||||
|
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||||
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="ic-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 10.5 12 3l9 7.5"/>
|
||||||
|
<path d="M5 9.5V20a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1V9.5"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="ic-tag" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 2 2 12l9 9 11-11V2Z"/>
|
||||||
|
<circle cx="7.5" cy="7.5" r="1.5"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="ic-swap" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M7 7h11l-3-3"/>
|
||||||
|
<path d="M17 17H6l3 3"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="ic-target" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="9"/>
|
||||||
|
<circle cx="12" cy="12" r="5"/>
|
||||||
|
<circle cx="12" cy="12" r="1.5"/>
|
||||||
|
</symbol>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
|
||||||
<form method="post" action="/">
|
<div class="top-row">
|
||||||
<?= CSRF::field() ?>
|
<div>
|
||||||
|
<div class="hello">Здравствуйте, <?= htmlspecialchars($user['full_name'] ?? $user['login'], ENT_QUOTES, 'UTF-8') ?></div>
|
||||||
<div class="mb-3">
|
<div class="balance-label">Доступно сейчас</div>
|
||||||
<label class="form-label">Логин
|
<div class="balance num"><?= number_format($total, 0, ',', ' ') ?> <span>₽</span></div>
|
||||||
<input type="text" name="login" class="form-control" autocomplete="username" autofocus>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Пароль
|
|
||||||
<input type="password" name="password" class="form-control" autocomplete="current-password">
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Войти</button>
|
|
||||||
</form>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
|
||||||
</html>
|
<div class="section-label">
|
||||||
|
<span>Счета</span>
|
||||||
|
<button type="button" class="btn-add" title="Добавить счёт"><svg class="ico"><use href="#ic-plus"/></svg></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (empty($accounts)): ?>
|
||||||
|
<div class="empty-state">Пока нет ни одного счёта — нажмите «+», чтобы добавить первый</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="grid-cards">
|
||||||
|
<?php foreach ($accounts as $account): ?>
|
||||||
|
<div class="room-card" style="--card-accent:var(--blue)">
|
||||||
|
<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')">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="room-title"><?= htmlspecialchars($account->title, ENT_QUOTES, 'UTF-8') ?></div>
|
||||||
|
<div class="room-value num">
|
||||||
|
<?= number_format((float)$account->summa, 2, ',', ' ') ?>
|
||||||
|
<?= htmlspecialchars($account->currency, ENT_QUOTES, 'UTF-8') ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|||||||
178
App/view/Login/login.html
Normal file
178
App/view/Login/login.html
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @description login.html — страница входа, два шага (логин/пароль → код с почты).
|
||||||
|
* Без общего layout — своя HTML-обвязка, страница входа не показывает
|
||||||
|
* навигацию сайта.
|
||||||
|
*
|
||||||
|
* @var string|null $error Сообщение об ошибке
|
||||||
|
* @var string $step 'credentials' | 'code'
|
||||||
|
*/
|
||||||
|
|
||||||
|
use System\Classes\CSRF;
|
||||||
|
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Вход — Бюджет</title>
|
||||||
|
<link rel="stylesheet" href="/vendor/twbs/bootstrap-icons/font/bootstrap-icons.css">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0b0b0f;
|
||||||
|
--surface: #17171d;
|
||||||
|
--ink: #f2f2f5;
|
||||||
|
--ink-soft: #c4c4cc;
|
||||||
|
--muted: #85858f;
|
||||||
|
--blue: #4c8dff;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
box-shadow: 0 1.5rem 3rem rgba(0, 0, 0, .5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-icon {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: color-mix(in srgb, var(--blue) 20%, transparent);
|
||||||
|
color: var(--blue);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: 1.1rem; text-align: center; margin: 0 0 .5rem; }
|
||||||
|
|
||||||
|
.step-hint { font-size: .82rem; color: var(--muted); text-align: center; margin-bottom: 1.5rem; }
|
||||||
|
|
||||||
|
.field { margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
label { display: block; font-size: .8rem; color: var(--ink-soft); margin-bottom: .35rem; }
|
||||||
|
|
||||||
|
input[type=text], input[type=password] {
|
||||||
|
width: 100%;
|
||||||
|
background: #0b0b0f;
|
||||||
|
border: 1px solid #2a2a33;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: .65rem .8rem;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: .95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus { outline: none; border-color: var(--blue); }
|
||||||
|
|
||||||
|
.remember { display: flex; align-items: center; gap: .5rem; font-size: .85rem; color: var(--ink-soft); margin-bottom: 1.25rem; }
|
||||||
|
|
||||||
|
button[type=submit] {
|
||||||
|
width: 100%;
|
||||||
|
padding: .7rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--blue);
|
||||||
|
color: #fff;
|
||||||
|
font-size: .95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[type=submit]:hover { opacity: .9; }
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: .82rem;
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
background: color-mix(in srgb, #f87171 20%, transparent);
|
||||||
|
color: #f87171;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: .6rem .8rem;
|
||||||
|
font-size: .85rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-icon"><i class="bi bi-piggy-bank"></i></div>
|
||||||
|
<h1>Бюджет</h1>
|
||||||
|
|
||||||
|
<?php if ($error !== null): ?>
|
||||||
|
<div class="alert"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($step === 'code'): ?>
|
||||||
|
<div class="step-hint">Код отправлен на почту</div>
|
||||||
|
|
||||||
|
<form method="post" action="/login">
|
||||||
|
<?= CSRF::field() ?>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="code">Код из письма</label>
|
||||||
|
<input type="text" id="code" name="code" inputmode="numeric" autocomplete="one-time-code" autofocus>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit">Подтвердить</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<button type="button" id="resend-code" class="back-link" disabled>Отправить код ещё раз</button>
|
||||||
|
|
||||||
|
<script src="/vendor/components/jquery/jquery.min.js"></script>
|
||||||
|
<script src="/App/media/js/login-code.js"></script>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="step-hint">Введите логин и пароль</div>
|
||||||
|
|
||||||
|
<form method="post" action="/login">
|
||||||
|
<?= CSRF::field() ?>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="login">Логин</label>
|
||||||
|
<input type="text" id="login" name="login" autocomplete="username" autofocus>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">Пароль</label>
|
||||||
|
<input type="password" id="password" name="password" autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="remember">
|
||||||
|
<input type="checkbox" name="remember" value="1">
|
||||||
|
Запомнить меня
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit">Войти</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -7,8 +7,10 @@
|
|||||||
* @var string $content Содержимое страницы
|
* @var string $content Содержимое страницы
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
use Services\Auth;
|
||||||
use System\Classes\ProfilerToolbar;
|
use System\Classes\ProfilerToolbar;
|
||||||
|
|
||||||
|
$auth_user = Auth::instance()->getUser();
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
@ -18,17 +20,58 @@ use System\Classes\ProfilerToolbar;
|
|||||||
<title>Бюджет</title>
|
<title>Бюджет</title>
|
||||||
<link rel="stylesheet" href="/vendor/twbs/bootstrap/dist/css/bootstrap.min.css">
|
<link rel="stylesheet" href="/vendor/twbs/bootstrap/dist/css/bootstrap.min.css">
|
||||||
<link rel="stylesheet" href="/vendor/twbs/bootstrap-icons/font/bootstrap-icons.css">
|
<link rel="stylesheet" href="/vendor/twbs/bootstrap-icons/font/bootstrap-icons.css">
|
||||||
|
<link rel="stylesheet" href="/App/media/css/dashboard.css">
|
||||||
<?php $this->getStyles(); ?>
|
<?php $this->getStyles(); ?>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="navbar navbar-expand navbar-dark bg-dark">
|
<svg width="0" height="0" style="position:absolute">
|
||||||
<div class="container">
|
<defs>
|
||||||
<a class="navbar-brand" href="/">Бюджет</a>
|
<symbol id="ic-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
</div>
|
<path d="M3 10.5 12 3l9 7.5"/>
|
||||||
</nav>
|
<path d="M5 9.5V20a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1V9.5"/>
|
||||||
<main class="container py-3">
|
</symbol>
|
||||||
|
<symbol id="ic-card" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="2" y="5" width="20" height="14" rx="2"/>
|
||||||
|
<line x1="2" y1="10" x2="22" y2="10"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="ic-tag" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 2 2 12l9 9 11-11V2Z"/>
|
||||||
|
<circle cx="7.5" cy="7.5" r="1.5"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="ic-swap" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M7 7h11l-3-3"/>
|
||||||
|
<path d="M17 17H6l3 3"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="ic-target" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="9"/>
|
||||||
|
<circle cx="12" cy="12" r="5"/>
|
||||||
|
<circle cx="12" cy="12" r="1.5"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="ic-logout" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||||
|
<polyline points="16 17 21 12 16 7"/>
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||||
|
</symbol>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
<?php if ($auth_user !== null): ?>
|
||||||
|
<a href="/login/logout" title="Выйти (<?= htmlspecialchars($auth_user['login'], ENT_QUOTES, 'UTF-8') ?>)"
|
||||||
|
style="position:fixed;top:1rem;right:1rem;z-index:30;color:#85858f;">
|
||||||
|
<svg style="width:1.3rem;height:1.3rem;"><use href="#ic-logout"/></svg>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<main>
|
||||||
<?= $content ?>
|
<?= $content ?>
|
||||||
</main>
|
</main>
|
||||||
|
<?php if ($auth_user !== null): ?>
|
||||||
|
<nav class="dock">
|
||||||
|
<a href="/" class="dock-item active"><span class="ic"><svg class="ico"><use href="#ic-home"/></svg></span>Обзор</a>
|
||||||
|
<div class="dock-item"><span class="ic"><svg class="ico"><use href="#ic-card"/></svg></span>Счета</div>
|
||||||
|
<div class="dock-item"><span class="ic"><svg class="ico"><use href="#ic-tag"/></svg></span>Категории</div>
|
||||||
|
<div class="dock-item"><span class="ic"><svg class="ico"><use href="#ic-swap"/></svg></span>Операции</div>
|
||||||
|
<div class="dock-item"><span class="ic"><svg class="ico"><use href="#ic-target"/></svg></span>Цели</div>
|
||||||
|
</nav>
|
||||||
|
<?php endif; ?>
|
||||||
<script src="/vendor/components/jquery/jquery.min.js"></script>
|
<script src="/vendor/components/jquery/jquery.min.js"></script>
|
||||||
<script src="/vendor/twbs/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="/vendor/twbs/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<?php $this->getScripts(); ?>
|
<?php $this->getScripts(); ?>
|
||||||
|
|||||||
20
CLAUDE.md
20
CLAUDE.md
@ -36,7 +36,7 @@ index.php — точка входа; константы
|
|||||||
System/Classes/ — ядро фреймворка (namespace System\Classes\)
|
System/Classes/ — ядро фреймворка (namespace System\Classes\)
|
||||||
System/Classes/HTTP/ — HTTP-инфраструктура: интерфейсы, заголовки, исключения
|
System/Classes/HTTP/ — HTTP-инфраструктура: интерфейсы, заголовки, исключения
|
||||||
System/Classes/HTTP/Client/ — исходящие HTTP-запросы (Curl, Request, Response)
|
System/Classes/HTTP/Client/ — исходящие HTTP-запросы (Curl, Request, Response)
|
||||||
System/Classes/HTTP/Exception/ — HTTPException_302/403/404
|
System/Classes/HTTP/Exception/ — HTTPException_403/404
|
||||||
App/Controller/ — контроллеры приложения (namespace App\Controller\)
|
App/Controller/ — контроллеры приложения (namespace App\Controller\)
|
||||||
App/Repositories/ — конкретные репозитории (extends System\Classes\Repository), например BudgetRepository
|
App/Repositories/ — конкретные репозитории (extends System\Classes\Repository), например BudgetRepository
|
||||||
App/config/ — конфиги (config.php; config.local.php — в .gitignore)
|
App/config/ — конфиги (config.php; config.local.php — в .gitignore)
|
||||||
@ -99,8 +99,7 @@ PhpStorm может показывать предупреждение «Namespac
|
|||||||
| `System\Classes\HTTP\Header` | Extends `ArrayObject`; `send()`, `__toString()` → RFC-формат |
|
| `System\Classes\HTTP\Header` | Extends `ArrayObject`; `send()`, `__toString()` → RFC-формат |
|
||||||
| `System\Classes\HTTP\Message` | Интерфейс: `protocol()`, `headers()`, `body()`, `render()` |
|
| `System\Classes\HTTP\Message` | Интерфейс: `protocol()`, `headers()`, `body()`, `render()` |
|
||||||
| `System\Classes\HTTP\Request` | Интерфейс входящего запроса; константы методов GET/POST/PUT/... |
|
| `System\Classes\HTTP\Request` | Интерфейс входящего запроса; константы методов GET/POST/PUT/... |
|
||||||
| `System\Classes\HTTP\HTTPException` | HTTP-исключение; `factory(int $code)`, `getResponse(): Response` |
|
| `System\Classes\HTTP\HTTPException` | HTTP-исключение для действительно исключительных кодов (throw + `MyException::handler()`); `factory(int $code)`, `getResponse(): Response`. Редиректы (302) через неё не делаются — не исключение, а штатный поток, см. `HTTP::redirect()` |
|
||||||
| `System\Classes\HTTP\Exception\HTTPException_302` | Редирект через `HTTP::redirect()` |
|
|
||||||
| `System\Classes\HTTP\Exception\HTTPException_403` | 403 Forbidden |
|
| `System\Classes\HTTP\Exception\HTTPException_403` | 403 Forbidden |
|
||||||
| `System\Classes\HTTP\Exception\HTTPException_404` | 404 Not Found |
|
| `System\Classes\HTTP\Exception\HTTPException_404` | 404 Not Found |
|
||||||
| `System\Classes\HTTP\Client\Curl` | Исполнитель исходящих HTTP-запросов через cURL |
|
| `System\Classes\HTTP\Client\Curl` | Исполнитель исходящих HTTP-запросов через cURL |
|
||||||
@ -296,10 +295,13 @@ return [
|
|||||||
из конфига). `$_auth_roles` — список разрешённых ролей (`['admin']`, `['admin', 'manager']`);
|
из конфига). `$_auth_roles` — список разрешённых ролей (`['admin']`, `['admin', 'manager']`);
|
||||||
пустой массив (по умолчанию) — любой авторизованный, без проверки роли.
|
пустой массив (по умолчанию) — любой авторизованный, без проверки роли.
|
||||||
|
|
||||||
Проверка — в `Controller::before()`: нет авторизации → редирект на `/login` (**не** `throw`:
|
Проверка — в `Controller::before()`: нет авторизации → `HTTP::redirect('/login')` напрямую (не через
|
||||||
неперехваченные исключения уходят в `MyException::handler()`, который не вызывает `getResponse()`,
|
`HTTPException` — редирект гостя на логин не исключительная ситуация, а штатное поведение; раньше это
|
||||||
поэтому для 302 вызывается `HTTPException::factory(302, url)->getResponse()` напрямую — она сама
|
делалось через несуществующий больше `HTTPException_302`, но `throw` этого исключения ушёл бы в
|
||||||
делает `Location` + `exit`); авторизован, но роль не подходит → `throw HTTPException::factory(403)`.
|
`MyException::handler()`, который не вызывает `getResponse()` и настоящий редирект не отправил бы —
|
||||||
|
сам класс требовал прямого вызова `->getResponse()` без throw, что нарушало контракт остальных
|
||||||
|
подклассов `HTTPException` и вводило в заблуждение; убран целиком); авторизован, но роль не подходит →
|
||||||
|
`throw HTTPException::factory(403)`.
|
||||||
`App\Controller\Admin\AdminController` включает `$_auth_protection = true` и `$_auth_roles = ['admin']`
|
`App\Controller\Admin\AdminController` включает `$_auth_protection = true` и `$_auth_roles = ['admin']`
|
||||||
для всей админки.
|
для всей админки.
|
||||||
|
|
||||||
@ -544,7 +546,9 @@ throw HTTPException::factory(404, 'Страница :url не найдена', [
|
|||||||
$response = HTTPException::factory(403)->getResponse();
|
$response = HTTPException::factory(403)->getResponse();
|
||||||
```
|
```
|
||||||
|
|
||||||
`factory(int $code)` создаёт `HTTPException_302/403/404` или базовый класс для неизвестных кодов.
|
`factory(int $code)` создаёт `HTTPException_403/404` или базовый класс для неизвестных кодов. Только
|
||||||
|
для действительно исключительных ситуаций (throw); для редиректов — `System\Classes\HTTP::redirect()`
|
||||||
|
напрямую, не через эту иерархию (см. раздел Auth выше — почему).
|
||||||
|
|
||||||
### Routing (Route::resolve)
|
### Routing (Route::resolve)
|
||||||
|
|
||||||
|
|||||||
@ -25,6 +25,28 @@ interface AuthDriver
|
|||||||
*/
|
*/
|
||||||
public function login(string $login, string $password): bool;
|
public function login(string $login, string $password): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет логин/пароль, не авторизуя (не трогает сессию) — первый шаг
|
||||||
|
* двухфакторной авторизации: логин/пароль верны, но сессия ещё не
|
||||||
|
* выставляется, пока не пройдёт второй фактор (см. forceLogin()).
|
||||||
|
*
|
||||||
|
* @param string $login
|
||||||
|
* @param string $password
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function verifyCredentials(string $login, string $password): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Авторизует пользователя без проверки пароля — второй шаг двухфакторной
|
||||||
|
* авторизации (после verifyCredentials() и успешной проверки кода).
|
||||||
|
*
|
||||||
|
* @param string $login
|
||||||
|
* @param bool $remember «Запомнить меня» — не спрашивать логин повторно
|
||||||
|
* до явного logout() (см. logout())
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function forceLogin(string $login, bool $remember = false): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Разлогинивает пользователя (очищает состояние сессии).
|
* Разлогинивает пользователя (очищает состояние сессии).
|
||||||
*
|
*
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
namespace Services\Auth;
|
namespace Services\Auth;
|
||||||
|
|
||||||
use System\Classes\Config;
|
use System\Classes\Config;
|
||||||
|
use System\Classes\Cookie;
|
||||||
use System\Classes\Session;
|
use System\Classes\Session;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -21,6 +22,12 @@ use System\Classes\Session;
|
|||||||
*/
|
*/
|
||||||
class FileAuthDriver implements AuthDriver
|
class FileAuthDriver implements AuthDriver
|
||||||
{
|
{
|
||||||
|
/** @var string Имя cookie «запомнить меня» */
|
||||||
|
protected const REMEMBER_COOKIE = 'remember_me';
|
||||||
|
|
||||||
|
/** @var int Время жизни cookie «запомнить меня», сек (90 дней) */
|
||||||
|
protected const REMEMBER_LIFETIME = 90 * 24 * 3600;
|
||||||
|
|
||||||
/** @var string|null Путь к файлу пользователей (если не передан — берётся из конфига) */
|
/** @var string|null Путь к файлу пользователей (если не передан — берётся из конфига) */
|
||||||
protected ?string $_users_file;
|
protected ?string $_users_file;
|
||||||
|
|
||||||
@ -52,13 +59,21 @@ class FileAuthDriver implements AuthDriver
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Логин текущего авторизованного пользователя из сессии.
|
* Логин текущего авторизованного пользователя: из сессии, либо (если сессии
|
||||||
|
* нет) восстанавливается из cookie «запомнить меня» — тогда же выставляется
|
||||||
|
* обратно и в сессию, чтобы не проверять cookie на каждом обращении.
|
||||||
*
|
*
|
||||||
* @return string|null
|
* @return string|null
|
||||||
*/
|
*/
|
||||||
protected function currentLogin(): ?string
|
protected function currentLogin(): ?string
|
||||||
{
|
{
|
||||||
return Session::instance()->get($this->_session_key);
|
$login = Session::instance()->get($this->_session_key);
|
||||||
|
|
||||||
|
if ($login !== null) {
|
||||||
|
return $login;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->restoreFromRememberCookie();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -66,9 +81,7 @@ class FileAuthDriver implements AuthDriver
|
|||||||
*/
|
*/
|
||||||
public function login(string $login, string $password): bool
|
public function login(string $login, string $password): bool
|
||||||
{
|
{
|
||||||
$user = $this->users()[$login] ?? null;
|
if (!$this->verifyCredentials($login, $password)) {
|
||||||
|
|
||||||
if ($user === null || !password_verify($password, $user['password'] ?? '')) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,12 +90,81 @@ class FileAuthDriver implements AuthDriver
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function verifyCredentials(string $login, string $password): bool
|
||||||
|
{
|
||||||
|
$user = $this->users()[$login] ?? null;
|
||||||
|
|
||||||
|
return $user !== null && password_verify($password, $user['password'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function forceLogin(string $login, bool $remember = false): void
|
||||||
|
{
|
||||||
|
Session::instance()->set($this->_session_key, $login);
|
||||||
|
|
||||||
|
if ($remember) {
|
||||||
|
$this->setRememberCookie($login);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @inheritDoc
|
* @inheritDoc
|
||||||
*/
|
*/
|
||||||
public function logout(): void
|
public function logout(): void
|
||||||
{
|
{
|
||||||
Session::instance()->delete($this->_session_key);
|
Session::instance()->delete($this->_session_key);
|
||||||
|
Cookie::delete(self::REMEMBER_COOKIE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ставит cookie «запомнить меня»: логин + HMAC-подпись на хэше пароля
|
||||||
|
* пользователя (не отдельный секрет — при смене пароля хэш меняется,
|
||||||
|
* старые cookie сами становятся недействительными). Без серверного
|
||||||
|
* хранилища токенов — не нужно для файлового драйвера.
|
||||||
|
*
|
||||||
|
* @param string $login
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function setRememberCookie(string $login): void
|
||||||
|
{
|
||||||
|
$hash = $this->users()[$login]['password'] ?? null;
|
||||||
|
|
||||||
|
if ($hash === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Cookie::set(self::REMEMBER_COOKIE, $login . '|' . hash_hmac('sha256', $login, $hash), self::REMEMBER_LIFETIME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет cookie «запомнить меня» и, если подпись верна, восстанавливает
|
||||||
|
* сессию для этого логина.
|
||||||
|
*
|
||||||
|
* @return string|null Логин, если cookie валиден
|
||||||
|
*/
|
||||||
|
protected function restoreFromRememberCookie(): ?string
|
||||||
|
{
|
||||||
|
$cookie = Cookie::get(self::REMEMBER_COOKIE);
|
||||||
|
|
||||||
|
if ($cookie === null || !str_contains($cookie, '|')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$login, $signature] = explode('|', $cookie, 2);
|
||||||
|
$hash = $this->users()[$login]['password'] ?? null;
|
||||||
|
|
||||||
|
if ($hash === null || !hash_equals(hash_hmac('sha256', $login, $hash), $signature)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Session::instance()->set($this->_session_key, $login);
|
||||||
|
|
||||||
|
return $login;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -58,10 +58,10 @@ class Controller extends BaseController
|
|||||||
$user = Auth::instance($this->_auth_driver)->getUser();
|
$user = Auth::instance($this->_auth_driver)->getUser();
|
||||||
|
|
||||||
if ($user === null) {
|
if ($user === null) {
|
||||||
// getResponse() 302 сам делает Location + exit — throw здесь не подходит:
|
// Не исключительная ситуация — штатное поведение (гостя отправляем логиниться),
|
||||||
// неперехваченные исключения уходят в MyException::handler(), который
|
// поэтому обычный редирект, а не throw HTTPException: throw ушёл бы в
|
||||||
// getResponse() не вызывает и настоящий редирект не отправит.
|
// MyException::handler() и отрендерил бы страницу ошибки вместо перехода на /login.
|
||||||
HTTPException::factory(302, '/login')->getResponse();
|
HTTP::redirect('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->_auth_roles && !in_array($user['role'] ?? null, $this->_auth_roles, true)) {
|
if ($this->_auth_roles && !in_array($user['role'] ?? null, $this->_auth_roles, true)) {
|
||||||
|
|||||||
@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package Bicycle
|
|
||||||
* @author Egor Isaev
|
|
||||||
* @description HTTPException_302.php
|
|
||||||
* @copyright (c) 04/06/2026
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace System\Classes\HTTP\Exception;
|
|
||||||
|
|
||||||
use System\Classes\HTTP;
|
|
||||||
use System\Classes\HTTP\HTTPException;
|
|
||||||
use System\Classes\Response;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 302 Found: редирект на адрес из сообщения исключения.
|
|
||||||
*/
|
|
||||||
class HTTPException_302 extends HTTPException
|
|
||||||
{
|
|
||||||
/** @var int HTTP-код */
|
|
||||||
protected int $_code = 302;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Выполняет редирект через HTTP::redirect() и завершает выполнение.
|
|
||||||
*
|
|
||||||
* @return Response Никогда не возвращается (редирект завершает скрипт)
|
|
||||||
*/
|
|
||||||
public function getResponse(): Response
|
|
||||||
{
|
|
||||||
$location = $this->getMessage() ?: '/';
|
|
||||||
HTTP::redirect($location, 302);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
31
schema.sql
31
schema.sql
@ -63,12 +63,17 @@ CREATE TABLE categories (
|
|||||||
type ENUM ('income', 'expense') NOT NULL,
|
type ENUM ('income', 'expense') NOT NULL,
|
||||||
is_delete TINYINT(1) NOT NULL DEFAULT 0, -- архивация вместо удаления, тот же приём что у accounts — сохраняет историю транзакций
|
is_delete TINYINT(1) NOT NULL DEFAULT 0, -- архивация вместо удаления, тот же приём что у accounts — сохраняет историю транзакций
|
||||||
`order` INT NOT NULL DEFAULT 0,
|
`order` INT NOT NULL DEFAULT 0,
|
||||||
|
-- формульные статьи (см. бюджет_текущий_план.md): настраиваются на статье ДОХОДА, ссылаются на
|
||||||
|
-- привязанную статью РАСХОДА + дефолтный процент; у остальных категорий оба поля NULL
|
||||||
|
linked_expense_categorie_id INT UNSIGNED NULL,
|
||||||
|
formula_pct DECIMAL(5, 2) NULL,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
KEY ix_categories_user_id (user_id),
|
KEY ix_categories_user_id (user_id),
|
||||||
KEY ix_categories_parent_id (parent_id),
|
KEY ix_categories_parent_id (parent_id),
|
||||||
CONSTRAINT fk_categories_user FOREIGN KEY (user_id) REFERENCES users (id),
|
CONSTRAINT fk_categories_user FOREIGN KEY (user_id) REFERENCES users (id),
|
||||||
-- удаление родителя не должно тянуть за собой подкатегории — они просто станут верхнего уровня
|
-- удаление родителя не должно тянуть за собой подкатегории — они просто станут верхнего уровня
|
||||||
CONSTRAINT fk_categories_parent FOREIGN KEY (parent_id) REFERENCES categories (id) ON DELETE SET NULL
|
CONSTRAINT fk_categories_parent FOREIGN KEY (parent_id) REFERENCES categories (id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_categories_linked_expense FOREIGN KEY (linked_expense_categorie_id) REFERENCES categories (id)
|
||||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- categorie_id NULL — обычный transfer (в т.ч. пополнение копилки/цели): деньги просто
|
-- categorie_id NULL — обычный transfer (в т.ч. пополнение копилки/цели): деньги просто
|
||||||
@ -96,7 +101,28 @@ CREATE TABLE transactions (
|
|||||||
CONSTRAINT fk_transactions_account_in FOREIGN KEY (account_in_id) REFERENCES accounts (id)
|
CONSTRAINT fk_transactions_account_in FOREIGN KEY (account_in_id) REFERENCES accounts (id)
|
||||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Лог правок/удалений transactions — цепочка снимков (не old/new-пары), достаточно для отката:
|
||||||
|
-- откат edit — snapshot предыдущей записи этой же transaction_id; восстановление после delete —
|
||||||
|
-- snapshot записи, предшествующей записи delete (сама delete-запись snapshot не хранит); откат
|
||||||
|
-- create — просто удалить операцию, snapshot не нужен. Механизм самого отката — не реализован,
|
||||||
|
-- здесь только лог.
|
||||||
|
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),
|
||||||
|
-- без FK на transactions.id намеренно: после физического удаления операции строка transactions
|
||||||
|
-- пропадает, а история по ней должна остаться читаемой
|
||||||
|
KEY ix_transaction_history_transaction_id (transaction_id),
|
||||||
|
CONSTRAINT fk_transaction_history_user FOREIGN KEY (user_id) REFERENCES users (id)
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- Факт не хранится — считается на лету суммой transactions по категории за месяц.
|
-- Факт не хранится — считается на лету суммой transactions по категории за месяц.
|
||||||
|
-- plan_summ хранится всегда (и у формульных строк тоже) — у формульных пересчитывается и
|
||||||
|
-- перезаписывается автоматически при правке суммы/процента статьи-источника (кэш, как accounts.summa).
|
||||||
CREATE TABLE budgets (
|
CREATE TABLE budgets (
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
categorie_id INT UNSIGNED NOT NULL,
|
categorie_id INT UNSIGNED NOT NULL,
|
||||||
@ -104,6 +130,9 @@ CREATE TABLE budgets (
|
|||||||
year SMALLINT UNSIGNED NOT NULL,
|
year SMALLINT UNSIGNED NOT NULL,
|
||||||
month TINYINT UNSIGNED NOT NULL,
|
month TINYINT UNSIGNED NOT NULL,
|
||||||
type ENUM ('income', 'expense') NOT NULL,
|
type ENUM ('income', 'expense') NOT NULL,
|
||||||
|
-- каким процентом посчитан plan_summ этого месяца — только для строк формульной статьи расхода,
|
||||||
|
-- NULL для обычных строк (см. категорию categories.linked_expense_categorie_id)
|
||||||
|
formula_pct_used DECIMAL(5, 2) NULL,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uq_budgets_categorie_period (categorie_id, year, month),
|
UNIQUE KEY uq_budgets_categorie_period (categorie_id, year, month),
|
||||||
CONSTRAINT fk_budgets_categorie FOREIGN KEY (categorie_id) REFERENCES categories (id)
|
CONSTRAINT fk_budgets_categorie FOREIGN KEY (categorie_id) REFERENCES categories (id)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user