This commit is contained in:
Egor Isaev 2026-08-14 14:05:41 +03:00
parent c9b81cdcd1
commit ac1334c784
11 changed files with 1844 additions and 81 deletions

View File

@ -19,4 +19,4 @@
- [Бюджет — актуальный план (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
- [Визуальный дизайн (Nocturne)](reference_dashboard_design_artifact.md) — актуальная палитра/токены проекта (App/media/css/dashboard.css), сверяться при работе над UI

View File

@ -1,27 +1,85 @@
---
name: reference-dashboard-design-artifact
description: Ссылка на согласованный визуальный макет дашборда «Бюджет» (тёмная тема, room-card, dock-навбар) — сверяться при любой работе над UI
description: Визуальный дизайн проекта — палитра/токены Nocturne (2026-08-14, поверх старого макета room-card/dock-навбар) — сверяться при любой работе над UI
metadata:
type: reference
---
Макет дашборда согласован заранее (в другой сессии) как Claude Artifact:
`https://claude.ai/code/artifact/55e7f715-2ee7-45e2-acb7-82b883fcad02` («Бюджет — обзор (макет)»).
**Актуальная версия (2026-08-14).** Пользователь принёс новый hi-fi дизайн-хендофф — сгенерирован
внешним дизайн-инструментом («clode design»), лежит в `App/design_handoff_personal_finance/`
(`Личные финансы.dc.html`, `nocturne.css`, `README.md`). Система называется **Nocturne** (тёмная,
mono-accent, `--color-accent:#9184d9`, шрифт Inter в оригинале). Хендофф покрывает 4 экрана:
Дашборд, План на год, Транзакции, Аналитика — из них в проекте на момент разговора реально
существовали только первые два (Транзакции — есть только `TransactionRepository`, без
контроллера/view; Аналитика — не существует вообще).
Ключевые элементы дизайна оттуда, уже перенесённые в `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 (дата/% бюджета/
изменение за месяц/курс валюты) — в реализации пока упрощена (нет данных по бюджету/курсу).
**Решено с пользователем, что делаем сейчас (см. также [[budget-current-plan]] по фиче):**
1. **Только визуальная система** — токены/палитра/радиусы/тени/шрифт поверх уже существующих
экранов (Дашборд, Категории, План на год). Экраны Транзакции/Аналитика — не делаем сейчас,
это отдельный заход (нужен новый бэкенд: контроллеры, агрегирующие запросы для графиков).
2. **Категории остаются отдельной страницей `/categories`** — в хендоффе CRUD статей открывается
модалкой прямо со страницы «План на год» (кнопка «Статьи»), но пользователь явно попросил не
переносить, оставить как есть.
3. **Шрифт — не Inter из хендоффа, а SF Pro**, который уже лежит в проекте
(`App/media/fonts/`, `App/media/css/fonts.css`) — этот файл раньше нигде не подключался,
подключён в `layout.html` этим же заходом.
Макет — на русском, включает демо-данные (Кошелёк/Карта Тинькофф/Евро, цели «Отпуск»/«Остаток»,
полный список категорий из [[бюджет_текущий_план]]) — не копировать демо-суммы как реальные, только
структуру/классы/цветовую схему.
**Как перенесено технически** — не копировали HTML хендоффа (там своя `sc-`-шаблонизация чужого
инструмента, не Bicycle), а перетащили только **значения** токенов Nocturne на уже существующие
CSS-переменные проекта в `App/media/css/dashboard.css`, чтобы не трогать разметку/JS:
- `--bg:#161826`, `--surface:#232532`, `--surface-2:#292b31`, `--surface-3:#3f424d`,
`--ink:#e9e9ed`, `--ink-soft:#cfd3e5`, `--muted:#9397ab`, `--divider` (16% ink) — вместо старой
чёрно-серой палитры (`--bg:#0b0b0f` и т.д.).
- Новый `--accent:#9184d9` (+ `--accent-tint:#d2cefd` для kicker-подписей типа «Доступно сейчас») —
единый акцент интерфейса (активный пункт dock, фокус форм, `.btn-primary`). Раньше активный dock
подсвечивался `--teal` — заменено на `--accent`.
- `--radius-sm/md/lg` (4/8/14px) и `--shadow-card`/`--shadow-pop` (hairline-обводка + мягкая тень —
фирменная деталь Nocturne) — применены ко всем карточкам/панелям/дропдаунам.
- `--sans`/`--font-heading` — теперь `"SF Pro Text"`/`"SF Pro Display"` вместо системного sans;
`--font-heading` навешан на заголовки/крупные суммы (`.balance`, `.room-title`,
`.budget-group-name`, `.section-label` и т.п.).
- Bootstrap-компоненты форм/модалок (`.btn-primary`, `.form-control`, `.form-select`,
`.form-check-input`, `.modal-content`) — довешаны CSS-переопределения под эту же палитру
(раньше были дефолтные цвета Bootstrap, не тронутые прошлым заходом).
- **Акцентные цвета per-тип счёта/категории** (`--teal/--green/--purple/--blue/--orange/--pink/
--red/--amber/--gray`) — **не трогали**, оставлены как были: это уже прямые тёмные цвета вне
системы токенов (та же логика, что и в самом хендоффе — см. README.md проекта, «разноцветные
акценты... заданы прямыми oklch(), не входят в токены Nocturne»).
- `App/view/Login/login.html` — **не тронут**, у него свой независимый `<style>` со старой
палитрой (`--bg:#0b0b0f` и т.п.), не завязан на `dashboard.css`. Не входил в согласованный объём
этого захода — если понадобится единый вид, надо явно попросить.
**How to apply**: перед тем как добавлять новый раздел UI (Счета/Категории/Операции/Цели — сейчас
это просто неактивные пункты dock-навбара) — свериться с этим макетом (`WebFetch` по ссылке выше),
не изобретать свой стиль карточек/навигации заново.
**Второй заход того же дня (2026-08-14, позже) — структурная переверстка дашборда.** Первый заход
(только токены) пользователь не увидел как изменение вообще — сравнивал с хендоффом напрямую и
сказал «ничего не поменялось». Урок: перекраска CSS-переменных на тёмном фоне почти незаметна на
глаз, пока сама структура карточек не похожа на макет — токены одни не считаются «применением
дизайна» для пользователя. После явного «хочу узнаваемую картинку из хендоффа» добавлено в
`App/view/Index/index.html` + `App/Controller/IndexController.php` (только с реальными данными, не
фейковыми числами):
- Плашка-дата над шапкой (`.dash-kicker`, день недели + число + месяц) + ряд тегов `.tag`/
`.tag-outline` (месяц/год; «Бюджет N% потрачено» — реальный факт/план по expense-статьям месяца).
- «Всего по всем счетам» под «Доступно сейчас» (`$total_all` в контроллере — сумма всех не
архивных RUB-счетов, без фильтра `include_in_total`).
- Подзаголовок типа счёта на карточке (`.room-sub`, «Наличные»/«Банковский счёт»/«Копилка»).
- «Копилки и цели» — кольцо прогресса (SVG `stroke-dasharray`) на основе `GoalRepository::getForUser()`
(новый метод, JOIN с `accounts`) — рендерится только если у пользователя реально есть `goals`;
создание/редактирование цели через UI **не реализовано** (нет полей цели в модалке счёта) — это
просто витрина существующих данных, не полноценная фича.
- Списки «Доходы»/«Бюджет на …» — прогресс-бар план/факт на каждой статье
(`TransactionRepository::getMonthActualByCategory()`, новый метод — сумма `transactions` за месяц
по категории, `summa * COALESCE(rate_to_rub,1)`), перерасход по расходам красным.
Проверено вживую (не только `php -l`) — залогинился curl'ом под `mikrit`/`qwerty` (2FA-код-заглушка
`1234`, см. `LoginController::STUB_CODE`) и вытащил реальный HTML `/` — рендерится без ошибок,
новые блоки на месте. Один найденный и исправленный по ходу баг: `GoalRepository::getForUser()`
обращался к `$this->obj_class` (он `private` в `Repository`, не `protected` — недоступен из
наследника) → `ErrorException`; исправлено на прямой `Model::class`.
**Не сделано (следующий заход, если попросят):** экраны Транзакции/Аналитика (нужен новый бэкенд),
единый вид логина, CRUD целей (создание/редактирование — сейчас только чтение), донат/полилиния/
бары в Аналитике, курс валюты и «дельта за месяц» в тегах шапки (нет источника данных —
`Services\CurrencyRate` не реализован, история баланса не хранится).
**How to apply**: при следующей работе над UI — сверяться с актуальными значениями токенов прямо в
`App/media/css/dashboard.css` (это единственный источник истины, не хардкодить цвета в шаблонах),
и с `App/design_handoff_personal_finance/` — если понадобится продолжить перенос (Транзакции/
Аналитика или точная вёрстка карточек).

View File

@ -11,6 +11,8 @@ namespace App\Controller;
use App\Repositories\AccountRepository;
use App\Repositories\BudgetRepository;
use App\Repositories\CategoryRepository;
use App\Repositories\GoalRepository;
use App\Repositories\TransactionRepository;
use App\Repositories\UserRepository;
use Services\Auth;
use System\Classes\Controller;
@ -42,28 +44,61 @@ class IndexController extends Controller
$accounts ?: []
));
// «Всего по всем счетам» — без фильтра include_in_total, для строки под «Доступно сейчас»
// (см. дизайн-хендофф App/design_handoff_personal_finance). Валютные счета — та же
// сознательная недоделка, что и у $total выше: без Services\CurrencyRate конвертировать
// не во что, поэтому считаем только RUB (не показываем непересчитанную сумму как есть).
$total_all = array_sum(array_map(
static fn ($account) => $account->currency === 'RUB' ? (float)$account->summa : 0,
$accounts ?: []
));
$month = (int)date('n');
$year = (int)date('Y');
$category_repo = new CategoryRepository();
$budget_repo = new BudgetRepository();
// Факта (реальных transactions) в проекте ещё нет — это отдельная будущая фича
// (см. .claude/memory/бюджет_текущий_план.md), поэтому fact = 0 везде; прогресс-бары
// покажут 0%, пока операции не появятся, и заполнятся сами без правки этого кода.
$tree = $user ? $category_repo->getTree($user->id) : [];
$plan_month = $user ? array_map(
static fn ($months) => $months[$month]['plan_summ'] ?? 0.0,
$budget_repo->getYear($user->id, $year)
) : [];
// Факт за месяц — реальные transactions (таблица/репозиторий уже есть, UI для их ввода
// пока нет, поэтому в живой БД сумма обычно 0 — это корректный факт, не заглушка).
$fact_month = $user ? (new TransactionRepository())->getMonthActualByCategory($user->id, $year, $month) : [];
// % бюджета по расходам за месяц — план/факт только по expense-статьям (для плашки в шапке).
$expense_ids = [];
foreach ($tree as $items) {
foreach ($items as $c) {
if ($c->type === 'expense') {
$expense_ids[(int)$c->id] = true;
}
}
}
$plan_expense = 0.0;
$fact_expense = 0.0;
foreach ($expense_ids as $categorie_id => $_) {
$plan_expense += $plan_month[$categorie_id] ?? 0.0;
$fact_expense += $fact_month[$categorie_id] ?? 0.0;
}
$pct_expense = $plan_expense > 0 ? round($fact_expense / $plan_expense * 100) : null;
$goals = $user ? (new GoalRepository())->getForUser($user->id) : [];
return $this->render('index', [
'user' => $auth_user,
'accounts' => $accounts ?: [],
'archived_accounts' => $archived_accounts ?: [],
'total' => $total,
'total_all' => $total_all,
'tree' => $tree,
'plan_month' => $plan_month,
'fact_month' => $fact_month,
'pct_expense' => $pct_expense,
'goals' => $goals ?: [],
'month' => $month,
]);
}

View File

@ -8,6 +8,8 @@
namespace App\Repositories;
use PDO;
use Services\DataBase\Model;
use System\Classes\Repository;
/**
@ -15,6 +17,11 @@ use System\Classes\Repository;
*/
class GoalRepository extends Repository
{
/**
* @var string Таблица accounts для JOIN в getForUser() — вынесена в свойство (см. BudgetRepository::$categories_table).
*/
protected string $accounts_table = 'accounts';
/**
* @param string|null $connection Имя подключения (Database::instance($connection)); null — по умолчанию
*/
@ -22,4 +29,26 @@ class GoalRepository extends Repository
{
parent::__construct('goals', connection: $connection);
}
/**
* Цели пользователя (по его активным счетам) вместе с данными счёта-копилки — для карточек
* «Копилки и цели» на дашборде.
*
* @param int $user_id
* @return array<int,object> Строки goals + account_title/account_summa/account_currency
*/
public function getForUser(int $user_id): array
{
$sql = "SELECT g.*, a.title AS account_title, a.summa AS account_summa, a.currency AS account_currency
FROM $this->table g
INNER JOIN $this->accounts_table a ON a.id = g.account_id
WHERE a.user_id = :user_id AND a.is_delete = 0";
$stmt = $this->pdo->prepare($sql);
$stmt->execute(['user_id' => $user_id]);
// $this->obj_class — private в Repository (не protected), сюда не дотянуться из наследника;
// Model — и так дефолт конструктора Repository, GoalRepository его не переопределяет.
return $stmt->fetchAll(PDO::FETCH_CLASS, Model::class);
}
}

View File

@ -8,6 +8,7 @@
namespace App\Repositories;
use PDO;
use System\Classes\Repository;
/**
@ -22,4 +23,34 @@ class TransactionRepository extends Repository
{
parent::__construct('transactions', connection: $connection);
}
/**
* Факт за месяц по статьям — сумма реальных операций (income/expense, не transfer — у тех
* categorie_id = null), в рублях (см. бюджет_текущий_план.md — summa * COALESCE(rate_to_rub, 1)).
* Для дашборда/плана: план/факт по категории.
*
* @param int $user_id
* @param int $year
* @param int $month
* @return array<int,float> [categorie_id => сумма]
*/
public function getMonthActualByCategory(int $user_id, int $year, int $month): array
{
$sql = "SELECT categorie_id, SUM(summa * COALESCE(rate_to_rub, 1)) AS s
FROM $this->table
WHERE user_id = :user_id AND categorie_id IS NOT NULL
AND YEAR(date) = :year AND MONTH(date) = :month
GROUP BY categorie_id";
$stmt = $this->pdo->prepare($sql);
$stmt->execute(['user_id' => $user_id, 'year' => $year, 'month' => $month]);
$result = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$result[(int)$row['categorie_id']] = (float)$row['s'];
}
return $result;
}
}

View File

@ -0,0 +1,34 @@
# Handoff: Личные финансы (Dashboard / План на год / Аналитика)
## Overview
Адаптивный (комп/планшет/телефон) прототип системы личных финансов: дашборд, годовой план по месяцам с CRUD статей доходов/расходов (категории + подкатегории, с формулой-привязкой доход→расход), экран транзакций, аналитика. Нижняя навигация: Дашборд / Бюджет / Транзакции / Аналитика / Настройки.
## About the Design Files
Файлы в этой папке (`Личные финансы.dc.html`, `nocturne.css`) — **дизайн-референс на HTML**, не production-код. Задача разработчика — **пересобрать эти экраны в целевом окружении проекта** (в вашем случае — существующее приложение "Bicycle", PHP-бэкенд + фронтенд-шаблоны в `App/view/`), используя его текущие паттерны (layout.html, контроллеры, существующий CSS), а не копировать HTML как есть.
## Fidelity
**Hi-fi.** Финальные цвета, типографика, отступы и интерактивные состояния — DC-файл можно открыть в браузере и использовать как эталон 1:1.
## Design system
Nocturne (dark, mono-accent). Токены — в `nocturne.css` (`:root`): `--color-bg #161826`, `--color-surface #232532`, `--color-text #e9e9ed`, `--color-accent #9184d9` (+ тональные рампы 100–900 для accent/accent-2/neutral). Шрифт: Inter (`--font-heading`/`--font-body`). Радиусы/тени — `--radius-*`, `--shadow-sm/md/lg`. Разноцветные акценты счетов/статей в дашборде заданы прямыми `oklch()` — не входят в токены Nocturne, подобраны под ту же светлоту/насыщенность.
## Screens
1. **Дашборд** — статус-бар (дата, бюджет %, дельта, курс), доступно сейчас (крупная сумма), карточки счетов (⋮-меню: редактировать/удалить, кнопка «+» → модалка «Новый счёт» → «Новый кредит»), копилки/цели (кольцевой прогресс, ⋮-меню), список статей доходов (прогресс-бар план/факт), бюджет по группам (то же, с перерасходом >100% — красный).
2. **План на год** (`planStyle`) — таблица статей × 12 месяцев (план), группы доходов/расходов, инлайн-редактируемые ячейки; на телефоне — аккордеон по группам. Кнопка «Статьи» открывает CRUD-модалку категорий (группы + подкатегории, add/edit/delete, поле формулы для доходов).
3. **Транзакции** (`transactionsStyle`) — список операций (дата, категория, сумма, доход зелёным).
4. **Аналитика** (`analyticsStyle`) — динамика доходов/расходов (SVG-полилиния), структура расходов (donut), план vs факт (бары), тренд/прогноз, год к году (столбцы по кварталам).
Модалки: «Новый счёт» (тип: расчётный/накопительный/наличные/кредит-карта, цвет, валюта, приватность, учёт в общем балансе), «Новый кредит» (новый/уже выплачиваю, 4 типа: потребительский/ипотека/авто/кредитная карта, поля ставки/срока/платежа/остатка), «Статьи доходов и расходов» (CRUD дерева категорий).
## Interactions
- Верхний свитчер экрана + свитчер устройства (комп/планшет/телефон, 1280/834/390px) — только для превью в самом DC-файле, в продакшене не нужен.
- Нижняя навигация — плавающая пилюля, 5 иконок, активная подсвечена акцентным кружком (важно: во React/Vue-эквиваленте стилизовать активный пункт целиком одним объектом стилей — не смешивать динамический style-объект с литеральной строкой CSS в одном атрибуте, как в исходном DC один раз аукнулось).
- ⋮-меню на карточках счетов/целей — клик открывает мини-дропдаун (редактировать/удалить), закрывается повторным кликом.
- CRUD статей: добавление/редактирование через инлайн-форму внутри модалки, удаление — сразу.
## Assets
Иконки — инлайн SVG-спрайт (`<symbol>` в начале файла), в духе Phosphor (design-system требует Phosphor — при переносе на реальный icon-set заменить на настоящие Phosphor-иконки: home, chart, sliders, gear, wallet, card, piggy-bank, folder, trash, pencil, plus, bank, bag, bolt, car, coin).
## Files
- `Личные финансы.dc.html` — весь прототип (все экраны + модалки + логика состояния в одном файле).
- `nocturne.css` — токены и базовые компоненты дизайн-системы Nocturne.

View File

@ -0,0 +1,294 @@
/* Nocturne — design-system tokens and component classes. This file is the source of truth for the system's look; retune it here and see readme.md. */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
:root {
--color-bg: #161826;
--color-surface: #232532;
--color-text: #e9e9ed;
--color-accent: #9184d9; /* review round 7 (Barron): the accent moves to
the product's blurple — OKLCH hue 289.2, the hue of the app's own Pro
accent (#9a8fe0, --om-accent-pro) — at the same lightness the steel
accent held (L 0.660, so the documented ratios survive) and chroma
in the product blurple's own range (its token sits at C 0.118; the
accent takes 0.125, from the old steel's 0.030):
the accent now reads as an accent rather than another neutral. */
--color-accent-2: #a7a1db; /* the machine-derived stand-in follows: same
hue, same L 0.734, chroma at the same 2:3 proportion it held */
--color-divider: color-mix(in srgb, #e9e9ed 16%, transparent);
/* Tonal ramps — generated in OKLCH on one shared lightness scale, so the
same step of any role matches the others in visual value. */
--color-neutral-100: #f3f5fe;
--color-neutral-200: #e4e7f5;
--color-neutral-300: #cfd3e5;
--color-neutral-400: #b2b6ca;
--color-neutral-500: #9397ab;
--color-neutral-600: #75798c;
--color-neutral-700: #595d6c;
--color-neutral-800: #3f424d;
--color-neutral-900: #292b31;
--color-accent-100: #f5f4ff;
--color-accent-200: #e7e5fe;
--color-accent-300: #d2cefd;
--color-accent-400: #b5abfc;
--color-accent-500: #968ae0;
--color-accent-600: #796cbf;
--color-accent-700: #5d5294;
--color-accent-800: #423a6a;
--color-accent-900: #2b2741;
--color-accent-2-100: #f5f4ff;
--color-accent-2-200: #e7e5fe;
--color-accent-2-300: #d2cefd;
--color-accent-2-400: #b5afe8;
--color-accent-2-500: #9690c9;
--color-accent-2-600: #7972a9;
--color-accent-2-700: #5c5783;
--color-accent-2-800: #423e5d;
--color-accent-2-900: #2b293a;
/* Deck section-divider ground — review round 3: saturation as presence.
Dividers take a saturated deep indigo (hue 277, the neutral ramp's own
family; C 0.094 — written when the accent held C 0.030, before the
round-7 blurple raised it past the ground's) with a lighter bloom and
a ghost tint of the same hue, while the slide grounds stay
desaturated. Deck-scale fills only — not interface colors. */
--color-section: #262a60;
--color-section-glow: #353b80;
--color-section-ghost: #4c5397;
--font-heading: "Inter", system-ui, sans-serif;
--font-heading-weight: 500;
--font-body: "Inter", system-ui, sans-serif;
--space-1: 2.8px;
--space-2: 5.6px;
--space-3: 8.4px;
--space-4: 11.2px;
--space-6: 16.8px;
--space-8: 22.4px;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 14px;
/* Elevation — derived from the ground: soft ink-tinted shadows on a
light theme, a hairline edge + ambient darkness on a dark one. */
--shadow-sm: 0 0 0 1px #3f424d;
--shadow-md: 0 0 0 1px #595d6c, 0 6px 18px rgba(0,0,0,0.55);
--shadow-lg: 0 0 0 1px #9397ab, 0 16px 40px rgba(0,0,0,0.65);
}
body {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-body);
}
h1, h2, h3, h4 { font-family: var(--font-heading); font-weight: var(--font-heading-weight); }
.lighten{mix-blend-mode:lighten;background-color:transparent}
/* ══════════════════════════════════════════════════════════════════════════
Components — built with the tokens above. Plain CSS
on plain HTML: no JavaScript, no build step. Each class is documented in
readme.md and demonstrated in foundations/ and components/.
══════════════════════════════════════════════════════════════════════ */
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font-size: 15px; line-height: 1.55; font-weight: 400; }
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
line-height: 1.12; letter-spacing: -0.015em; margin: 0 0 var(--space-2);
}
h1 { font-size: 42px; }
h2 { font-size: 32px; }
h3 { font-size: 25px; }
h4 { font-size: 20px; }
h5 { font-size: 16px; }
h6 { font-size: 13px; }
h6 { letter-spacing: 0.08em; text-transform: uppercase; }
p { margin: 0 0 var(--space-3); }
a { color: var(--color-accent); text-underline-offset: 3px; }
img { display: block; max-width: 100%; }
figure { margin: 0; }
figcaption {
font-size: 11px; margin-top: var(--space-1);
color: color-mix(in srgb, var(--color-text) 55%, transparent);
}
.text-muted { color: color-mix(in srgb, var(--color-text) 55%, transparent); }
:focus { outline: none; }
:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
::selection { background: color-mix(in srgb, var(--color-accent) 30%, transparent); }
/* — rules — */
/* Rules fade to transparent at both ends instead of stopping cleanly — a
Nocturne signature. The ramp is 48px an end (one deck baseline unit),
painted from the same divider token. Freestanding rules fade; box
outlines, in-control separators, and short accent marks stay solid. */
.hr {
height: 1px; border: 0; margin: var(--space-4) 0;
background: linear-gradient(to right,
transparent, var(--color-divider) 48px,
var(--color-divider) calc(100% - 48px), transparent);
}
/* — buttons — */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
cursor: pointer; text-decoration: none;
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
font-size: 14px; line-height: 1.2; color: var(--color-text); /* matches the .input's 14px —
the pair sits side by side in sign-up rows */
background: transparent; border: 1px solid transparent;
padding: var(--space-2) calc(var(--space-3) * 1.2);
border-radius: var(--radius-md);
}
.btn svg { display: block; }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-primary { color: var(--color-accent); border-color: var(--color-accent); }
.btn-primary:hover { background: color-mix(in srgb, var(--color-accent) 12%, transparent); }
.btn-primary:active { background: color-mix(in srgb, var(--color-accent) 22%, transparent); }
.btn-secondary { border-color: var(--color-divider); }
.btn-secondary:hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
.btn-secondary:active { background: color-mix(in srgb, var(--color-text) 14%, transparent); }
.btn-ghost { color: var(--color-accent); padding-inline: var(--space-1); }
.btn-ghost:hover { background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
.btn-ghost:active { background: color-mix(in srgb, var(--color-accent) 18%, transparent); }
.btn-icon { width: 36px; height: 36px; padding: 0; }
.btn-block { width: 100%; margin-top: var(--space-2); }
/* — forms — */
.field > label {
display: block; font-size: 12px; margin-bottom: 5px;
color: color-mix(in srgb, var(--color-text) 70%, transparent);
}
.input {
width: 100%; min-height: 36px; padding: 6px 10px; font: inherit;
font-size: 14px; color: var(--color-text); caret-color: var(--color-accent);
background: var(--color-surface);
border: 1px solid var(--color-divider); border-radius: var(--radius-md);
}
.input:hover { border-color: color-mix(in srgb, var(--color-text) 45%, transparent); }
.input:focus-visible { border-color: var(--color-accent); outline-offset: 0; }
textarea.input { min-height: 90px; resize: vertical; }
.radio { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; }
.radio input, .seg-opt input {
position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none;
}
.radio .dot {
width: 16px; height: 16px; flex: none; border-radius: 50%;
border: 1.5px solid var(--color-divider);
}
.radio:hover .dot { border-color: var(--color-accent); }
.radio input:checked + .dot {
border-color: var(--color-accent); background: var(--color-accent);
box-shadow: inset 0 0 0 4px var(--color-bg);
}
.radio input:focus-visible + .dot { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.seg {
display: inline-flex; overflow: hidden;
border: 1px solid var(--color-divider); border-radius: var(--radius-md);
}
.seg-opt {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 12px; font-size: 13px; cursor: pointer;
}
.seg-opt + .seg-opt { border-left: 1px solid var(--color-divider); }
.seg-opt:has(input:checked) { color: var(--color-accent); box-shadow: inset 0 0 0 1px var(--color-accent); }
.seg-opt:not(:has(input:checked)):hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
.seg-opt:has(input:focus-visible) { outline: 2px solid var(--color-accent); outline-offset: -2px; }
/* — cards — */
.card {
display: flex; flex-direction: column; gap: var(--space-2);
padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-surface);
}
.card-kicker { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-accent); }
.card-title {
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
font-size: 17px; line-height: 1.2;
}
.card-body { margin: 0; font-size: 13px; opacity: 0.8; flex: 1; }
.card-meta {
display: flex; align-items: center; gap: 6px; font-size: 11px;
color: color-mix(in srgb, var(--color-text) 50%, transparent);
}
.elev-sm { box-shadow: var(--shadow-sm); }
.elev-md { box-shadow: var(--shadow-md); }
.elev-lg { box-shadow: var(--shadow-lg); }
/* — tags — */
.tag {
display: inline-flex; align-items: center; font-size: 11px;
letter-spacing: 0.02em; padding: 3px 10px;
border-radius: calc(var(--radius-md) * 0.75);
}
.tag-accent { background: var(--color-accent-800); color: var(--color-accent-100); }
.tag-accent-2 { background: var(--color-accent-2-800); color: var(--color-accent-2-100); }
.tag-neutral { background: var(--color-neutral-800); color: var(--color-neutral-100); }
.tag-outline { border: 1px solid var(--color-accent); color: var(--color-accent); }
/* — navigation — */
.nav {
display: flex; align-items: center; gap: var(--space-4);
padding: var(--space-3) var(--space-4);
border-bottom: none;
}
.nav-brand {
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
font-size: 18px; margin-right: auto;
}
.nav a { color: inherit; text-decoration: none; font-size: 14px; }
.nav a:hover, .nav a[aria-current='page'] { color: var(--color-accent); }
/* — tables — */
.table { width: 100%; border-collapse: collapse; font-size: 14px; }
.table th {
text-align: left; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
color: color-mix(in srgb, var(--color-text) 60%, transparent);
padding: var(--space-2); border-bottom: 1px solid transparent; /* keeps layout; the row paints the fading rule */
}
.table td {
padding: var(--space-2);
border-bottom: 1px solid transparent;
}
/* Row rules as row-level strips so the end-fade spans the row, not each cell. */
.table thead tr {
background: linear-gradient(to right,
transparent, var(--color-divider) 48px,
var(--color-divider) calc(100% - 48px), transparent) no-repeat bottom / 100% 1px;
}
.table tbody tr {
background: linear-gradient(to right,
transparent, color-mix(in srgb, var(--color-text) 8%, transparent) 48px,
color-mix(in srgb, var(--color-text) 8%, transparent) calc(100% - 48px), transparent) no-repeat bottom / 100% 1px;
}
.table tbody tr:hover {
/* the hover tint layers over the row rule, which keeps painting */
background:
linear-gradient(color-mix(in srgb, var(--color-text) 4%, transparent),
color-mix(in srgb, var(--color-text) 4%, transparent)) no-repeat 0 0 / 100% 100%,
linear-gradient(to right,
transparent, color-mix(in srgb, var(--color-text) 8%, transparent) 48px,
color-mix(in srgb, var(--color-text) 8%, transparent) calc(100% - 48px), transparent) no-repeat bottom / 100% 1px;
}
/* — dialog — */
.dialog-backdrop {
position: fixed; inset: 0; display: grid; place-items: center;
padding: var(--space-4);
background: color-mix(in srgb, var(--color-neutral-900) 50%, transparent);
}
.dialog {
width: min(440px, 100%); display: flex; flex-direction: column; gap: var(--space-3);
padding: var(--space-4); border-radius: var(--radius-lg);
background: var(--color-surface); box-shadow: var(--shadow-lg);
}
.dialog-title {
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
font-size: 20px;
}
.dialog-body { font-size: 14px; opacity: 0.85; }
.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-2); }

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,21 @@
/* Токены — палитра Nocturne (App/design_handoff_personal_finance/nocturne.css), перенесённая на
существующие имена переменных проекта, чтобы не переписывать разметку/JS (см. README хендоффа —
пересобрать в целевом окружении, не копировать HTML как есть). Акцентные цвета счетов/статей
(--teal/--green/--purple/...) — уже тёмная палитра под этот же принцип (прямые цвета, не токены
системы), оставлены как есть. */
:root {
--bg: #0b0b0f;
--surface: #17171d;
--surface-2: #1e1e26;
--surface-3: #24242d;
--ink: #f2f2f5;
--ink-soft: #c4c4cc;
--muted: #85858f;
--bg: #161826;
--surface: #232532;
--surface-2: #292b31;
--surface-3: #3f424d;
--ink: #e9e9ed;
--ink-soft: #cfd3e5;
--muted: #9397ab;
--shadow: rgba(0, 0, 0, 0.5);
--divider: color-mix(in srgb, var(--ink) 16%, transparent);
--accent: #9184d9;
--accent-tint: #d2cefd;
--teal: #2dd4bf;
--green: #34d399;
@ -18,7 +27,15 @@
--amber: #fbbf24;
--gray: #6b7280;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--sans: "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--font-heading: "SF Pro Display", var(--sans);
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 14px;
--shadow-card: 0 0 0 1px var(--surface-3), 0 6px 18px var(--shadow);
--shadow-pop: 0 0 0 1px var(--surface-3), 0 16px 40px var(--shadow);
}
body {
@ -38,6 +55,37 @@ main {
.ico { width: 1.05em; height: 1.05em; flex-shrink: 0; }
/* ---------- шапка: дата-плашка + теги (день недели/месяц/% бюджета) ---------- */
.dash-kicker {
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.dash-tags {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
margin-top: 0.6rem;
}
.tag {
display: inline-flex;
align-items: center;
font-size: 0.72rem;
letter-spacing: 0.01em;
padding: 0.2rem 0.65rem;
border-radius: var(--radius-sm);
}
.tag-outline {
border: 1px solid var(--divider);
color: var(--ink-soft);
}
/* ---------- шапка: приветствие + баланс ---------- */
.top-row {
@ -59,11 +107,12 @@ main {
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
color: var(--accent-tint);
margin-top: 0.6rem;
}
.balance {
font-family: var(--font-heading);
font-size: 3rem;
font-weight: 800;
letter-spacing: -0.02em;
@ -92,6 +141,7 @@ main {
display: flex;
align-items: center;
justify-content: space-between;
font-family: var(--font-heading);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.1em;
@ -113,10 +163,10 @@ main {
.room-card {
position: relative;
background: var(--surface);
border-radius: 18px;
border-radius: var(--radius-lg);
padding: 1rem 1.1rem 1.15rem;
overflow: hidden;
box-shadow: 0 2px 10px var(--shadow);
box-shadow: var(--shadow-card);
}
.room-card::after {
@ -140,7 +190,7 @@ main {
.room-icon {
width: 2.1rem;
height: 2.1rem;
border-radius: 10px;
border-radius: var(--radius-md);
display: inline-flex;
align-items: center;
justify-content: center;
@ -150,7 +200,7 @@ main {
margin-bottom: 0.7rem;
}
.room-title { font-size: 0.92rem; font-weight: 600; }
.room-title { font-family: var(--font-heading); font-size: 0.92rem; font-weight: 600; }
.room-sub {
font-size: 0.76rem;
@ -188,8 +238,8 @@ main {
z-index: 5;
min-width: 160px;
background: var(--surface-2);
border-radius: 12px;
box-shadow: 0 8px 24px var(--shadow);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-pop);
padding: 0.35rem;
}
@ -200,7 +250,7 @@ main {
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.6rem;
border-radius: 8px;
border-radius: var(--radius-md);
font-size: 0.82rem;
color: var(--ink-soft);
}
@ -213,7 +263,7 @@ main {
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.6rem;
border-radius: 8px;
border-radius: var(--radius-md);
font-size: 0.82rem;
color: var(--ink-soft);
cursor: pointer;
@ -234,8 +284,8 @@ main {
.budget-list {
background: var(--surface);
border-radius: 16px;
box-shadow: 0 2px 10px var(--shadow);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
margin-bottom: 1.5rem;
}
@ -248,7 +298,7 @@ main {
gap: 1rem;
}
.budget-row + .budget-row { border-top: 1px solid rgba(255, 255, 255, 0.06); }
.budget-row + .budget-row { border-top: 1px solid var(--divider); }
.budget-group-head {
display: flex;
@ -259,9 +309,10 @@ main {
}
.budget-group-head + .budget-row { border-top: none; }
.budget-row + .budget-group-head { border-top: 1px solid rgba(255, 255, 255, 0.06); }
.budget-row + .budget-group-head { border-top: 1px solid var(--divider); }
.budget-group-name {
font-family: var(--font-heading);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.04em;
@ -275,11 +326,37 @@ main {
.budget-plan-amount { font-size: 0.84rem; font-weight: 600; color: var(--ink-soft); white-space: nowrap; }
/* строка статьи план/факт — название сверху, бар+подпись снизу, % справа (см. дизайн-хендофф) */
.budget-row-main { flex: 1; min-width: 0; }
.budget-bar {
height: 7px;
border-radius: 4px;
background: var(--surface-3);
overflow: hidden;
margin-top: 0.35rem;
}
.budget-bar-fill { height: 100%; border-radius: 4px; }
.budget-row-caption { font-size: 0.72rem; color: var(--muted); margin-top: 0.3rem; }
.budget-row-side { flex-shrink: 0; font-size: 0.84rem; font-weight: 600; }
/* ---------- копилки/цели: кольцо прогресса ---------- */
.goal-card { display: flex; flex-direction: row; align-items: center; gap: 0.9rem; }
.goal-ring { flex-shrink: 0; }
.goal-ring text { font-family: var(--sans); }
/* пустое состояние + кнопка добавить */
.empty-state {
border: 1px dashed var(--surface-3);
border-radius: 18px;
border-radius: var(--radius-lg);
padding: 2.5rem 1rem;
text-align: center;
color: var(--muted);
@ -298,7 +375,7 @@ main {
cursor: pointer;
}
.btn-add:hover { background: var(--blue); }
.btn-add:hover { background: var(--accent); }
/* ---------- архив счетов (модалка) ---------- */
@ -328,7 +405,7 @@ main {
justify-content: space-between;
gap: 1rem;
background: var(--surface);
border-radius: 14px;
border-radius: var(--radius-lg);
padding: 0.7rem 0.9rem;
}
@ -339,7 +416,7 @@ main {
min-width: 0;
}
.category-group { font-weight: 600; }
.category-group { font-family: var(--font-heading); font-weight: 600; }
.category-sub {
margin-left: 1.6rem;
@ -367,12 +444,12 @@ main {
.budget-card {
background: var(--surface);
border-radius: 14px;
border-radius: var(--radius-lg);
padding: 0.75rem 0.85rem;
box-shadow: 0 2px 8px var(--shadow);
box-shadow: var(--shadow-card);
}
.budget-card-name { font-size: 0.84rem; font-weight: 600; margin-bottom: 0.5rem; }
.budget-card-name { font-family: var(--font-heading); font-size: 0.84rem; font-weight: 600; margin-bottom: 0.5rem; }
.budget-months-inline { display: flex; gap: 0.35rem; }
@ -383,7 +460,7 @@ main {
align-items: center;
gap: 0.15rem;
background: var(--surface-2);
border-radius: 8px;
border-radius: var(--radius-md);
padding: 0.35rem 0.15rem;
min-width: 0;
}
@ -401,7 +478,7 @@ main {
padding: 0;
}
.plan-input:focus-visible { outline: 1px solid var(--teal); border-radius: 3px; }
.plan-input:focus-visible { outline: 1px solid var(--accent); border-radius: 3px; }
.plan-input::-webkit-outer-spin-button, .plan-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.plan-input { -moz-appearance: textfield; }
@ -418,7 +495,7 @@ main {
.budget-acc-card {
background: var(--surface);
border-radius: 12px;
border-radius: var(--radius-lg);
margin-bottom: 0.5rem;
overflow: hidden;
}
@ -428,6 +505,7 @@ main {
align-items: center;
gap: 0.6rem;
padding: 0.7rem 0.9rem;
font-family: var(--font-heading);
font-size: 0.84rem;
font-weight: 600;
cursor: pointer;
@ -462,7 +540,7 @@ main {
justify-content: space-between;
gap: 0.4rem;
background: var(--surface-2);
border-radius: 8px;
border-radius: var(--radius-md);
padding: 0.3rem 0.5rem;
}
@ -509,9 +587,9 @@ main {
a.dock-item:hover { color: var(--ink-soft); }
.dock-item.active {
color: var(--teal);
background: color-mix(in srgb, var(--teal) 16%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--teal) 35%, transparent);
color: var(--accent);
background: color-mix(in srgb, var(--accent) 16%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);
}
.dock-item.active .ico { transform: translateY(-1px); }
@ -521,3 +599,80 @@ a.dock-item:hover { color: var(--ink-soft); }
.balance { font-size: 2.4rem; }
.dock-item { padding: 0.5rem 0.65rem; }
}
/* ---------- Bootstrap: формы/кнопки/модалки под токены Nocturne ----------
Разметка форм (модалки счетов/статей) — штатные Bootstrap-компоненты (.form-control, .btn-primary
и т.п.), меняем только их отрисовку под палитру/радиусы проекта, не трогая JS/структуру. */
.btn { border-radius: var(--radius-md); }
.btn-primary {
background: var(--accent);
border-color: var(--accent);
}
.btn-primary:hover,
.btn-primary:focus,
.btn-primary:active {
background: color-mix(in srgb, var(--accent) 85%, black) !important;
border-color: color-mix(in srgb, var(--accent) 85%, black) !important;
}
.btn-outline-light {
border-color: var(--surface-3);
color: var(--ink-soft);
}
.btn-outline-light:hover,
.btn-outline-light:focus,
.btn-outline-light:active {
background: var(--surface-3) !important;
border-color: var(--surface-3) !important;
color: var(--ink) !important;
}
.btn-secondary {
background: transparent;
border-color: var(--surface-3);
color: var(--ink-soft);
}
.btn-secondary:hover,
.btn-secondary:focus,
.btn-secondary:active {
background: var(--surface-3) !important;
border-color: var(--surface-3) !important;
color: var(--ink) !important;
}
.form-control,
.form-select {
background: var(--bg);
border-color: var(--surface-3);
color: var(--ink);
border-radius: var(--radius-md);
}
.form-control:focus,
.form-select:focus {
background: var(--bg);
border-color: var(--accent);
color: var(--ink);
box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--accent) 25%, transparent);
}
.form-check-input {
background-color: var(--bg);
border-color: var(--surface-3);
}
.form-check-input:checked {
background-color: var(--accent);
border-color: var(--accent);
}
.form-check-input:focus {
box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--accent) 25%, transparent);
}
.modal-content { border-radius: var(--radius-lg); }

View File

@ -9,6 +9,10 @@
* @var object[] $accounts Счета пользователя (accounts, не архивные)
* @var object[] $archived_accounts Архивные счета пользователя (is_delete=1)
* @var float $total Сумма счетов с include_in_total=1 в рублях
* @var float $total_all Сумма всех (не архивных) счетов в рублях, без фильтра include_in_total
* @var array $fact_month [categorie_id => сумма] — факт за текущий месяц (реальные transactions)
* @var int|null $pct_expense % бюджета по расходам за месяц (факт/план), null — если план не задан
* @var object[] $goals Цели пользователя + данные счёта-копилки (account_title/account_summa/account_currency)
*/
use App\Classes\Currency;
@ -26,6 +30,26 @@ $type_accents = [
'savings' => 'var(--purple)',
];
$type_labels = [
'cash' => 'Наличные',
'bank' => 'Банковский счёт',
'savings' => 'Копилка',
];
$weekdays = [1 => 'Понедельник', 2 => 'Вторник', 3 => 'Среда', 4 => 'Четверг', 5 => 'Пятница', 6 => 'Суббота', 7 => 'Воскресенье'];
$month_names = [
1 => 'январь', 2 => 'февраль', 3 => 'март', 4 => 'апрель', 5 => 'май', 6 => 'июнь',
7 => 'июль', 8 => 'август', 9 => 'сентябрь', 10 => 'октябрь', 11 => 'ноябрь', 12 => 'декабрь',
];
$month_names_gen = [
1 => 'января', 2 => 'февраля', 3 => 'марта', 4 => 'апреля', 5 => 'мая', 6 => 'июня',
7 => 'июля', 8 => 'августа', 9 => 'сентября', 10 => 'октября', 11 => 'ноября', 12 => 'декабря',
];
$ucfirstMb = static fn (string $s): string => mb_strtoupper(mb_substr($s, 0, 1)) . mb_substr($s, 1);
$this->setScript('accounts.js');
?>
@ -93,11 +117,21 @@ $this->setScript('accounts.js');
</defs>
</svg>
<div class="dash-kicker"><?= $weekdays[(int)date('N')] ?>, <?= (int)date('j') ?> <?= $month_names_gen[(int)date('n')] ?></div>
<div class="dash-tags">
<span class="tag tag-outline">📅 <?= $ucfirstMb($month_names[(int)date('n')]) ?> <?= date('Y') ?></span>
<?php if ($pct_expense !== null): ?>
<?php $over = $pct_expense > 100; ?>
<span class="tag" style="background:color-mix(in srgb, <?= $over ? 'var(--red)' : 'var(--accent)' ?> 20%, transparent);color:<?= $over ? 'var(--red)' : 'var(--accent-tint)' ?>">Бюджет <?= $pct_expense ?>% потрачено</span>
<?php endif; ?>
</div>
<div class="top-row">
<div>
<div class="hello">Здравствуйте, <?= htmlspecialchars($user['full_name'] ?? $user['login'], ENT_QUOTES, 'UTF-8') ?></div>
<div class="balance-label">Доступно сейчас</div>
<div class="balance num"><?= number_format($total, 0, ',', ' ') ?> <span>₽</span></div>
<div class="balance-total num"><span class="label">Всего по всем счетам</span><?= number_format($total_all, 0, ',', ' ') ?> ₽</div>
</div>
</div>
@ -136,6 +170,7 @@ $this->setScript('accounts.js');
</div>
</div>
<div class="room-title"><?= htmlspecialchars($account->title, ENT_QUOTES, 'UTF-8') ?></div>
<div class="room-sub"><?= $type_labels[$account->type_acc] ?? '' ?></div>
<div class="room-value num">
<?= number_format((float)$account->summa, 2, ',', ' ') ?>
<?= htmlspecialchars($account->currency, ENT_QUOTES, 'UTF-8') ?>
@ -145,22 +180,48 @@ $this->setScript('accounts.js');
</div>
<?php endif; ?>
<?php
$month_names = [
1 => 'январь', 2 => 'февраль', 3 => 'март', 4 => 'апрель', 5 => 'май', 6 => 'июнь',
7 => 'июль', 8 => 'август', 9 => 'сентябрь', 10 => 'октябрь', 11 => 'ноябрь', 12 => 'декабрь',
];
<?php if (!empty($goals)): ?>
<div class="section-label"><span>Копилки и цели</span></div>
<div class="grid-cards grid-cards-goals">
<?php foreach ($goals as $goal): ?>
<?php
$saved = (float)$goal->account_summa;
$target = (float)$goal->target_summa;
$pct = $target > 0 ? min(100, $saved / $target * 100) : 0;
$r = 22;
$circumference = 2 * M_PI * $r;
$offset = $circumference * (1 - $pct / 100);
?>
<div class="room-card goal-card" style="--card-accent:var(--purple)">
<svg width="52" height="52" viewBox="0 0 52 52" class="goal-ring">
<circle cx="26" cy="26" r="<?= $r ?>" fill="none" stroke="var(--surface-3)" stroke-width="5"/>
<circle cx="26" cy="26" r="<?= $r ?>" fill="none" stroke="var(--purple)" stroke-width="5" stroke-linecap="round"
stroke-dasharray="<?= round($circumference, 2) ?>" stroke-dashoffset="<?= round($offset, 2) ?>"
transform="rotate(-90 26 26)"/>
<text x="26" y="30" text-anchor="middle" font-size="12" fill="var(--ink)"><?= round($pct) ?>%</text>
</svg>
<div>
<div class="room-title"><?= htmlspecialchars($goal->account_title, ENT_QUOTES, 'UTF-8') ?></div>
<div class="room-sub num">
<?= number_format($saved, 0, ',', ' ') ?> из <?= number_format($target, 0, ',', ' ') ?>
<?= htmlspecialchars($goal->account_currency, ENT_QUOTES, 'UTF-8') ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php
$groups = $tree[0] ?? [];
// Строки плана для секции типа $type (income|expense): группа с подкатегориями — заголовком
// группы + строками по ним; группа без подкатегорий — одной строкой на саму группу (тот же
// приём, что и в App/view/Budgets/budgets.html). Показываем только план — факта (реальных
// transactions) в проекте ещё нет, а прогресс-бар, который всегда 0%, только запутывает
// (см. фидбек пользователя); добавим план/факт, когда появятся операции. Замыкание, не
// Строки плана+факта для секции типа $type (income|expense): группа с подкатегориями —
// заголовком группы + строками по ним; группа без подкатегорий — одной строкой на саму группу
// (тот же приём, что и в App/view/Budgets/budgets.html). Факт — реальные transactions за месяц
// ($fact_month, 0 пока операций нет — это корректное значение, не заглушка). Замыкание, не
// именованная функция — шаблон может быть include'ится не один раз за процесс (например
// в тестах), именованная функция дала бы "Cannot redeclare".
$renderBudgetList = static function (array $groups, array $tree, array $plan_month, string $type): void {
$renderBudgetList = static function (array $groups, array $tree, array $plan_month, array $fact_month, string $type): void {
$type_groups = array_filter($groups, static fn ($g) => $g->type === $type);
if ($type_groups === []) {
@ -168,6 +229,26 @@ $renderBudgetList = static function (array $groups, array $tree, array $plan_mon
return;
}
// Строка одной статьи (или самостоятельной группы без детей) — план+факт+прогресс-бар.
// Перерасход (факт > плана) для расходов подсвечивается красным — для доходов это, наоборот,
// хорошо (получено больше плана), там всегда акцентный цвет.
$renderRow = static function (object $category, float $plan, float $fact, string $type): void {
$pct = $plan > 0 ? round($fact / $plan * 100) : ($fact > 0 ? 100 : 0);
$bar_width = min(100, $pct);
$over = $type === 'expense' && $fact > $plan && $plan > 0;
$pct_color = $over ? 'var(--red)' : 'var(--accent-tint)';
$bar_color = $over ? 'var(--red)' : 'var(--accent)';
echo '<div class="budget-row">'
. '<div class="budget-row-main">'
. '<div class="budget-name">' . htmlspecialchars($category->title, ENT_QUOTES, 'UTF-8') . '</div>'
. '<div class="budget-bar"><div class="budget-bar-fill" style="width:' . $bar_width . '%;background:' . $bar_color . '"></div></div>'
. '<div class="budget-row-caption num">' . number_format($fact, 0, ',', ' ') . ' из ' . number_format($plan, 0, ',', ' ') . ' ₽</div>'
. '</div>'
. '<div class="budget-row-side"><span class="num" style="color:' . $pct_color . '">' . $pct . '%</span></div>'
. '</div>';
};
echo '<div class="budget-list">';
foreach ($type_groups as $group) {
@ -186,13 +267,10 @@ $renderBudgetList = static function (array $groups, array $tree, array $plan_mon
. '<span class="budget-group-sum num">' . number_format($head_plan, 0, ',', ' ') . ' ₽</span>'
. '</div>';
// Самостоятельная статья (без подкатегорий) — плашка выше уже единственная строка на неё
// (название+план), отдельного .budget-row для неё не рисуем (см. комментарий выше).
foreach ($children as $category) {
$plan = $plan_month[$category->id] ?? 0.0;
echo '<div class="budget-row">'
. '<div class="budget-name">' . htmlspecialchars($category->title, ENT_QUOTES, 'UTF-8') . '</div>'
. '<div class="budget-plan-amount num">' . number_format($plan, 0, ',', ' ') . ' ₽</div>'
. '</div>';
$renderRow($category, $plan_month[$category->id] ?? 0.0, $fact_month[$category->id] ?? 0.0, $type);
}
}
@ -201,10 +279,10 @@ $renderBudgetList = static function (array $groups, array $tree, array $plan_mon
?>
<div class="section-label"><span>Доходы</span></div>
<?php $renderBudgetList($groups, $tree, $plan_month, 'income'); ?>
<?php $renderBudgetList($groups, $tree, $plan_month, $fact_month, 'income'); ?>
<div class="section-label"><span>Бюджет на <?= $month_names[$month] ?? '' ?></span></div>
<?php $renderBudgetList($groups, $tree, $plan_month, 'expense'); ?>
<?php $renderBudgetList($groups, $tree, $plan_month, $fact_month, 'expense'); ?>
<div class="modal fade" id="account-modal" tabindex="-1" data-bs-theme="dark">
<div class="modal-dialog">

View File

@ -20,6 +20,7 @@ $auth_user = Auth::instance()->getUser();
<title>Бюджет</title>
<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="/App/media/css/fonts.css">
<link rel="stylesheet" href="/App/media/css/dashboard.css">
<?php $this->getStyles(); ?>
</head>
@ -47,6 +48,19 @@ $auth_user = Auth::instance()->getUser();
<circle cx="12" cy="12" r="5"/>
<circle cx="12" cy="12" r="1.5"/>
</symbol>
<symbol id="ic-chart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" y1="20" x2="20" y2="20"/>
<rect x="6" y="12" width="3" height="8"/>
<rect x="13" y="7" width="3" height="13"/>
</symbol>
<symbol id="ic-analytics" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 17 9 11 13 15 21 7"/>
<polyline points="14 7 21 7 21 14"/>
</symbol>
<symbol id="ic-gear" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06A2 2 0 1 1 7.04 4.29l.06.06A1.65 1.65 0 0 0 8.92 4.68 1.65 1.65 0 0 0 9.93 3.17V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.32 9c.14.36.4.66.74.85.34.19.72.27 1.09.24H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</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"/>
@ -65,11 +79,11 @@ $auth_user = Auth::instance()->getUser();
</main>
<?php if ($auth_user !== null): ?>
<nav class="dock">
<a href="/" class="dock-item active" title="Обзор"><svg class="ico"><use href="#ic-home"/></svg></a>
<div class="dock-item" title="Счета"><svg class="ico"><use href="#ic-card"/></svg></div>
<div class="dock-item" title="Категории"><svg class="ico"><use href="#ic-tag"/></svg></div>
<div class="dock-item" title="Операции"><svg class="ico"><use href="#ic-swap"/></svg></div>
<div class="dock-item" title="Цели"><svg class="ico"><use href="#ic-target"/></svg></div>
<a href="/" class="dock-item active" title="Дашборд"><svg class="ico"><use href="#ic-home"/></svg></a>
<a href="/budgets" class="dock-item" title="Бюджет"><svg class="ico"><use href="#ic-chart"/></svg></a>
<div class="dock-item" title="Транзакции"><svg class="ico"><use href="#ic-swap"/></svg></div>
<div class="dock-item" title="Аналитика"><svg class="ico"><use href="#ic-analytics"/></svg></div>
<div class="dock-item" title="Настройки"><svg class="ico"><use href="#ic-gear"/></svg></div>
</nav>
<?php endif; ?>
<script src="/vendor/components/jquery/jquery.min.js"></script>