Compare commits

..

1 Commits

Author SHA1 Message Date
Дмитрий f696314b11 feat(frontend): Plan 5 Task 7 — router + nav + regions + ProjectCard + story 2026-05-11 19:30:45 +03:00
246 changed files with 615 additions and 53108 deletions
-6
View File
@@ -139,9 +139,3 @@ app/infection-summary.log
# Plan 3 Task 5 — Playwright Node subprocess (~200MB chromium downloads on prod)
app/playwright/node_modules/
# Superpowers using-git-worktrees — локальные worktrees вне репо
.claude/worktrees/
# Vitest coverage output (app/coverage/) — генерируется npm run test:coverage
/app/coverage/
-6
View File
@@ -87,12 +87,6 @@ paths = [
'''app/composer\.lock''',
# Pest-тесты с фиктивными data-фикстурами (не реальные ПДн)
'''app/tests/.*\.php''',
# Database seeders с демо-данными (admin@demo.local + +7916123XXXX демо-телефоны)
'''app/database/seeders/.*\.php''',
# Audit-internal docs (findings/blocked/report/plan) — содержат демо-телефоны и
# script-смешанные artifacts как finding'и для review (не реальные ПДн)
'''docs/superpowers/audits/.*\.md''',
'''docs/superpowers/plans/.*\.md''',
# Mock-данные для UI-разводки фронтенда (фиктивные имена/телефоны)
'''app/resources/js/composables/mockDeals\.ts''',
# Vitest-тесты с assertion на mock-данные (mock-телефоны из mockDeals)
+12 -18
View File
@@ -1,6 +1,6 @@
# CLAUDE.md — техконтекст Лидерры
**Версия:** 1.91 от 13.05.2026 (day +1) — Session-end documentation hygiene после CTO-19 ✅ closure via Lucide migration. Содержание: (1) §0 cross-ref row Pravila v1.11 → **v1.12** (sync: §4.6 +visual smoke methodology для UI-refactor; §4.7 +п.4 plans/specs relative paths `../../../`); (2) §9 +v1.91 entry. Связано: реестр v1.82→v1.83 (CTO-19 closure в commit `0832997`); audit `docs/superpowers/audits/2026-05-12-portal-full-audit-findings.md` Q.INFO.001 +audit methodology gap note (Phase 4 SAST checks must begin с `ls .github/workflows/`); memory quirks 74-76 (Lucide+Histoire peerDep / Vuetify-internal mdi defaults gap / plans-relative-paths). Регрессия зелёная (verified в commit `0832997`): Pest --parallel 742/739/0/3 ✅, Vitest 88 files / 683 passed + 3 skipped, Vite build 3.52s, axe-core /admin/billing 0 iconography violations, lychee 252 OK / 0 errors, gitleaks 0 (372+ commits). Workflow learning: `superpowers:brainstorming``:writing-plans``:subagent-driven-development` efficient для mechanical UI-refactor (icon migration). Через `/claude-md-management:revise-claude-md`. **v1.90 наследие:** Merge R15 motion-runtime removal cleanup из `origin/main` в `plan5-frontend-projects` (commits `0fd93fd` planning + `615db99` нормативная правка). Plan5 ветка форкнулась 12.05 утром от `48f27b4` ДО появления `615db99` на main; после 113 атомарных коммитов на plan5 (audit fixes, Plan 5 frontend Tasks 7-11, Quiet Luxury portal redesign, Q.DEFER.002/003/004 closures, audit-cleanup tail) — merge синхронизирует R15 changes. **§5 п.12** → маркер «Резерв (снят 12.05.2026, см. CHANGELOG)» (нумерация п.1–11 сохранена, чтобы cross-refs в memory не сломать). **§2 строка «Animation default stack»** переписана с regulatory denylist на guidance recommendation (motion-v/gsap/anime.js/lottie-web/popmotion/@motionone/dom — ✅ разрешены без обоснования; framer-motion остаётся technical block — React-only peerDep, runtime crash в Vue, не regulatory rule). **§0 cross-refs** обновлены — Pravila v1.10 → **v1.11**, PSR_v1 v1.7 → **v2.0**, Tooling v1.15 → **v1.16**. **§6 фаза** + **§8 self-review** строки (Plan 4/5 + Quiet Luxury + Q.DEFER closures context + schema baseline v8.19 + dev-actual factual) — preserved из plan5 v1.88/v1.89 base. Plan5 v1.89 factual fix §6 (615db99 = R15 removal, ≠ Plan 4) подтверждён и сохранён. NB: §9 содержит **две v1.88 entries** — plan5 audit schema-sync + origin/main R15-removal — это collision версионной нумерации parallel-branch bump'ов; обе валидны исторически, явно labelled в §9. Files fast-forwarded без conflict: `Plugin_stack_rules_v1.md` (R15 удалён, 162 lines diff), `Pravila_raboty_Claude_v1_1.md` (§11.5/§13.2 счётчик 16→15 + cross-refs), `Tooling_v8_3.md` (§9.2 reformulated). Через ручное conflict resolution на 2 файлах (CLAUDE.md + CHANGELOG_claude_md.md) + post-merge `/claude-md-management:revise-claude-md` polish (per §5 п.10). **v1.89 наследие:** factual fix §6 + шапка v1.88 changelog (615db99 ≠ Plan 4). **v1.88 наследие (plan5 branch):** audit-driven sync §0/§2/§6/§8 после полного аудита портала. Schema-метрики §0/§2/§8 разделены на «commit baseline v8.19» (62/12/117/39/5/13/5) + «dev-actual factual» (75/102/289/39/5/19/0). **v1.88 наследие (origin/main):** снятие R15 motion-runtime restrictions per user decision 12.05.2026 («сними все запреты на использование framer motion»); conscious rollback v1.83 audited construction. **v1.87 наследие:** sync schema-метрик после Plan 4 (Billing+CSV+Admin) на ветке `plan4-billing`. Schema **v8.11 → v8.19**. Предыдущая v1.86 — закрытие 13 находок третьего аудита (детали в [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md)).
**Версия:** 1.87 от 11.05.2026 — sync schema-метрик после Plan 4 (Billing+CSV+Admin) на ветке `plan4-billing` (15 коммитов готовы к FF-merge). Schema **v8.11 → v8.19** (накопленный drift от Plans 1+2+3+4): **56→62 базовых таблиц, 97→117 индексов, 38→39 RLS** + 5 функций / 13 триггеров. §0 источник истины row «Схема БД», §2 стек строка БД, §6 фаза, §8 self-review триггеры — обновлены. §6 расширен Plan 4 closure: 14 task-коммитов `a907fea..174dbae` + lychee CV-fix `fded2ee`, Pest 687/684 passed + 3 skipped/0 failed (2090 assertions), Vitest 49 files / 428 passed, Histoire 24 stories / 31 variants, lychee 0 broken, gitleaks 0 leaks. Активированы 7-ступенчатый pricing-tier биллинг (PricingTierResolver pure + LedgerService dual-balance prepaid→rub bcmath); CsvReconcileJob hourly с drift>5% алертом; auto-pause flow ZeroBalancePausedMail 1/час/tenant; 3 admin/tenant UI экрана (AdminPricingTiers + AdminSupplierPrices + ChargesTab в существующем BillingView). +7 новых Биз-25..31 в реестре (раздел 13 Открытые_вопросы v1.78). Через `/claude-md-management:revise-claude-md`. Предыдущая v1.86 — закрытие 13 находок третьего аудита правил использования плагинов и скилов (4 P0 + 5 P1 + 2 P2 + 2 sync-правки в README/README_АРХИВ). Через `/claude-md-management:claude-md-improver`. Ключевые правки: **P0-01 §3 header «Карта 28 инструментов» → «33 инструментов»** (header застрял с pre-FD эпохи, в то время как контент включает #1#33); **P0-02 §3.4 header «(+5, итого 28)» → «итого 29»** (после добавления #30 в фазу 2 фаза 3 cumulative должна быть 29, не 28); **P0-03 §3.3 footer «из 30 номеров минус #1 = 29 active» → «из 33 номеров (29 phase-slot + 3 off-phase + 1 historic)»** (формулировка предшествовала формализации #31/#32/#33); **P0-04 §6 «Активно: 19 инструментов из 29» + «(19/29 активны по фазам)» → «24 / (24/29)»** (внутренний арифметический конфликт: тут же раскладка 9+8+7=24, но числовая метка застряла на 19 с эпохи когда фаза 2 имела ~4 активных); **P1-06 §5 п.5 «PSR_v1 v1.5+» → «v1.7+»** (sync после bump'а PSR_v1); **P2-02 §3.3 #33 «вне Pravila §13» → «вне UI-пула §13»** (Pravila §13.2 v1.10 включает claude-md-management как infrastructure subsection — текущая формулировка вводила в заблуждение). Связанные обновления: **PSR_v1 v1.6 → v1.7** (sync cross-refs шапки на v1.86/v1.10/v1.15; description-fix описки «slot уровня 2.5» → «2b» внутри changelog'а v1.6, фактическое R0.1 всегда содержало «2b»). **Tooling v1.14 → v1.15** (sync cross-refs шапки на v1.86/v1.10/v1.7; §11.5/§12 «28 инструментов» → «33 формализованные позиции»). Pravila v1.10 — без изменений. Предыдущая v1.85 — закрытие 15 находок второго аудита (детали в [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md)).
**Назначение:** оперативная карта для Claude Code. Не первоисточник — первоисточники указаны в §0.
**Владелец и режим правок:** все изменения этого файла — **только** через плагин `claude-md-management` (skills `/claude-md-management:claude-md-improver` для audit/targeted-updates и `/claude-md-management:revise-claude-md` для capture session-learnings). Прямые правки запрещены — см. §5 п.11.
@@ -12,12 +12,12 @@
| Тема | Документ |
|---|---|
| Продуктовые правила работы Claude | [docs/Pravila_raboty_Claude_v1_1.md](docs/Pravila_raboty_Claude_v1_1.md) (**v1.12 от 13.05.2026 day +1** — methodology additions: §4.6 +«Для UI-refactor (icon migration / palette swap / layout overhaul)» subsection (visual smoke verification обязательна, unit tests jsdom недостаточны, Vuetify-internal default mdi-* gap learning от CTO-19); §4.7 +п.4 plans/specs относительные пути `../../../<target>` (lychee catches broken paths, прецедент CTO-19 fixup `f6e1e64`). v1.11 наследие — sync после PSR_v1 v2.0 (R15 снят): §11.5/§13.2 счётчик 16→15 правил R0R14; §13.9/§13.10 cross-ref v1.6→v2.0; §13.10 НЕ удалено — про R14, не R15. v1.10 наследие — §0 +note про §11 override) |
| **Правила совместного использования плагинов Claude** | [docs/Plugin_stack_rules_v1.md](docs/Plugin_stack_rules_v1.md) (**v2.0 от 12.05.2026** — major bump: removal of R15 motion-runtime restrictions per user decision; conscious rollback v1.4 audited construction. Удалено: R15 целиком (R15.1R15.7), R0.6 п.11, R8 motion тай-брейкеры (3), R11.6 motion иерархия, R13 motion-сценарии (5). Шапка count: «16 правил R0–R15» → «15 правил R0R14». framer-motion переведён из regulatory hard-запрета в technical-guidance уровень: peerDep на react+react-dom, не работает в Vue физически; v1.7 наследие — sync cross-refs; v1.6 наследие — R0.4.A свёрнут до cross-ref на Pravila §12.3 SoT, R0.6 пронумерован 1–11) |
| Полный реестр 33 формализованных позиций тулчейна (29 active + 3 off-phase + 1 historic) | [docs/Tooling_v8_3.md](docs/Tooling_v8_3.md) (**Прил. Н v1.16 от 12.05.2026** — §9.2 «Motion runtime библиотеки» переформулирован из regulatory denylist в technical guidance синхронно с PSR_v1 v2.0 (R15 снят): motion-v/gsap/anime.js/lottie-web/popmotion/@motionone/dom — ✅ разрешено без обоснования; framer-motion + react-spring — ❌ technical block (React-only peerDep), не regulatory rule. Cross-refs шапки sync: PSR_v1 v1.7+ → v2.0+, CLAUDE.md v1.86+ → v1.88+, Pravila v1.10+ → v1.11+; v1.15 наследие — sync cross-refs + «28 инструментов» → «33 формализованные позиции») |
| Продуктовые правила работы Claude | [docs/Pravila_raboty_Claude_v1_1.md](docs/Pravila_raboty_Claude_v1_1.md) (**v1.10 от 10.05.2026 вечер** — §0 +note про §11 локальное override-исключение над §2.2/§4.5/§8.4; §11.5 «10 правил» → «v1.6, 16»; §13.2 «v1.4 (15 правил)» → «v1.6 (16)»; §13.9/§13.10 PSR_v1 v1.4 → v1.6; v1.9 наследие — §12.3 SoT, §13.2 +claude-md-management off-pool, §13.6 hard-rule tier-структура) |
| **Правила совместного использования плагинов Claude** | [docs/Plugin_stack_rules_v1.md](docs/Plugin_stack_rules_v1.md) (**v1.7 от 10.05.2026 поздний вечер** — sync cross-refs шапки на актуальные версии связанных документов после bump'ов CLAUDE.md v1.85 → v1.86 и Tooling v1.14 → v1.15; description-fix описки «slot уровня 2.5» → «slot уровня 2b» внутри changelog'а v1.6 (фактическое R0.1 line 33 всегда содержало «2b»); v1.6 наследие — R0.4.A свёрнут до cross-ref на Pravila §12.3 SoT, R0.6 пронумерован 1–11; v1.5 наследие — R10.1 разбит на 3 блока (enabledPlugins/built-in/MCP), R10.4/R14.7 tier-метки, R8 +тай-брейкер FD↔21st) |
| Полный реестр 33 формализованных позиций тулчейна (29 active + 3 off-phase + 1 historic) | [docs/Tooling_v8_3.md](docs/Tooling_v8_3.md) (**Прил. Н v1.15 от 10.05.2026 поздний вечер** — sync cross-refs шапки на актуальные версии связанных документов после bump'ов CLAUDE.md v1.85 → v1.86 и PSR_v1 v1.6 → v1.7 («Pravila v1.9+» → «v1.10+», «PSR_v1 v1.5+» → «v1.7+», «CLAUDE.md v1.84+»«v1.86+»); §11.5/§12 «28 инструментов» → «33 формализованные позиции» (DevOps-раздел застрял с эпохи v1.0 — фразы «не входят в 28», «вне 28»); v1.14 наследие — §10.3 шаг 2 «3 skills» → «14», §13 +v1.13 +v1.14 entries, §7 +Tooling explicit slot 2b alongside CLAUDE.md; v1.13 наследие — §7 +PSR_v1 уровнем 3, §4.7 +#33 claude-md-management, §6 +5 конфликтов v1.4, §4.6 settings → .claude.json, §0 счётчик 33) |
| Главное ТЗ | [docs/CRM_bp-gr_Инструкция_v8_5.md](docs/CRM_bp-gr_Инструкция_v8_5.md) (v8.5 от 07.05.2026 — реализация 27 решений аудита C; in-place hygiene v1.20 от 08.05.2026 поздний вечер: §2.4/§5.5/§5.6/§6.5/§11/§20.12.3/§21.1/§27.1 синхронизированы под schema v8.6 двустадийный dedup) |
| Схема БД | [db/schema.sql](db/schema.sql) (**v8.19 от 11.05.2026** — Plan 4 (Billing+CSV+Admin): +1 таблица `supplier_csv_reconcile_log` SaaS-level, +3 колонки `tenants.delivered_in_month` / `lead_charges.charge_source` / `supplier_leads.recovered_from_csv_at`, +3 индекса, +2 CHECK. **Schema baseline (commit-факт):** 62 базовые таблицы + 12 партиций + 117 индексов + 39 RLS-политик + 5 функций (`audit_block_mutation`, `audit_chain_hash`, `calc_lead_score`, `report_jobs_log_export`, `set_pd_subject_request_deadline`) + 13 триггеров. **Dev `liderra` factual** после `migrate:fresh` + накопленных `partitions:create-months`: **75 root tables + 102 partition children + 289 indexes + 39 RLS + 5 user funcs + 19 triggers + 0 dev roles** (на prod 5 ролей через `db/00_create_roles.sql`). Verified 2026-05-12 audit Phase 3.) |
| Открытые вопросы | [docs/Открытые_вопросы_v8_3.md](docs/Открытые_вопросы_v8_3.md) (**v1.83 от 13.05.2026 (day +1) — CTO-19 ✅ closed** через Lucide migration: `npm i lucide-vue-next ^1.0.0` + custom Vuetify `IconSet` в `app/resources/js/plugins/vuetify.ts` с 103-entry mapping (78 user-grep'нутых mdi-* + 25 Vuetify-internal defaults). 51 view untouched. CLAUDE.md §2 «Иконки: Lucide» бренд-spec compliance achieved. **Сводка §0 после v1.83: 87 продуктовых / 71 ✅ / 5 🟦 / 11 ⏸ / 1 P0 + 5 P1 + 3 P2 + 2 P3**. **Регрессия: Pest --parallel 742/739/0/3 / Vitest 88 files / 683 passed + 3 skipped / Vite build 3.52s / axe-core /admin/billing 0 iconography violations**. Spec/plan в docs/superpowers/. v1.82 — Catch-up bump v1.77 → v1.82. v1.77 — Sprint 4 «Audit tail» (Pest 421 / Vitest 416). Section ## 13 collision fixed: Plan 4 → ## 14, Аудит C ## 13) |
| Схема БД | [db/schema.sql](db/schema.sql) (**v8.19 от 11.05.2026** — Plan 4 (Billing+CSV+Admin): +1 таблица `supplier_csv_reconcile_log` SaaS-level, +3 колонки `tenants.delivered_in_month` / `lead_charges.charge_source` / `supplier_leads.recovered_from_csv_at`, +3 индекса, +2 CHECK. Метрики: **62 базовые таблицы + 12 партиций + 117 индексов + 39 RLS-политик + 5 функций + 13 триггеров**) |
| Открытые вопросы | [docs/Открытые_вопросы_v8_3.md](docs/Открытые_вопросы_v8_3.md) (**v1.75 от 09.05.2026 — Post-MVP Reports backend закрыт** (4 этапа); MVP по Claude-зоне закрыт в v1.74; финал-метрики Pest 403/403 + Vitest 393/393 + Histoire 21/43) |
| **Брендбук** | [liderra_v8_handoff/docs/BRANDBOOK_v2.md](liderra_v8_handoff/docs/BRANDBOOK_v2.md) **(v2 Forest от 07.05.2026)** — старый `docs/brandbook.md` v1.1 удалён 08.05.2026 |
| **Дизайн-handoff (токены, компоненты, 25 экранов)** | [liderra_v8_handoff/docs/DEVELOPER_HANDOFF.md](liderra_v8_handoff/docs/DEVELOPER_HANDOFF.md) (v8 Forest от 07.05.2026) — **только дизайн/токены/компоненты**; функционал и состав экранов — по ТЗ v8.5 |
| Анализ оригинала | [docs/Analiz_originala_v8_3.md](docs/Analiz_originala_v8_3.md) (Прил. М v1.1) |
@@ -64,7 +64,7 @@
|---|---|
| Backend | PHP 8.3 + **Laravel 13** (мажор переоткрыт 08.05.2026 поздний вечер: при `composer create-project laravel/laravel` без `^11` Composer подтянул свежайшую 13.7; live-проверка совместимости — Boost v2.4.6, Larastan v3.9.6, Pest v4.7.0, IDE Helper v3.7.0, Pint v1.29 работают; принято заказчиком) |
| Frontend | Vue 3 + **Vuetify 3** (НЕ Tailwind, НЕ Inertia, НЕ Livewire, НЕ Filament) |
| БД | PostgreSQL 16. **Schema baseline (v8.19 commit-факт):** 62 базовые таблицы + 12 партиций, 117 индексов, 39 RLS-политик, 5 ролей БД, 13 триггеров, 5 user-функций. **Dev `liderra` factual** (после `migrate:fresh` + накопленных partition'ов от `partitions:create-months`): **75 root tables + 102 partition children, 289 indexes, 39 RLS, 5 user funcs, 19 triggers, 0 dev roles** (на prod 5 ролей через `db/00_create_roles.sql`). 5-я роль `crm_supplier_worker` BYPASSRLS введена в Plan 3 для sharing-flow + используется Plan 4 ResetMonthlyCountersCommand + CsvReconcileJob. Audit-verified 2026-05-12 (Phase 3). |
| БД | PostgreSQL 16 (**62 базовые таблицы + 12 партиций, 117 индексов, 39 RLS-политик, 5 ролей БД, 13 триггеров, 5 функций** — schema v8.19 от 11.05.2026; backend multi-tenant фундамент развёрнут на dev `liderra` через `php artisan migrate:fresh`; 5-я роль `crm_supplier_worker` BYPASSRLS введена в Plan 3 для sharing-flow + используется Plan 4 ResetMonthlyCountersCommand + CsvReconcileJob) |
| Кэш / очереди | Redis 7 |
| Pooler | PgBouncer (transaction pooling) |
| Облако | Yandex Cloud, регион `ru-central1` (Москва) |
@@ -73,7 +73,7 @@
| Sentry | self-hosted в Yandex Cloud |
| Helpdesk | JivoSite |
**Шрифты:** Inter (UI, axis `opsz` 14..32), JetBrains Mono (numerics с `tnum`, код). **Иконки:** Lucide. **Палитра v8 Forest:** Teal `#0F6E56` (primary, неоспариваемый), `#F6F3EC` warm ivory (page bg), `#012019` теало-нуар (sidebar). 14 OKLCH-статусов в [BRANDBOOK_v2 §3.6](liderra_v8_handoff/docs/BRANDBOOK_v2.md) — **палитра используется**, но мапить на 14 slug'ов из [db/schema.sql:2076](db/schema.sql#L2076) (источник истины для статусов воронки — schema/ТЗ §6.4, не handoff). **A11y:** WCAG 2.1 AA. **Animation default stack (рекомендация, не hard-rule с v1.88):** Vue native `<Transition>` / `<TransitionGroup>` + Vuetify transitions (`v-fade`, `v-slide-y`, `v-scale`, `v-expand`, `v-dialog-transition`) + CSS `@keyframes` + `prefers-reduced-motion` + View Transitions API (Chrome 111+ / Safari 18+). Motion-runtime библиотеки (`motion-v`, `gsap`, `anime.js`, `lottie-web`, `popmotion`, `@motionone/dom`) — разрешены к установке без обоснования. `framer-motion`**technical block** (React-only peerDep на `react+react-dom`, runtime crash в Vue физически), не regulatory rule — см. [Tooling §9.2](docs/Tooling_v8_3.md) technical guidance.
**Шрифты:** Inter (UI, axis `opsz` 14..32), JetBrains Mono (numerics с `tnum`, код). **Иконки:** Lucide. **Палитра v8 Forest:** Teal `#0F6E56` (primary, неоспариваемый), `#F6F3EC` warm ivory (page bg), `#012019` теало-нуар (sidebar). 14 OKLCH-статусов в [BRANDBOOK_v2 §3.6](liderra_v8_handoff/docs/BRANDBOOK_v2.md) — **палитра используется**, но мапить на 14 slug'ов из [db/schema.sql:2076](db/schema.sql#L2076) (источник истины для статусов воронки — schema/ТЗ §6.4, не handoff). **A11y:** WCAG 2.1 AA. **Animation default stack** (R11.6 + R15 PSR_v1): Vue native `<Transition>` / `<TransitionGroup>` + Vuetify transitions (`v-fade`, `v-slide-y`, `v-scale`, `v-expand`, `v-dialog-transition`) + CSS `@keyframes` + `prefers-reduced-motion` + View Transitions API (Chrome 111+ / Safari 18+). motion-v / framer-motion / gsap / anime.js / lottie-web**не установлены** и условно разрешены только по R15.2 (4 триггера), см. §5 п.12.
---
@@ -184,7 +184,7 @@ trivy image liderra:latest
2. **Не использовать Inertia / Livewire / Tailwind / Filament / Flux UI / Nova / Folio / Volt / Wayfinder guidelines** Boost'ау нас Vue + Vuetify.
3. **Не запускать a11y через Lighthouse** — единственный источник истины Pa11y.
4. **Не помещать ПДн / токены / API-ключи в коммиты.** Правило §5.2 правил Claude. Защита — gitleaks в pre-commit.
5. **Расширенный пул UI-инструментов — координируется через [PSR_v1](docs/Plugin_stack_rules_v1.md) v2.0+.** Кратко: paired-stack ядро (**Superpowers** = процесс / **Frontend Design** = решатель UI), плюс два инструмента **в роли материала, не решателя**: UPM (резерв-библиотека, R10.1/R11.5/R14.3) и 21st Magic MCP (генератор шаблонов, R10.1/R14.4). Все четыре проходят **R6.0 фильтр стека** (срезать React/Tailwind/shadcn/JSX → Vue 3 + Vuetify 3) и **R6.1 hard-override Forest** (палитра/шрифты/иконки/aesthetic — Brandbook, не плагины). UPM и 21st **не параллельно** с FD и друг с другом (R14.5). **A11y технический** — за Pa11y (п.3); плагины покрывают только a11y-принципы. **Детали — PSR_v1 R6/R10/R11/R14** (не копировать сюда — оперативная карта остаётся компактной).
5. **Расширенный пул UI-инструментов — координируется через [PSR_v1](docs/Plugin_stack_rules_v1.md) v1.7+.** Кратко: paired-stack ядро (**Superpowers** = процесс / **Frontend Design** = решатель UI), плюс два инструмента **в роли материала, не решателя**: UPM (резерв-библиотека, R10.1/R11.5/R14.3) и 21st Magic MCP (генератор шаблонов, R10.1/R14.4). Все четыре проходят **R6.0 фильтр стека** (срезать React/Tailwind/shadcn/JSX → Vue 3 + Vuetify 3) и **R6.1 hard-override Forest** (палитра/шрифты/иконки/aesthetic — Brandbook, не плагины). UPM и 21st **не параллельно** с FD и друг с другом (R14.5). **A11y технический** — за Pa11y (п.3); плагины покрывают только a11y-принципы. **Детали — PSR_v1 R6/R10/R11/R14** (не копировать сюда — оперативная карта остаётся компактной).
6. **Не ставить два инструмента на одну задачу** — список 10+ запрещённых дублей в [docs/Tooling_v8_3.md](docs/Tooling_v8_3.md) §9.
7. **Не редактировать этот `CLAUDE.md` без обновления** [docs/Pravila_raboty_Claude_v1_1.md](docs/Pravila_raboty_Claude_v1_1.md) и [docs/Tooling_v8_3.md](docs/Tooling_v8_3.md) — иначе три источника разойдутся (применяется ВНУТРИ flow п.10; пропуск синхронизации — отдельная ошибка даже при работающем плагине).
8. **Не править `db/schema.sql`** без записи в [db/CHANGELOG_schema.md](db/CHANGELOG_schema.md) — правило §4.2 правил Claude.
@@ -196,7 +196,7 @@ trivy image liderra:latest
Плагин — **единственный** интерфейс ведения файла; он отвечает за содержание и качество (по `references/quality-criteria.md` плагина: commands/architecture/non-obvious patterns/conciseness/currency/actionability). Прямые `Edit`/`Write` по `CLAUDE.md` без вызова skill'а — нарушение, фиксировать в feedback. Внутри flow плагина продолжают действовать пп.7 (синхронизация Pravila + Tooling) и общие §4 правил Claude.
11. **Не пропускать инвокацию Superpowers skill'а** для задачи, попадающей под карту §12.2 правил Claude (TDD, debug, plan, parallel, review, verify, brainstorm, worktree, finishing PR, subagent, writing-skills). Это **hard rule** (§12 правил Claude), §9 «Отступления» к нему **не применяется**. Рационализация типа «эта задача проще, чем требует skill» / «сейчас быстрее без skill'а» — нарушение того же уровня, что игнорирование §5 ПДн. **Список exclusions — Pravila §12.3 (Single Source of Truth, v1.9+)**: при расширении правок здесь — править только Pravila §12.3, не дублировать список текстом сюда. Запрос заказчика «не используй superpowers сейчас» — единственная отмена, и **только** на текущее действие. См. Pravila §12.4.
12. **Резерв.** Был «не устанавливать motion runtime библиотеки без прохождения R15.2 PSR_v1». Снят 12.05.2026 (CLAUDE.md v1.88 + PSR_v1 v2.0). Motion-runtime библиотеки разрешены без обоснования; `framer-motion` остаётся technical block (React-only peerDep). Подробности — [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md) запись v1.88, [docs/Tooling_v8_3.md](docs/Tooling_v8_3.md) §9.2.
12. **Не устанавливать motion runtime библиотеки без прохождения R15.2 PSR_v1** (4 условия триггера) — `framer-motion` (R15.1 hard-запрет навсегда: React-only архитектурно, не работает в Vue), `react-spring` (R15.1-аналог: тоже React-only), `motion-v` (R15.2: Vue 3 порт framer-motion, условно по 4 триггерам), `gsap`, `anime.js`, `react-spring`, `lottie-web`, `popmotion`, `@motionone/dom` (R15.7: аналогично). **Default motion stack** — Vue native `<Transition>` / `<TransitionGroup>` + Vuetify transitions + CSS `@keyframes` + View Transitions API (R11.6 уровни 3–6). Большинство задач закрываются на уровнях 1–2. Установка любой animation runtime библиотеки в `package.json`**R0.6 пункт 11 hard-стоп**, Auto mode не отменяет. Подробности — PSR_v1 v1.6 R15 + Tooling Прил. Н v1.14 §9.2.
---
@@ -205,8 +205,7 @@ trivy image liderra:latest
**Post-MVP (10.05.2026).** Фазы 0/1/2 по тулчейну закрыты (24/29 активны по фазам после установки Frontend Design plugin v1.78). **+3 off-phase tools формализованы 10.05.2026:** #31 UI UX Pro Max (skill, резерв-библиотека UI, формализован v1.83), #32 21st.dev Magic MCP (генератор шаблонов UI, формализован v1.83), **#33 claude-md-management** (skills, инфраструктура CLAUDE.md edits — формализован в v1.84 после audit находки «5-й включённый плагин без номера в реестре»). UPM + 21st активируются только через PSR_v1 v1.6 R14 pipeline; claude-md-management — обязательный канал правок CLAUDE.md (§5 п.10), регулируется PSR_v1 R10.1 блок 1. Итого формализованных позиций тулчейна: **33** (19/29 активных по фазам + 3 off-phase + 1 заменённый PG MCP исторически — слот #1, заменён #10 Boost в фазе 1, формально остаётся в реестре). Без «+1 historic» арифметика «33» не сходится — это правка v1.85. MVP Claude-зоны закрыт в v1.72; затем закрыт Reports backend epic (v1.73, 4 этапа `19f319c..e0ffe7e`). 13/13 экранов handoff покрыты UI + 3 ErrorView (404/403/500); landing ⏸ Б-1. Backend: auth (login/register/2FA/recovery/forgot/reset) + deals API (index/show/store/update/transition/destroy/restore/export-CSV+XLSX) + 3 lookup-API + reminders CRUD + cron + in_app_notifications + bell-UI polling + notification-preferences PATCH + admin (tenants/billing/incidents/system) + impersonation + webhook receive (HMAC + per-token rate-limit). Все 8 schema-default событий уведомлений интегрированы. **Pest 403/403, Vitest 393/393, Histoire 21/43.**
- Активно: **24 инструмента из 29 phase-slot** — 9 из фазы 0 (см. §3.1) + 8 из фазы 1: **#10 Boost v2.4.6**, **#11 Pint v1.29**, **#12 Larastan v3.9.6**, **#13 Roave/SecurityAdvisories**, **#14 IDE Helper v3.7.0**, **#15 squawk v2.51.0**, **#16 pgFormatter v5.9**, **#18 Pest v4.7.0** + 7 из фазы 2 (закрыта по тулчейну, см. ниже): #19 Superpowers + #2024 + **#30 Frontend Design plugin** (paired stack). 9+8+7=24. Off-phase tools (#31 UPM + #32 21st + #33 claude-md-management) — также активны в `~/.claude/settings.json`/`~/.claude.json`, но регулируются отдельной механикой R10/R14 PSR_v1 / §5 п.10 (не входят в фазовую раскладку).
- **Plan 4 (Billing + CSV Reconcile + Admin) MERGED в `origin/main`** — Plan 4 closure marker `8681040` («docs: Plan 4 closure — CLAUDE.md v1.87 + Открытые_вопросы v1.78»); backend task-коммиты `a907fea..174dbae` (Tasks 9-11) merged ранее. **Post-Plan-4 на origin/main отдельно подъехала R15 motion-runtime removal история:** `0fd93fd` (design+plan) + `615db99` («chore(rules): remove R15 motion-runtime restrictions (PSR_v1 v2.0)») — НЕ часть Plan 4. Schema v8.18 → v8.19 (новая таблица `supplier_csv_reconcile_log`, +3 колонки, +3 индекса, +2 CHECK). Активирован 7-ступенчатый pricing-tier биллинг (`PricingTierResolver` pure + `LedgerService` dual-balance prepaid→rub через bcmath); `CsvReconcileJob` hourly с drift>5% алертом; auto-pause flow `ZeroBalancePausedMail` 1/час/tenant; 3 UI экрана (`AdminPricingTiersView` + `AdminSupplierPricesView` + `ChargesTab` в `BillingView`).
- **Plan 5 frontend (Tasks 7-11) + Quiet Luxury portal redesign (20 commits) + dev-indices (10 commits) в ветке `plan5-frontend-projects`** (85+ commits ahead of `origin/main` на 12.05.2026 после audit-fix-серии): backend ProjectController 8 методов + schema v8.20 (post-merge) + 41 Pest; frontend 6 commits + Vitest delta +25 specs; Quiet Luxury foundation CSS (tokens/typography/motion) + 3 composables + 4 UI primitives + AppSidebar rewrite + 4 view applications; DevIndexBadge temporary feedback feature. **Post-merge factual baseline 12.05.2026:** Pest 742 / Vitest 614 + 3 skipped / Histoire 35 stories / 63 variants / Vite build 1.80s / 0 lychee broken / 0 gitleaks. +7 новых **Биз-25..31** в реестре (Plan 4). Drive-by closure: Plan 1 deferred WARNING #7 (SupplierProjectFactory random race) — fixed в Task 10 `0f820c4`.
- **Plan 4 (Billing + CSV Reconcile + Admin) на ветке `plan4-billing` ready for FF-merge (11.05.2026):** 15 коммитов поверх main HEAD `926fee9`. Schema v8.18 → v8.19 (новая таблица `supplier_csv_reconcile_log`, +3 колонки, +3 индекса, +2 CHECK). Активирован 7-ступенчатый pricing-tier биллинг (`PricingTierResolver` pure + `LedgerService` dual-balance prepaid→rub через bcmath); `CsvReconcileJob` hourly с drift>5% алертом; auto-pause flow `ZeroBalancePausedMail` 1/час/tenant; 3 UI экрана (`AdminPricingTiersView` + `AdminSupplierPricesView` + `ChargesTab` в `BillingView`). **Pest 687/684 passed + 3 skipped/0 failed (2090 assertions); Vitest 49 files / 428 passed; Histoire 24 stories / 31 variants; lychee 0 broken; gitleaks 0 leaks.** +7 новых **Биз-25..31** в реестре (раздел 13 Открытые_вопросы v1.78). Drive-by closures: Plan 1 deferred WARNING #7 (SupplierProjectFactory random race) — closed в `0f820c4` (Task 10).
- Готово в фазе 1: Laravel 13.7 в `app/`, predis 3.4.2, **schema.sql v8.19 развёрнута через `migrate:fresh` (871 ms, 1 миграция `load_initial_schema.php` — raw SQL через `DB::unprepared(file_get_contents(...))`)**, 3 default Laravel-миграции удалены (users/cache/jobs дублировались с нашей schema), smoke-test'ы (**Pest 19/19 за 1711 ms** — 4 RLS smoke + 8 model smoke + 5 middleware + 2 default; Pint passed, PHPStan analyse passed с baseline, ide-helper:generate OK + ide-helper:models -W -M -N для @mixin IdeHelper*, squawk 0 issues с конфигом, pgFormatter dry-run OK), MCP-сервер `boost:mcp` через Roster auto-detect (9 tools, JSON-RPC 2024-11-05). **Eloquent-модели**: `Tenant`, `User`, `Project` (+ factories) — `User` переписан под нашу схему (`password_hash` вместо `password`, override `getAuthPassword()`), Soft Deletes на Tenant + User. **Middleware `SetTenantContext`** (alias `tenant`): резолюция tenant_id из `auth()->user()`, subdomain или `X-Tenant-Id` header → `SET LOCAL app.current_tenant_id` в обёртке транзакции (PgBouncer-safe). **Deployment-скрипты ролей БД** для production: `db/00_create_roles.sql`, `db/02_grants.sql`. На dev — `postgres` superuser. **CTO-13 RLS smoke-test реализован**: `tests/Feature/RlsSmokeTest.php` + `TenantModelsTest.php` + `SetTenantContextTest.php`.
- Артефакты фазы 0 без изменений: 17 файлов архива (narrative v8.5 финал 07.05.2026), **13 концептов v8 Forest в [web/v8/](web/v8/)**.
- **Стек dev**: native Windows. PostgreSQL 16 (Chocolatey, Windows-сервис) + Memurai Developer (Redis 7-совместимый, Windows-сервис) + native PHP 8.3 + Composer. **Без Docker, без WSL2** — машина OpenStack-VPS не пробрасывает nested virtualization. Подробности — `memory/project_phase1_strategy.md`.
@@ -242,7 +241,7 @@ trivy image liderra:latest
| Файл | Что проверять |
|---|---|
| `db/schema.sql` | 0 orphan-FK, целостность RLS, метрики сверять с **schema baseline v8.19** (62 базовые таблицы + 12 партиций + 117 индексов + 39 RLS-политик + 5 функций + 13 триггеров) ИЛИ с **dev-actual фактом** (75 + 102 + 289 + 39 + 5 + 19 — varies от partition accumulation, audit-verified 2026-05-12), 0 дубликатов `CREATE TABLE` |
| `db/schema.sql` | 0 orphan-FK, целостность RLS, метрики сверять с текущей версией (**v8.19** = 62 базовые таблицы + 12 партиций + 117 индексов + 39 RLS-политик + 5 функций + 13 триггеров), 0 дубликатов `CREATE TABLE` |
| narrative `.md` | Версии в шапке/колонтитуле, 0 «готовится»/«TBD», кросс-ссылки на актуальные имена файлов |
| Прил. А–Н | Версия совпадает с narrative; все упомянутые подразделы существуют |
| Прил. Н (этот реестр инструментов) | Ровно 29 в активном наборе; 0 дублей; синхронность с этим CLAUDE.md |
@@ -255,11 +254,6 @@ trivy image liderra:latest
Полная история — [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md) (вынесена 09.05.2026 при правке v1.73→v1.74 ради лаконичности шапки). Здесь — последние правки:
- **v1.91 от 13.05.2026 (day +1)** — Session-end documentation hygiene после CTO-19 ✅ closure via Lucide migration. **§0 row Pravila** bumped v1.11 → v1.12 (methodology additions: §4.6 +UI-refactor visual smoke; §4.7 +п.4 plans/specs relative paths). **Связано:** реестр v1.82→v1.83 (CTO-19 closure в commit `0832997`, `f6e1e64` link fixup); audit `findings.md` Q.INFO.001 +audit methodology gap note (Phase 4 SAST coverage check must begin с `ls .github/workflows/` — пропустил `.github/workflows/sast.yml` 12.05.2026); memory quirks 74-76 (Lucide+Histoire `--legacy-peer-deps` / Vuetify-internal default mdi-* gap / plans-relative-paths `../../../`). **Без изменений:** §0 cross-refs PSR_v1 v2.0 / Tooling v1.16 / реестр v1.83 (актуальные); §2-§8 контент invariant; код / schema / migrations / тесты — нетронуты. Регрессия (фактическая, не verified в этом bump'е — verified в предыдущем commit `0832997`): Pest --parallel 742/739/0/3, Vitest 88 files / 683 / 3 skipped, Vite build 3.52s, axe-core 0 iconography violations. **Через:** `superpowers:brainstorming` (F-option scope clarification) → `:writing-plans``/claude-md-management:revise-claude-md` (для этого CLAUDE.md bump per §5 п.10) + ручные Edit (Pravila §4.6/§4.7 + audit findings.md). Workflow learning (capture для future sessions): для mechanical UI-refactor пайплайн brainstorming → writing-plans → subagent-driven-development efficient (CTO-19 case).
- **v1.90 от 13.05.2026 (day)** — Merge R15 motion-runtime removal cleanup из `origin/main` в `plan5-frontend-projects`. Merge-base `48f27b4`; plan5 был 113 ahead / 2 behind. Origin/main за этот период получила 2 коммита: `0fd93fd` (planning artefacts spec+plan, +2 files) + `615db99` (нормативная правка 5 файлов: PSR_v1 v1.7→v2.0, Pravila v1.10→v1.11, Tooling v1.15→v1.16, CLAUDE.md v1.87→v1.88, CHANGELOG entry). `git merge-tree` показал ровно 2 conflict'а: CLAUDE.md (шапка version + §9 entries) и CHANGELOG_claude_md.md (entries). Остальные 3 нормативных файла fast-forward без conflict'а (plan5 не редактировал их после fork). **Конфликт-resolution:** шапка → v1.90 unified; §0 cross-refs → take origin/main (Pravila v1.11 / PSR_v1 v2.0 / Tooling v1.16); §2 Animation default stack → take origin/main (motion-runtime guidance); §5 п.12 → take origin/main (marker «Резерв (снят 12.05.2026)»); §6 фаза + §8 self-review → keep plan5 (Plan 4 MERGED + Plan 5 frontend + Quiet Luxury context); §9 история версий → keep both v1.88 entries explicitly labelled (plan5 audit schema-sync + origin/main R15 removal — distinct concerns, version-number collision result of parallel-branch bump'ов), plus v1.89 plan5 factual fix + new v1.90 merge entry. **Через ручное conflict resolution + post-merge `/claude-md-management:revise-claude-md` polish (per §5 п.10).** Memory updates после push: `feedback_plugin_paired_stack.md` (remove branch-divergent note + bump tier-структуру к v2.0), `project_state.md` (branch counters), `reference_archive.md` (file version refs).
- **v1.89 от 12.05.2026 (ночь, post-audit continuation)** — factual fix §6 + шапка v1.88 changelog: коммит `615db99` ошибочно представлен как Plan 4 merge (фактически `615db99` это R15 motion-runtime removal commit «chore(rules): remove R15 motion-runtime restrictions (PSR_v1 v2.0)»; правильный Plan 4 closure marker на origin/main — `8681040` «docs: Plan 4 closure — CLAUDE.md v1.87 + Открытые_вопросы v1.78», backend task-коммиты Plan 4 `a907fea..174dbae` (Tasks 9-11) merged ранее). Дополнительно: коммит `f4ec5dc` («fix(redesign): sidebar position:fixed + main padding-left — restore main content visibility» — Quiet Luxury hotfix на ветке `plan5-frontend-projects`) ошибочно представлен в v1.88 §6 как PSR_v1 R15 removal — убран из §6 формулировки (Quiet Luxury hotfix не связан с R15 motion-runtime removal и не находится на origin/main). Связанные документы НЕ требуют изменений: Pravila v1.10 / PSR_v1 v1.7 / Tooling v1.15 / реестр v1.77 на ветке `plan5-frontend-projects` остаются как есть; фактологический фикс локален в CLAUDE.md. Verified через `git show 615db99 --stat` (subject «chore(rules): remove R15 motion-runtime restrictions (PSR_v1 v2.0)») + `git show 8681040` (subject «docs: Plan 4 closure — CLAUDE.md v1.87 + Открытые_вопросы v1.78») + `git show f4ec5dc` (subject «fix(redesign): sidebar position:fixed + main padding-left — restore main content visibility»). Заказчик: «доделывать аудит, поправить ошибку в CLAUDE.md». Через `/claude-md-management:claude-md-improver`. *(NB v1.90 post-merge: связанные документы Pravila/PSR_v1/Tooling всё-таки обновились — но не из-за фактологического фикса плана5, а из-за подтянутого R15 removal из origin/main. Этот NB не отменяет v1.89 logic — он добавляет post-merge context.)*
- **v1.88 от 12.05.2026 (ночь) — plan5 branch (audit schema-sync)** — audit-driven sync §0/§2/§6/§8 после полного аудита портала (`docs/superpowers/audits/2026-05-12-portal-full-audit-*.md`). Заказчик: «проведи полный аудит всего портала ... исправь все что сможешь в моё отсутствие». Через `/claude-md-management:revise-claude-md`. **Ключевые правки:** **§0 row «Схема БД»** — добавлено «schema baseline v8.19» metrics + «dev-actual factual» 75/102/289/39/5/19/0 (после `migrate:fresh` + накопленных `partitions:create-months`), 5 user-функций перечислены поимённо (audit_block_mutation, audit_chain_hash, calc_lead_score, report_jobs_log_export, set_pd_subject_request_deadline). **§0 row «Открытые_вопросы»** — v1.75 → v1.77 (Sprint 4 Audit tail close); добавлено note о post-v1.77 deviation (Plan 4/5 + Quiet Luxury merged без registry bump). **§2 row «БД»** — аналогично §0 schema-row, baseline + factual split. **§6 фаза** — «Plan 4 ready for FF-merge» → «Plan 4 MERGED в origin/main `8681040`» + новый параграф про Plan 5 frontend Tasks 7-11 + Quiet Luxury portal redesign + dev-indices в `plan5-frontend-projects` ветке (85+ commits ahead). *(NB v1.89: исходная v1.88 формулировка указывала `615db99` для Plan 4 merge — factual error, по факту `615db99` это R15 motion-runtime removal commit; исправлено post-audit в v1.89.)* **§8 self-review row** — добавлено разделение «baseline ИЛИ dev-actual». **Audit-fixes batch** (commits `3a8229a..audit-final`): Histoire build broken (P0 BulkActionsBar.story Pinia) fixed → 35 stories / 63 variants build OK; vue-tsc 9 errors fixed (AppSidebar NavItem.countKey + Project type unify); ESLint 17 errors fixed (test mocks any → unknown + vitest/no-disabled-tests cleanup + unused beforeEach); Prettier --write 37 files; markdownlint --fix 165 → 1 left (untracked design.md); cspell +79 words в `cspell-words.txt` 187 → 18 issues; routes/web.php +explicit Route::view для `/projects, /reminders, /admin/*`. **Регрессии:** 0. Final factual baseline: Pest 742 / Vitest 614 + 3 skipped / vue-tsc 0 / ESLint 0 / markdownlint 1 (untracked) / cspell 18 (mixed-script artifacts) / lychee 0 broken / gitleaks 0.
- **v1.88 от 12.05.2026 — origin/main (R15 motion-runtime removal)** — снятие R15 motion-runtime restrictions per user decision 12.05.2026 («сними все запреты на использование framer motion»). Conscious rollback v1.83 audited construction (10.05.2026, R15 двухуровневая motion-конструкция была введена через brainstorming → «двухуровневый» подтверждение заказчика; v1.88 — namesake rollback). **§5 п.12** → маркер «Резерв (снят 12.05.2026, см. CHANGELOG)» (нумерация п.1–11 сохранена, чтобы cross-refs в memory `feedback_environment.md` / `feedback_plugin_paired_stack.md` не сломать); **§2 строка «Animation default stack»** переписана с regulatory denylist на guidance recommendation; **§0 cross-refs** обновлены — Pravila v1.10 → v1.11, PSR_v1 v1.7 → v2.0, Tooling v1.15 → v1.16. **framer-motion** — technical block (peerDep react+react-dom, не работает в Vue физически), не regulatory rule. Связано: PSR_v1 v1.7 → v2.0 (R15 удалено целиком: R15.1 framer-motion + R15.2 motion-v 4 условия + R15.3 default стойка + R15.4 проверка + R15.5 hard-запрет дублирования + R15.6 live-override + R15.7 gsap/anime/lottie; R0.6 п.11 удалён; R8 motion тай-брейкеры удалены; R11.6 motion иерархия удалена; R13 motion-сценарии удалены), Pravila v1.10 → v1.11 (§11.5/§13.2 счётчик 16→15 правил; §13.9/§13.10 cross-refs на PSR_v1 v1.6→v2.0; §13.10 НЕ удалено — оно про R14, не R15), Tooling v1.15 → v1.16 (§9.2 reformulated в technical guidance), CHANGELOG_claude_md.md + MEMORY sync. Через `superpowers:brainstorming` → 3 варианта → выбор B (полная отмена R15) → `superpowers:writing-plans``superpowers:executing-plans` + `/claude-md-management:claude-md-improver` + ручные Edit (PSR_v1/Tooling/Pravila). v1.87→v1.88. **NB version-number collision:** на ветке plan5 также присутствует другая v1.88 entry (audit-driven schema-sync) — обе валидны, обе 12.05.2026, обе явно labelled.
- **v1.87 от 11.05.2026** — sync schema-метрик после Plan 4 (Billing+CSV+Admin). Schema **v8.11 → v8.19** (накопленный drift от Plans 1+2+3+4): §0 «Источник истины» row «Схема БД», §2 «Стек» строка БД, §6 «Текущая фаза», §8 self-review триггеры — все обновлены до 62 базовых таблиц / 12 партиций / 117 индексов / 39 RLS / 5 функций / 13 триггеров / 5 ролей БД. §6 расширен Plan 4 closure summary: 15 коммитов на ветке `plan4-billing` (14 task-коммитов `a907fea..174dbae` + lychee CV-fix `fded2ee`), Pest 687/684 passed + 3 skipped/0 failed (2090 assertions), Vitest 49 files / 428 passed, Histoire 24 stories / 31 variants, lychee 0 broken, gitleaks 0 leaks. Активированы 7-ступенчатый pricing-tier биллинг + CsvReconcileJob hourly + auto-pause flow + 3 UI экрана. +7 новых Биз-25..31 в реестре (раздел 13 Открытые_вопросы v1.78). Drive-by closure: Plan 1 deferred WARNING #7 (SupplierProjectFactory random race) — fixed в Task 10 `0f820c4`. Через `/claude-md-management:revise-claude-md`.
- **v1.86 от 10.05.2026 (поздний вечер)** — закрытие 13 находок третьего аудита правил использования плагинов и скилов (4 P0 + 5 P1 + 2 P2 + 2 sync-правки в README/README_АРХИВ). Заказчик: «проведи аудит правил использования плагинов и скилов на предмет конфликта и запутаностей» → Claude через `/claude-md-management:claude-md-improver` нашёл 12 формальных находок + 4 sync-побочки, представил quality report, получил «исправь все, только при выполнении руководствуйся правилом, прежде чем вносить изменения тебе надо проанализировать как оно влияет на другие правила, что исправляю одно не делать других ошибок», применил с cross-impact-анализом перед каждой группой. **P0 (4 — реальные арифметические конфликты в CLAUDE.md, прошли мимо второго аудита):** §3 header «Карта 28 инструментов» → «33» (header застрял с pre-FD эпохи); §3.4 header «(+5, итого 28)» → «итого 29» (после добавления #30 в фазу 2 cumulative должна быть 29); §3.3 footer «из 30 номеров минус #1 = 29 active» → расширенная формулировка «33 номеров: 29 phase-active + 3 off-phase + 1 historic»; §6 «Активно: 19 инструментов из 29» + «(19/29 активны)» → «24» в обоих местах (внутренний арифметический конфликт: тут же раскладка 9+8+7=24, но числовая метка застряла на 19 с эпохи когда фаза 2 имела ~4 активных). **P1 (5 — обновление stale `+`-refs на актуальные версии):** PSR_v1 шапка cross-refs «CLAUDE.md v1.84+/Pravila v1.9+» → «v1.86+/v1.10+»; Tooling шапка cross-refs «Pravila v1.9+/PSR_v1 v1.5+/CLAUDE.md v1.84+» → «v1.10+/v1.7+/v1.86+»; CLAUDE.md §5 п.5 «PSR_v1 v1.5+» → «v1.7+». **P2 (2 — внутренние несогласованности формулировок):** PSR_v1 line 4 «slot уровня 2.5» → «уровня 2b» (описка внутри changelog'а v1.6, фактическое R0.1 line 33 всегда содержало «2b»); CLAUDE.md §3.3 #33 «вне Pravila §13» → «вне UI-пула §13» (Pravila §13.2 v1.10 включает claude-md-management как infrastructure subsection; «вне §13» вводило в заблуждение). **Побочки sync:** README.md и README_АРХИВ_v8_5.md «карта 28 инструментов» → «33 инструмента»; Tooling §11.5/§12 «не входят в 28» → «33 формализованные позиции». Связано: **PSR_v1 v1.6→v1.7**, **Tooling v1.14→v1.15**. Pravila v1.10 — без изменений. Через `/claude-md-management:claude-md-improver`.
-1
View File
@@ -1,5 +1,4 @@
*.log
.backups/
.DS_Store
.env
.env.backup
@@ -5,160 +5,48 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\BulkProjectActionRequest;
use App\Http\Requests\StoreProjectRequest;
use App\Http\Requests\UpdateProjectRequest;
use App\Http\Resources\ProjectResource;
use App\Models\Project;
use App\Services\Project\ProjectService;
use App\Models\Tenant;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Проекты tenant'а расширенный API для ProjectsView + NewDealDialog.
* Проекты tenant'а — для NewDealDialog dropdown'а и DealsView/Smart-filters.
*
* index: фильтры по signal_type/status/search, пагинация, batch-fetch по ids.
* show: детальная карточка проекта с supplier_links.
*
* Auth: auth:sanctum + tenant middleware (устанавливает app.current_tenant_id для RLS).
* Task 2 Plan 5 заменяет MVP-версию (tenant_id параметром, без auth).
* На MVP: tenant_id параметром. На prod: middleware('auth:sanctum')+'tenant'.
*/
class ProjectController extends Controller
{
public function __construct(private readonly ProjectService $projects) {}
/** GET /api/projects */
/** GET /api/projects?tenant_id={id} */
public function index(Request $request): JsonResponse
{
$query = Project::query()
->with(['supplierB1', 'supplierB2', 'supplierB3']) // eager-load to avoid N+1 in aggregation helpers
->where('tenant_id', $request->user()->tenant_id);
// Batch-fetch по ids — возвращает без пагинации (для dropdown'ов и т.п.)
if ($ids = $request->query('ids')) {
// '?ids=' batch fetch. Non-numeric and zero values silently dropped via intval+filter
// (intval('abc')=0 → array_filter drops 0). Acceptable for a read-only dropdown:
// invalid input produces empty result, not 422.
$idArray = array_filter(array_map('intval', explode(',', (string) $ids)));
$items = $query->whereIn('id', $idArray)->get();
return response()->json(['data' => ProjectResource::collection($items)]);
$tenantId = (int) $request->query('tenant_id', '0');
if ($tenantId < 1) {
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
}
// Фильтр по типу сигнала
if ($type = $request->query('signal_type')) {
$query->where('signal_type', $type);
$tenant = Tenant::find($tenantId);
if ($tenant === null) {
return response()->json(['message' => 'Тенант не найден.'], 404);
}
// Фильтр по статусу жизненного цикла
$status = $request->query('status');
if ($status === 'archived') {
$query->archived();
} elseif ($status === 'active') {
$query->active()->where('is_active', true);
} elseif ($status === 'paused') {
$query->active()->where('is_active', false);
} else {
// По умолчанию: все не архивированные (active + paused)
$query->active();
}
$projects = DB::transaction(function () use ($tenantId) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// Поиск по name и signal_identifier
if ($search = $request->query('search')) {
$query->where(function ($q) use ($search) {
$q->where('name', 'ilike', "%{$search}%")
->orWhere('signal_identifier', 'ilike', "%{$search}%");
});
}
$perPage = min((int) $request->query('per_page', '20'), 100);
$projects = $query->orderBy('created_at', 'desc')->paginate($perPage);
return Project::query()
->where('is_active', true)
->orderBy('name')
->get(['id', 'name', 'tag', 'type']);
});
return response()->json([
'data' => ProjectResource::collection($projects->items()),
'meta' => [
'current_page' => $projects->currentPage(),
'per_page' => $projects->perPage(),
'total' => $projects->total(),
],
'projects' => $projects->map(fn (Project $p) => [
'id' => $p->id,
'name' => $p->name,
'tag' => $p->tag,
'type' => $p->type,
]),
]);
}
/** POST /api/projects */
public function store(StoreProjectRequest $request): JsonResponse
{
$project = $this->projects->create($request->user()->tenant, $request->validated());
return response()->json(['data' => new ProjectResource($project)], 201);
}
/** PATCH /api/projects/{id} */
public function update(UpdateProjectRequest $request, int $id): JsonResponse
{
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
$updated = $this->projects->update($project, $request->validated());
return response()->json(['data' => new ProjectResource($updated)]);
}
/** GET /api/projects/{id} */
public function show(Request $request, int $id): JsonResponse
{
$project = Project::with(['supplierB1', 'supplierB2', 'supplierB3']) // eager-load to avoid N+1
->where('tenant_id', $request->user()->tenant_id)
->findOrFail($id);
return response()->json(['data' => new ProjectResource($project)]);
}
/** DELETE /api/projects/{id} — soft-archive (sets archived_at, is_active=false) */
public function destroy(Request $request, int $id): JsonResponse
{
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
$this->projects->archive($project);
return response()->json(null, 204);
}
/** POST /api/projects/{id}/sync — re-dispatch SyncSupplierProjectJob */
public function sync(Request $request, int $id): JsonResponse
{
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
$this->projects->triggerSync($project);
return response()->json(['queued' => true, 'sync_status' => 'pending'], 202);
}
/** PATCH /api/projects/{id}/toggle-active — flip is_active flag */
public function toggleActive(Request $request, int $id): JsonResponse
{
$request->validate(['is_active' => ['required', 'boolean']]);
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
$project->update(['is_active' => $request->boolean('is_active')]);
return response()->json(['data' => new ProjectResource($project->fresh())]);
}
/** POST /api/projects/bulk — batch pause/resume/archive/update_regions/update_days/update_limit */
public function bulk(BulkProjectActionRequest $request): JsonResponse
{
$tenantId = $request->user()->tenant_id;
$ids = $this->projects->resolveBulkScope(
$tenantId,
$request->validated('ids'),
$request->validated('scope.filter'),
);
if (count($ids) > ProjectService::BULK_MAX) {
return response()->json([
'errors' => ['scope' => ['Слишком много проектов под фильтр (>500). Уточните фильтры или выберите вручную.']],
], 422);
}
$payload = array_merge($request->validated(), ['ids' => $ids]);
$result = $this->projects->bulkAction($tenantId, $request->validated('action'), $payload);
return response()->json($result);
}
}
@@ -1,70 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class BulkProjectActionRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
$action = $this->input('action');
$rules = [
'action' => ['required', Rule::in([
'pause', 'resume', 'archive',
'update_regions', 'update_days', 'update_limit',
])],
'ids' => ['nullable', 'array', 'max:500'],
'ids.*' => ['integer', 'min:1'],
'scope' => ['nullable', 'array'],
'scope.filter' => ['nullable', 'array'],
'scope.filter.signal_type' => ['nullable', 'string', Rule::in(['site', 'call', 'sms'])],
'scope.filter.status' => ['nullable', 'string', Rule::in(['active', 'paused', 'archived'])],
'scope.filter.search' => ['nullable', 'string', 'max:255'],
];
if ($action === 'update_regions' || $action === 'update_days') {
$maxMask = $action === 'update_regions' ? 255 : 127;
$rules['add'] = ['nullable', 'integer', 'min:0', "max:{$maxMask}"];
$rules['remove'] = ['nullable', 'integer', 'min:0', "max:{$maxMask}"];
}
if ($action === 'update_limit') {
$rules['delta'] = ['nullable', 'integer'];
$rules['replace'] = ['nullable', 'integer', 'min:0'];
}
return $rules;
}
public function withValidator($validator): void
{
$validator->after(function ($v) {
$hasIds = ! empty($this->input('ids'));
$hasScope = $this->has('scope.filter') && is_array($this->input('scope.filter'));
if (! $hasIds && ! $hasScope) {
$v->errors()->add('ids', 'Either ids or scope.filter is required.');
}
if ($this->input('action') === 'update_limit') {
$hasDelta = $this->has('delta');
$hasReplace = $this->has('replace');
if ($hasDelta && $hasReplace) {
$v->errors()->add('delta', 'Cannot use both delta and replace.');
}
if (! $hasDelta && ! $hasReplace) {
$v->errors()->add('delta', 'Either delta or replace is required for update_limit.');
}
}
});
}
}
@@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreProjectRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
$signalType = $this->input('signal_type');
$base = [
'name' => ['required', 'string', 'max:255'],
'signal_type' => ['required', Rule::in(['site', 'call', 'sms'])],
'daily_limit_target' => ['required', 'integer', 'min:1', 'max:10000'],
'region_mask' => ['required', 'integer', 'min:0'],
'region_mode' => ['required', Rule::in(['include', 'exclude'])],
'delivery_days_mask' => ['required', 'integer', 'min:1', 'max:127'],
];
if ($signalType === 'site') {
$base['signal_identifier'] = ['required', 'string', 'regex:/^[a-z0-9][a-z0-9\-]*(\.[a-z0-9][a-z0-9\-]*)*\.[a-z]{2,}$/i'];
} elseif ($signalType === 'call') {
$base['signal_identifier'] = ['required', 'string', 'regex:/^7\d{10}$/'];
} elseif ($signalType === 'sms') {
$base['sms_senders'] = ['required', 'array', 'min:1'];
$base['sms_senders.*'] = ['string', 'max:11'];
$base['sms_keyword'] = ['nullable', 'string', 'min:1', 'max:50'];
}
return $base;
}
}
@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateProjectRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
// signal_type immutable: не валидируется в правилах, controller игнорирует поле
return [
'name' => ['sometimes', 'string', 'max:255'],
'daily_limit_target' => ['sometimes', 'integer', 'min:1', 'max:10000'],
'region_mask' => ['sometimes', 'integer', 'min:0'],
'region_mode' => ['sometimes', Rule::in(['include', 'exclude'])],
'delivery_days_mask' => ['sometimes', 'integer', 'min:1', 'max:127'],
'sms_senders' => ['sometimes', 'array', 'min:1'],
'sms_senders.*' => ['string', 'max:11'],
'sms_keyword' => ['sometimes', 'nullable', 'string', 'min:1', 'max:50'],
];
}
}
@@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Project;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Project */
class ProjectResource extends JsonResource
{
public function toArray(Request $request): array
{
/** @var Project $project */
$project = $this->resource;
return [
'id' => $this->id,
'name' => $this->name,
'signal_type' => $this->signal_type,
'signal_identifier' => $this->signal_identifier,
'sms_senders' => $this->sms_senders,
'sms_keyword' => $this->sms_keyword,
'daily_limit_target' => $this->daily_limit_target,
'effective_daily_limit_today' => $this->effective_daily_limit_today,
'delivered_today' => $this->delivered_today,
'delivered_in_month' => $this->delivered_in_month,
'is_active' => $this->is_active,
'archived_at' => $project->archived_at?->toIso8601String(),
'region_mask' => $this->region_mask,
'region_mode' => $this->region_mode,
'delivery_days_mask' => $this->delivery_days_mask,
'sync_status' => $this->aggregateSyncStatus(),
'last_synced_at' => $this->aggregateLastSyncedAt(),
'supplier_links' => $this->when(
$request->routeIs('projects.show'),
fn () => $this->getSupplierLinks(),
),
];
}
}
-105
View File
@@ -1,105 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Project;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
/**
* Синхронизирует Лидерра-проект с supplier_projects на B1/B2/B3
* в зависимости от signal_type.
*
* Семантика:
* site / call B1 + B2 + B3
* sms с keyword B2 + B3
* sms без keyword B3
*
* Записывает полученные supplier_projects.id в projects.supplier_b{1,2,3}_project_id.
*
* Retry: 3 попытки с backoff [15s, 60s, 300s].
*
* Spec: docs/superpowers/plans/2026-05-11-plan5-frontend-projects-ui-plan.md Task 4
*/
class SyncSupplierProjectJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
/** @var array<int, int> */
public array $backoff = [15, 60, 300];
public function __construct(public int $projectId) {}
public function handle(SupplierPortalClient $client): void
{
$project = Project::find($this->projectId);
if ($project === null) {
Log::warning("SyncSupplierProjectJob: project {$this->projectId} not found — skipping");
return;
}
$platforms = $this->resolvePlatforms($project);
foreach ($platforms as $platform) {
$uniqueKey = $this->buildUniqueKey($project, $platform);
$supplierProjectId = $client->ensureSupplierProject($platform, $project->signal_type, $uniqueKey);
$column = 'supplier_'.strtolower($platform).'_project_id';
$project->{$column} = $supplierProjectId;
}
$project->save();
}
/**
* Возвращает список uppercase platform-кодов для данного project.
* Коды соответствуют CHECK constraint: 'B1' / 'B2' / 'B3'.
*
* @return array<int, string>
*/
private function resolvePlatforms(Project $project): array
{
if (in_array($project->signal_type, ['site', 'call'], true)) {
return ['B1', 'B2', 'B3'];
}
if ($project->signal_type === 'sms') {
return $project->sms_keyword ? ['B2', 'B3'] : ['B3'];
}
return [];
}
/**
* Строит unique_key для пары (project, platform):
* site/call signal_identifier (домен / телефон)
* sms B2 sender + '+' + keyword
* sms B3 sender
*/
private function buildUniqueKey(Project $project, string $platform): string
{
if (in_array($project->signal_type, ['site', 'call'], true)) {
return (string) $project->signal_identifier;
}
// sms
$sender = (string) ($project->sms_senders[0] ?? '');
if ($platform === 'B2') {
return $sender.'+'.($project->sms_keyword ?? '');
}
// B3
return $sender;
}
}
-115
View File
@@ -4,13 +4,11 @@ declare(strict_types=1);
namespace App\Models;
use Carbon\CarbonInterface;
use Database\Factories\ProjectFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Collection;
/**
* Проект (лид-канал) внутри тенанта.
@@ -38,8 +36,6 @@ class Project extends Model
'tag',
'type',
'is_active',
// Plan 5 Task 1 (schema v8.20): soft archive flow — lifecycle-state рядом с is_active.
'archived_at',
'daily_limit_target',
'effective_daily_limit_today',
'effective_limit_calculated_at',
@@ -78,8 +74,6 @@ class Project extends Model
'sms_senders' => 'array',
'delivered_in_month' => 'integer',
'delivered_today' => 'integer',
// Plan 5 Task 1 (schema v8.20): soft archive.
'archived_at' => 'datetime',
];
}
@@ -132,113 +126,4 @@ class Project extends Model
{
return $query->where('signal_type', $signalType)->where('signal_identifier', $identifier);
}
/**
* Не архивированные проекты (archived_at IS NULL).
*
* Внимание: scope не фильтрует is_active. Приостановленные (is_active=false)
* проекты сюда попадают это разные lifecycle-состояния. Если нужны только
* «работающие» (не архив И не на паузе) комбинируйте:
* ->active()->where('is_active', true).
*
* @param Builder<Project> $query
* @return Builder<Project>
*/
public function scopeActive(Builder $query): Builder
{
return $query->whereNull('archived_at');
}
/**
* Архивированные проекты (archived_at IS NOT NULL).
*
* @param Builder<Project> $query
* @return Builder<Project>
*/
public function scopeArchived(Builder $query): Builder
{
return $query->whereNotNull('archived_at');
}
/**
* Все связанные SupplierProject из eager-loaded BelongsTo отношений.
*
* Используется внутри aggregateSyncStatus(), aggregateLastSyncedAt(),
* getSupplierLinks() устраняет N+1 (каждый из трёх методов вызывал
* SupplierProject::find() независимо; теперь читает из уже загруженных
* $this->supplierB1 / supplierB2 / supplierB3).
*
* Требует eager-load: Project::with(['supplierB1', 'supplierB2', 'supplierB3']).
*
* @return Collection<int, SupplierProject>
*/
private function resolvedSupplierProjects(): Collection
{
return collect([$this->supplierB1, $this->supplierB2, $this->supplierB3])->filter()->values();
}
/**
* Агрегированный статус синхронизации по всем связанным SupplierProject.
*
* Логика: если нет ни одного pending; если есть failed failed;
* если есть pending pending; иначе ok.
*
* Читает из eager-loaded отношений (см. resolvedSupplierProjects()).
*/
public function aggregateSyncStatus(): string
{
$statuses = $this->resolvedSupplierProjects()->pluck('sync_status');
if ($statuses->isEmpty()) {
return 'pending';
}
if ($statuses->contains('failed')) {
return 'failed';
}
if ($statuses->contains('pending')) {
return 'pending';
}
return 'ok';
}
/**
* Минимальная дата последней синхронизации по всем связанным SupplierProject.
*
* Использует sortBy по timestamp вместо Collection::min() на Carbon-объектах
* (min() сравнивает строковое представление, что ненадёжно для Carbon).
*
* Читает из eager-loaded отношений (см. resolvedSupplierProjects()).
*/
public function aggregateLastSyncedAt(): ?string
{
$ts = $this->resolvedSupplierProjects()
->pluck('last_synced_at')
->filter()
->sortBy(fn (CarbonInterface $c) => $c->timestamp)
->first();
return $ts?->toIso8601String();
}
/**
* Массив ссылок на связанные SupplierProject (для show endpoint).
*
* Читает из eager-loaded отношений (см. resolvedSupplierProjects()).
*
* @return array<int, array{platform: string, supplier_project_id: int, sync_status: string|null, last_synced_at: string|null}>
*/
public function getSupplierLinks(): array
{
return collect(['b1' => $this->supplierB1, 'b2' => $this->supplierB2, 'b3' => $this->supplierB3])
->filter()
->map(fn (SupplierProject $sp, string $platform) => [
'platform' => $platform,
'supplier_project_id' => $sp->id,
'sync_status' => $sp->sync_status,
'last_synced_at' => $sp->last_synced_at?->toIso8601String(),
])
->values()
->all();
}
}
-3
View File
@@ -44,7 +44,6 @@ class Tenant extends Model
'desired_daily_numbers',
'delivered_in_month',
'api_key_limit',
'limits',
];
protected function casts(): array
@@ -58,8 +57,6 @@ class Tenant extends Model
'desired_daily_numbers' => 'integer',
'delivered_in_month' => 'integer',
'api_key_limit' => 'integer',
// JSONB: {"max_users":5,"max_projects":10,"api_rps":60}
'limits' => 'array',
'webhook_token_rotated_at' => 'datetime',
'last_activity_at' => 'datetime',
'last_webhook_at' => 'datetime',
-200
View File
@@ -1,200 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Project;
use App\Jobs\SyncSupplierProjectJob;
use App\Models\Project;
use App\Models\Tenant;
use Illuminate\Http\Exceptions\HttpResponseException;
class ProjectService
{
public function update(Project $project, array $data): Project
{
// Immutable fields — silently drop (don't 422)
unset(
$data['tenant_id'], $data['signal_type'], $data['signal_identifier'],
$data['delivered_today'], $data['delivered_in_month'],
$data['supplier_b1_project_id'], $data['supplier_b2_project_id'], $data['supplier_b3_project_id'],
$data['archived_at'],
);
if (isset($data['daily_limit_target']) && $data['daily_limit_target'] < $project->delivered_today) {
throw new HttpResponseException(response()->json([
'errors' => [
'daily_limit_target' => [
"Лимит не может быть меньше уже доставленных лидов сегодня ({$project->delivered_today}).",
],
],
], 422));
}
$needsResync = array_key_exists('sms_senders', $data) || array_key_exists('sms_keyword', $data);
$project->update($data);
if ($needsResync) {
SyncSupplierProjectJob::dispatch($project->id);
}
return $project->fresh();
}
public function archive(Project $project): void
{
if ($project->archived_at !== null) {
throw new HttpResponseException(response()->json([
'message' => 'Project уже архивирован.',
], 409));
}
$project->update([
'is_active' => false,
'archived_at' => now(),
]);
}
public function triggerSync(Project $project): void
{
SyncSupplierProjectJob::dispatch($project->id);
}
public const BULK_MAX = 500;
public function resolveBulkScope(int $tenantId, ?array $ids, ?array $filter): array
{
if (! empty($ids)) {
return array_values(array_unique($ids));
}
$query = Project::where('tenant_id', $tenantId);
if (! empty($filter['signal_type'])) {
$query->where('signal_type', $filter['signal_type']);
}
if (! empty($filter['status'])) {
match ($filter['status']) {
'active' => $query->where('is_active', true)->whereNull('archived_at'),
'paused' => $query->where('is_active', false)->whereNull('archived_at'),
'archived' => $query->whereNotNull('archived_at'),
default => null,
};
}
if (! empty($filter['search'])) {
$query->where('name', 'ilike', '%'.$filter['search'].'%');
}
return $query->pluck('id')->all();
}
public function bulkAction(int $tenantId, string $action, array $payload): array
{
$ids = $payload['ids'] ?? [];
if (empty($ids)) {
return ['updated' => 0, 'skipped' => [], 'warnings' => []];
}
$query = Project::where('tenant_id', $tenantId)->whereIn('id', $ids);
return match ($action) {
'pause' => $this->bulkSimpleUpdate($query, ['is_active' => false]),
'resume' => $this->bulkSimpleUpdate($query, ['is_active' => true]),
'archive' => $this->bulkSimpleUpdate($query, ['is_active' => false, 'archived_at' => now()]),
'update_regions' => $this->bulkUpdateRegions($query, $payload),
'update_days' => $this->bulkUpdateDays($query, $payload),
'update_limit' => $this->bulkUpdateLimit($query, $payload),
};
}
private function bulkSimpleUpdate($query, array $update): array
{
$updated = $query->update($update);
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
}
private function bulkUpdateRegions($query, array $payload): array
{
$add = (int) ($payload['add'] ?? 0);
$remove = (int) ($payload['remove'] ?? 0);
// region_mask = (region_mask | add) & ~remove, clamped to 8 bits (0255)
$updated = $query->update([
'region_mask' => \DB::raw("(region_mask | {$add}) & ~{$remove} & 255"),
]);
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
}
private function bulkUpdateDays($query, array $payload): array
{
$add = (int) ($payload['add'] ?? 0);
$remove = (int) ($payload['remove'] ?? 0);
$updated = $query->update([
'delivery_days_mask' => \DB::raw("(delivery_days_mask | {$add}) & ~{$remove} & 127"),
]);
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
}
private function bulkUpdateLimit($query, array $payload): array
{
$delta = $payload['delta'] ?? null;
$replace = $payload['replace'] ?? null;
$projects = (clone $query)->select(['id', 'daily_limit_target', 'delivered_today'])->get();
$updatableIds = [];
$skipped = [];
foreach ($projects as $p) {
$newValue = $replace !== null
? (int) $replace
: (int) $p->daily_limit_target + (int) $delta;
if ($newValue < (int) $p->delivered_today) {
$skipped[] = ['id' => $p->id, 'reason' => 'below_delivered_today'];
} else {
$updatableIds[$p->id] = $newValue;
}
}
$updated = 0;
if (! empty($updatableIds)) {
if ($replace !== null) {
$updated = Project::whereIn('id', array_keys($updatableIds))
->update(['daily_limit_target' => (int) $replace]);
} else {
// delta — обновляем по одному (count bounded by MAX 500).
foreach ($updatableIds as $id => $newValue) {
Project::where('id', $id)->update(['daily_limit_target' => $newValue]);
$updated++;
}
}
}
return ['updated' => $updated, 'skipped' => $skipped, 'warnings' => []];
}
public function create(Tenant $tenant, array $data): Project
{
$limit = (int) ($tenant->limits['max_projects'] ?? 10);
$current = Project::where('tenant_id', $tenant->id)->active()->count();
if ($current >= $limit) {
throw new HttpResponseException(response()->json([
'message' => "Достигнут лимит проектов ({$limit}). Смените тариф.",
], 403));
}
$data['tenant_id'] = $tenant->id;
$data['is_active'] = true;
$project = Project::create($data);
SyncSupplierProjectJob::dispatch($project->id);
return $project->fresh();
}
}
@@ -8,7 +8,6 @@ use App\Exceptions\Supplier\SupplierAuthException;
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Models\SupplierProject;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Carbon\CarbonInterface;
use Illuminate\Http\Client\ConnectionException;
@@ -30,66 +29,12 @@ use Illuminate\Support\Facades\Cache;
* Авторизация: PHPSESSID cookie + X-CSRF-Token header (Redis cache 'supplier:session').
* На 401/403 single retry через dispatch_sync(RefreshSupplierSessionJob).
*/
class SupplierPortalClient
final class SupplierPortalClient
{
public function __construct(
private readonly HttpFactory $http,
) {}
/**
* Идемпотентно обеспечивает наличие supplier_project-записи для переданной
* тройки (platform, signalType, uniqueKey). Если запись уже существует
* возвращает её id. Иначе создаёт проект на стороне поставщика через
* saveProject() и сохраняет новую запись supplier_projects.
*
* Используется SyncSupplierProjectJob (Plan 5 Task 4).
*
* В тестах метод мокируется через $this->mock(SupplierPortalClient::class)
* реальное тело не вызывается.
*
* @param string $platform B1 / B2 / B3
* @param string $signalType site / call / sms
* @param string $uniqueKey domain / phone / sender+keyword / sender
*/
public function ensureSupplierProject(string $platform, string $signalType, string $uniqueKey): int
{
$existing = SupplierProject::query()
->where('platform', $platform)
->where('signal_type', $signalType)
->where('unique_key', $uniqueKey)
->first();
if ($existing !== null) {
return $existing->id;
}
$dto = new SupplierProjectDto(
platform: $platform,
signalType: $signalType,
uniqueKey: $uniqueKey,
limit: 0,
workdays: [1, 2, 3, 4, 5, 6, 7],
regions: [],
regionsReverse: false,
status: 'active',
);
$externalId = $this->saveProject($dto);
$sp = SupplierProject::query()->create([
'platform' => $platform,
'signal_type' => $signalType,
'unique_key' => $uniqueKey,
'supplier_external_id' => (string) $externalId,
'current_limit' => 0,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
'current_regions' => null,
'sync_status' => 'ok',
]);
return $sp->id;
}
/**
* @return array<int, mixed>
*/
@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Guard: schema.sql v8.20+ already contains this column; skip if present
// (prevents "duplicate column" error after `migrate:fresh` which loads schema.sql first).
if (Schema::hasColumn('projects', 'archived_at')) {
return;
}
Schema::table('projects', function (Blueprint $table) {
$table->timestampTz('archived_at')->nullable();
});
}
public function down(): void
{
// Внимание: down() не симметричен up()'у. Если schema.sql v8.20 уже добавил
// archived_at (через migrate:fresh → load_initial_schema), rollback этой
// миграции удалит колонку, что создаст drift с schema.sql. На проекте rollback
// применяется только после migrate:fresh, поэтому это приемлемо — но не
// используйте миграцию как способ отката v8.19 (нужна отдельная schema-bump).
Schema::table('projects', function (Blueprint $table) {
$table->dropColumn('archived_at');
});
}
};
@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Plan 5 Task 3: добавить limits JSONB в tenants.
*
* Используется ProjectService::create() для проверки лимита max_projects.
* Default '{}' (int)($tenant->limits['max_projects'] ?? 10) = 10 из сервиса.
*/
return new class extends Migration
{
public function up(): void
{
if (Schema::hasColumn('tenants', 'limits')) {
return;
}
Schema::table('tenants', function (Blueprint $table) {
// limits JSONB: {"max_users":5,"max_projects":10,"api_rps":60}
// Аналог limits в tariff_plans — per-tenant override лимитов тарифа.
$table->jsonb('limits')->default('{}')->after('api_key_limit');
});
}
public function down(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->dropColumn('limits');
});
}
};
-197
View File
@@ -1,197 +0,0 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
class DemoSeeder extends Seeder
{
public function run(): void
{
$tenant = Tenant::query()->where('subdomain', 'demo')->first()
?? Tenant::factory()->create([
'subdomain' => 'demo',
'organization_name' => 'Demo Tenant',
'contact_email' => 'admin@demo.local',
'status' => 'active',
'balance_rub' => '1000.00',
'balance_leads' => 100,
'is_trial' => false,
]);
$admin = User::query()->updateOrCreate(
['email' => 'admin@demo.local'],
[
'tenant_id' => $tenant->id,
'password_hash' => Hash::make('password'),
'first_name' => 'Demo',
'last_name' => 'Admin',
'timezone' => 'Europe/Moscow',
'is_active' => true,
'totp_enabled' => false,
'sound_enabled' => true,
'email_verified_at' => now(),
'notification_preferences' => [
'new_lead' => ['inapp' => true, 'push' => true, 'email' => false],
'reminder' => ['inapp' => true, 'push' => true, 'email' => true],
'low_balance' => ['email' => true],
'zero_balance' => ['email' => true],
'topup_success' => ['email' => true],
'invoice_paid' => ['email' => true],
'new_device_login' => ['email' => true],
'marketing' => ['email' => false],
],
]
);
$this->seedProjects($tenant->id);
$this->seedDeals($tenant->id, $admin->id);
$this->command->info("Demo tenant id={$tenant->id} subdomain=demo");
$this->command->info('Login: admin@demo.local / password');
}
private function seedProjects(int $tenantId): void
{
$now = now();
$projects = [
[
'tag' => 'site',
'name' => 'Окна СПб (сайт)',
'type' => 'webhook',
'signal_type' => 'site',
'signal_identifier' => 'okna-konkurent.ru',
'sms_senders' => null,
'sms_keyword' => null,
'daily_limit_target' => 50,
],
[
'tag' => 'call',
'name' => 'Натяжные потолки (звонок)',
'type' => 'webhook',
'signal_type' => 'call',
'signal_identifier' => '79161112233',
'sms_senders' => null,
'sms_keyword' => null,
'daily_limit_target' => 30,
],
[
'tag' => 'sms',
'name' => 'Доставка еды (СМС)',
'type' => 'webhook',
'signal_type' => 'sms',
'signal_identifier' => null,
'sms_senders' => json_encode(['EDA-PROMO', 'YAEDA']),
'sms_keyword' => 'скидка',
'daily_limit_target' => 20,
],
];
foreach ($projects as $p) {
DB::table('projects')->updateOrInsert(
['tenant_id' => $tenantId, 'name' => $p['name']],
[
'tenant_id' => $tenantId,
'name' => $p['name'],
'tag' => $p['tag'],
'type' => $p['type'],
'signal_type' => $p['signal_type'],
'signal_identifier' => $p['signal_identifier'],
'sms_senders' => $p['sms_senders'],
'sms_keyword' => $p['sms_keyword'],
'is_active' => true,
'daily_limit_target' => $p['daily_limit_target'],
'delivered_today' => 0,
'delivered_in_month' => 0,
'region_mask' => 0,
'region_mode' => 'include',
'delivery_days_mask' => 127,
'assignment_strategy' => 'manual',
'ttfr_target_minutes' => 60,
'created_at' => $now,
'updated_at' => $now,
]
);
}
}
private function seedDeals(int $tenantId, int $managerId): void
{
$statuses = DB::table('lead_statuses')->orderBy('sort_order')->get();
$projects = DB::table('projects')
->where('tenant_id', $tenantId)
->orderBy('id')
->get()
->keyBy('signal_type');
$samplePool = [
'site' => [
['name' => 'Иван Петров', 'phone' => '+79161234501', 'utm' => ['source' => 'yandex', 'medium' => 'cpc', 'campaign' => 'okna-spb']],
['name' => 'Анна Смирнова', 'phone' => '+79161234502', 'utm' => ['source' => 'google', 'medium' => 'organic', 'campaign' => null]],
],
'call' => [
['name' => 'Сергей Иванов', 'phone' => '+79161234503', 'utm' => ['source' => 'call', 'medium' => 'direct', 'campaign' => null]],
['name' => 'Мария Кузнецова', 'phone' => '+79161234504', 'utm' => ['source' => 'call', 'medium' => 'direct', 'campaign' => null]],
],
'sms' => [
['name' => 'Дмитрий Соколов', 'phone' => '+79161234505', 'utm' => ['source' => 'sms', 'medium' => 'promo', 'campaign' => 'eda-skidka']],
['name' => 'Елена Морозова', 'phone' => '+79161234506', 'utm' => ['source' => 'sms', 'medium' => 'promo', 'campaign' => 'eda-skidka']],
],
];
$now = now();
$signalCycle = ['site', 'call', 'sms'];
$i = 0;
foreach ($statuses as $status) {
$signal = $signalCycle[$i % 3];
$sample = $samplePool[$signal][$i % 2];
$project = $projects[$signal];
$existing = DB::table('deals')
->where('tenant_id', $tenantId)
->where('phone', $sample['phone'])
->where('status', $status->slug)
->first();
if ($existing) {
$i++;
continue;
}
DB::table('deals')->insert([
'tenant_id' => $tenantId,
'project_id' => $project->id,
'phone' => $sample['phone'],
'phones' => json_encode([$sample['phone']]),
'status' => $status->slug,
'contact_name' => $sample['name'],
'comment' => "Демо-сделка статуса «{$status->name_ru}» ({$signal})",
'manager_id' => $managerId,
'assigned_at' => $now,
'escalated_count' => 0,
'utm_source' => $sample['utm']['source'],
'utm_medium' => $sample['utm']['medium'],
'utm_campaign' => $sample['utm']['campaign'],
'region_code' => $i % 2 === 0 ? '77' : '78',
'city' => $i % 2 === 0 ? 'Москва' : 'Санкт-Петербург',
'time_in_form_seconds' => 30 + $i * 5,
'lead_score' => number_format(50.0 + $i * 3, 2, '.', ''),
'is_test' => false,
'received_at' => $now->copy()->subMinutes($i * 7),
'created_at' => $now,
'updated_at' => $now,
]);
$i++;
}
}
}
-25271
View File
File diff suppressed because it is too large Load Diff
-50
View File
@@ -1,50 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Dev Element Indices Manifest",
"type": "object",
"required": ["version", "lastId", "entries", "deleted"],
"properties": {
"$schema": { "type": "string" },
"version": { "const": 1 },
"lastId": { "type": "integer", "minimum": 0 },
"entries": {
"type": "object",
"patternProperties": {
"^[0-9]+$": {
"type": "object",
"required": ["file", "line", "tag", "parentChain", "signature", "createdAt"],
"properties": {
"file": { "type": "string" },
"line": { "type": "integer", "minimum": 1 },
"tag": { "type": "string" },
"parentChain": { "type": "array", "items": { "type": "string" } },
"signature": { "type": "string" },
"text": { "type": ["string", "null"] },
"key": { "type": ["string", "null"] },
"ref": { "type": ["string", "null"] },
"createdAt": { "type": "string", "format": "date-time" }
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"deleted": {
"type": "object",
"patternProperties": {
"^[0-9]+$": {
"type": "object",
"required": ["lastSignature", "lastFile", "deletedAt"],
"properties": {
"lastSignature": { "type": "string" },
"lastFile": { "type": "string" },
"deletedAt": { "type": "string", "format": "date-time" }
},
"additionalProperties": false
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
-12
View File
@@ -4,9 +4,6 @@
"requires": true,
"packages": {
"": {
"dependencies": {
"lucide-vue-next": "^1.0.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@histoire/plugin-vue": "^1.0.0-beta.1",
@@ -6970,15 +6967,6 @@
"node": "20 || >=22"
}
},
"node_modules/lucide-vue-next": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-1.0.0.tgz",
"integrity": "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==",
"license": "ISC",
"peerDependencies": {
"vue": ">=3.0.1"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
-4
View File
@@ -11,7 +11,6 @@
"format:check": "prettier --check \"resources/js/**/*.{ts,vue,css}\" \"tests/Frontend/**/*.ts\"",
"type-check": "vue-tsc --noEmit",
"test:vue": "vitest run",
"dx": "node scripts/dev-indices-lookup.mjs",
"story": "histoire dev",
"story:build": "histoire build",
"story:preview": "histoire preview"
@@ -46,8 +45,5 @@
"vue-tsc": "^3.2.8",
"vuedraggable": "^4.1.0",
"vuetify": "^3.12.5"
},
"dependencies": {
"lucide-vue-next": "^1.0.0"
}
}
+2 -62
View File
@@ -78,12 +78,6 @@ parameters:
count: 1
path: app/Http/Middleware/SetTenantContext.php
-
message: '#^Access to an undefined property App\\Models\\Project\:\:\$archived_at\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/ProjectResource.php
-
message: '#^Using nullsafe property access "\?\-\>name" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
@@ -102,18 +96,6 @@ parameters:
count: 1
path: app/Services/NotificationService.php
-
message: '#^Access to an undefined property App\\Models\\Project\:\:\$archived_at\.$#'
identifier: property.notFound
count: 1
path: app/Services/Project/ProjectService.php
-
message: '#^Match expression does not handle remaining value\: string$#'
identifier: match.unhandled
count: 1
path: app/Services/Project/ProjectService.php
-
message: '#^Return type \(array\<string, mixed\>\) of method Database\\Factories\\ProjectFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\Project, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\Project\>\:\:definition\(\)$#'
identifier: method.childReturnType
@@ -246,12 +228,6 @@ parameters:
count: 13
path: tests/Feature/AdminTenantsIndexTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 14
path: tests/Feature/Api/ProjectBulkActionsTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
@@ -813,13 +789,13 @@ parameters:
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 16
count: 20
path: tests/Feature/LookupsTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 4
count: 5
path: tests/Feature/LookupsTest.php
-
@@ -876,42 +852,6 @@ parameters:
count: 2
path: tests/Feature/PartitionsCreateMonthsTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:mock\(\)\.$#'
identifier: method.notFound
count: 6
path: tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php
-
message: '#^Access to an undefined property App\\Models\\Project\:\:\$archived_at\.$#'
identifier: property.notFound
count: 2
path: tests/Feature/Plan5/Projects/ProjectsActionsTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 9
path: tests/Feature/Plan5/Projects/ProjectsActionsTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 12
path: tests/Feature/Plan5/Projects/ProjectsListShowTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 9
path: tests/Feature/Plan5/Projects/ProjectsStoreTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 6
path: tests/Feature/Plan5/Projects/ProjectsUpdateTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
-11
View File
@@ -23,14 +23,3 @@ body {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum' 1;
}
/*
* A11y override: Vuetify .v-messages helper-text + .v-field-label opacity
* (~0.52 default) рендерится #7a7a7a/#767471 contrast 4.20-4.29 fails
* WCAG 2.1 AA 4.5:1. Q.DEFER.002 fix (12.05.2026 audit): локально bump до 0.7
* rendered #595959 7.9:1+.
*/
.v-messages,
.v-field-label {
--v-medium-emphasis-opacity: 0.7;
}
-132
View File
@@ -1,132 +0,0 @@
/* app/resources/css/motion.css
* Liderra motion-инфраструктура. 7 паттернов + reduced-motion wrapper.
* Spec: §9.
*/
/* === keyframes === */
@keyframes ld-fadeup {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: none; }
}
@keyframes ld-slideup {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
@keyframes ld-shimmer {
0% { background-position: -200px 0; }
100% { background-position: 200px 0; }
}
@keyframes ld-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.6); opacity: 0.4; }
}
@keyframes ld-dialog-in {
0% { opacity: 0; transform: scale(0.94) translateY(8px); }
100% { opacity: 1; transform: scale(1) translateY(0); }
}
/* === Utilities === */
/* motion #4 — Hover lift */
.ld-hover-lift {
transition:
transform 200ms cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
.ld-hover-lift:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-2);
}
/* motion #2 — Stagger list (применяется к строкам таблиц/списков; mount-only) */
.ld-stagger-row {
animation: ld-slideup 400ms cubic-bezier(0.16, 1, 0.3, 1) backwards;
}
.ld-stagger-row:nth-child(1) { animation-delay: 0ms; }
.ld-stagger-row:nth-child(2) { animation-delay: 50ms; }
.ld-stagger-row:nth-child(3) { animation-delay: 100ms; }
.ld-stagger-row:nth-child(4) { animation-delay: 150ms; }
.ld-stagger-row:nth-child(5) { animation-delay: 200ms; }
.ld-stagger-row:nth-child(6) { animation-delay: 250ms; }
.ld-stagger-row:nth-child(7) { animation-delay: 300ms; }
.ld-stagger-row:nth-child(8) { animation-delay: 350ms; }
.ld-stagger-row:nth-child(9) { animation-delay: 400ms; }
.ld-stagger-row:nth-child(10) { animation-delay: 450ms; }
/* motion #5 — Skeleton shimmer */
.ld-skeleton {
background: linear-gradient(
90deg,
rgba(1, 32, 25, 0.06) 0%,
rgba(1, 32, 25, 0.12) 50%,
rgba(1, 32, 25, 0.06) 100%
);
background-size: 400px 100%;
animation: ld-shimmer 1400ms infinite linear;
border-radius: var(--radius-6);
}
/* motion #10 (auxiliary) — Live pulse */
.ld-pulse {
position: relative;
display: inline-block;
width: 8px;
height: 8px;
border-radius: var(--radius-full);
background: var(--liderra-teal);
}
.ld-pulse::after {
content: '';
position: absolute;
inset: 0;
border-radius: var(--radius-full);
background: var(--liderra-teal);
animation: ld-pulse 1800ms infinite cubic-bezier(0.4, 0, 0.6, 1);
}
/* motion #6 — Page transition (View Transitions API + CSS fallback) */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 280ms;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}
::view-transition-old(root) {
animation-name: ld-fadeout-up;
}
::view-transition-new(root) {
animation-name: ld-fadeup;
}
@keyframes ld-fadeout-up {
from { opacity: 1; transform: none; }
to { opacity: 0; transform: translateY(-4px); }
}
/* CSS fallback для router transition */
.ld-route-fadeup-enter-active,
.ld-route-fadeup-leave-active {
transition: opacity 280ms cubic-bezier(0.16, 1, 0.3, 1),
transform 280ms cubic-bezier(0.16, 1, 0.3, 1);
}
.ld-route-fadeup-enter-from { opacity: 0; transform: translateY(4px); }
.ld-route-fadeup-leave-to { opacity: 0; transform: translateY(-4px); }
/* === Reduced motion — отключаем всё === */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
-47
View File
@@ -1,47 +0,0 @@
/* app/resources/css/tokens.css
* Liderra Forest design tokens (Iteration 1 Quiet Luxury).
* Spec: docs/superpowers/specs/2026-05-12-portal-redesign-quiet-luxury-design.md
*/
:root {
/* ===== Палитра (12 токенов) ===== */
--liderra-teal: #0F6E56;
--liderra-teal-deep: #0A5A47;
--liderra-noir: #012019;
--liderra-ivory: #F6F3EC;
--liderra-surface: #FFFFFF;
--liderra-muted: #6B6356;
--liderra-success: #2E8B57;
--liderra-saffron: #D9A441;
--liderra-error: #B83A3A;
--liderra-info: #3F7C95;
--liderra-plum: #7A5BA3;
--liderra-salmon: #CC6E50;
/* ===== Тонкие поверхности ===== */
--liderra-line: rgba(1, 32, 25, 0.08);
--liderra-line-strong: rgba(1, 32, 25, 0.14);
/* ===== Spacing (4pt grid) ===== */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--space-8: 32px;
--space-12: 48px;
--space-16: 64px;
/* ===== Радиусы ===== */
--radius-6: 6px;
--radius-8: 8px;
--radius-10: 10px;
--radius-12: 12px;
--radius-14: 14px;
--radius-full: 999px;
/* ===== Shadows (ambient + key, двухслойные) ===== */
--shadow-1: 0 1px 2px rgba(1, 32, 25, 0.04);
--shadow-2: 0 4px 12px rgba(1, 32, 25, 0.06), 0 1px 2px rgba(1, 32, 25, 0.04);
--shadow-3: 0 12px 28px rgba(1, 32, 25, 0.10);
--shadow-4: 0 24px 48px rgba(1, 32, 25, 0.16);
}
-84
View File
@@ -1,84 +0,0 @@
/* app/resources/css/typography.css
* Liderra typography Inter (UI) + JetBrains Mono (numerics) с tnum.
*/
@import url('https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,300..700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap');
html,
body {
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
font-feature-settings: 'tnum' 1, 'cv11' 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.ld-mono {
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
font-feature-settings: 'tnum' 1;
letter-spacing: -0.01em;
}
/* Шкала (см. spec §4) */
.ld-label {
font-size: 11px;
line-height: 14px;
font-weight: 500;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--liderra-muted);
}
.ld-body {
font-size: 13px;
line-height: 20px;
font-weight: 400;
}
.ld-body-strong {
font-size: 15px;
line-height: 22px;
font-weight: 500;
}
.ld-h3 {
font-size: 17px;
line-height: 24px;
font-weight: 600;
letter-spacing: -0.01em;
}
.ld-h2 {
font-size: 22px;
line-height: 28px;
font-weight: 600;
letter-spacing: -0.015em;
}
.ld-h1 {
font-size: 28px;
line-height: 36px;
font-weight: 600;
letter-spacing: -0.02em;
}
.ld-hero {
font-size: clamp(30px, 5vw, 48px);
font-weight: 600;
letter-spacing: -0.025em;
line-height: 1.1;
}
.ld-mono-xl {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 28px;
font-weight: 500;
letter-spacing: -0.02em;
font-feature-settings: 'tnum' 1;
}
.ld-mono-s {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 11px;
font-weight: 400;
font-feature-settings: 'tnum' 1;
}
-3
View File
@@ -2,9 +2,6 @@ import { createPinia } from 'pinia';
import { createApp } from 'vue';
import AppShell from './components/AppShell.vue';
import { vuetify } from './plugins/vuetify';
import '../css/tokens.css';
import '../css/typography.css';
import '../css/motion.css';
import { router } from './router';
// Точка входа Vue 3 + Vuetify 3 + Vue Router 4 + Pinia (фаза 2, CLAUDE.md §3.3).
+1 -7
View File
@@ -9,7 +9,7 @@
* Источник дизайна: liderra_v8_handoff/concepts/v8_login.html (auth),
* v8_dashboard.html (app), v8_errors.html (error).
*/
import { computed, defineAsyncComponent, type Component } from 'vue';
import { computed } from 'vue';
import { RouterView, useRoute } from 'vue-router';
import AdminLayout from '../layouts/AdminLayout.vue';
import AppLayout from '../layouts/AppLayout.vue';
@@ -17,11 +17,6 @@ import AuthLayout from '../layouts/AuthLayout.vue';
const route = useRoute();
const layoutName = computed(() => route.meta.layout ?? 'app');
// Dev-only overlay: tree-shaken from production bundle via import.meta.env.DEV guard.
const DevIndexOverlay: Component | null = import.meta.env.DEV
? defineAsyncComponent(() => import('./DevIndexOverlay.vue'))
: null;
</script>
<template>
@@ -29,5 +24,4 @@ const DevIndexOverlay: Component | null = import.meta.env.DEV
<RouterView v-else-if="layoutName === 'error'" />
<AdminLayout v-else-if="layoutName === 'admin'" />
<AppLayout v-else />
<component :is="DevIndexOverlay" v-if="DevIndexOverlay" />
</template>
@@ -1,61 +0,0 @@
<template>
<div v-if="index" class="dev-index-badge" :class="{ 'is-dialog': dialogMode }">
<span class="dev-index-num">{{ index }}</span>
<span class="dev-index-label">{{ label }}</span>
</div>
</template>
<script setup lang="ts">
/**
* Dev-only визуальный badge: показывает индекс и название текущего экрана/компонента
* для упрощения обратной связи на localhost («элемент 16: бага X»).
*
* Использование:
* - Layout-уровень: `<DevIndexBadge :index="route.meta.devIndex" :label="route.meta.devLabel" />`
* - Inline (диалоги/sub-компоненты): `<DevIndexBadge :index="18" label="NewProjectDialog" :dialog-mode="true" />`
*
* Не отображается если `index` falsy (null/undefined/0/'').
* `dialogMode` переключает position: fixed → absolute для встраивания внутрь карточек.
*/
defineProps<{
index: number | string | null | undefined;
label?: string;
dialogMode?: boolean;
}>();
</script>
<style scoped>
.dev-index-badge {
position: fixed;
top: 64px;
right: 8px;
z-index: 9000;
display: inline-flex;
align-items: center;
gap: 6px;
background: #0f6e56;
color: #fff;
padding: 4px 10px;
border-radius: 4px;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 11px;
font-weight: 600;
pointer-events: none;
opacity: 0.92;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18);
}
.dev-index-badge.is-dialog {
position: absolute;
z-index: 10000;
}
.dev-index-num {
background: rgba(255, 255, 255, 0.22);
padding: 1px 6px;
border-radius: 3px;
min-width: 22px;
text-align: center;
}
.dev-index-label {
letter-spacing: 0.02em;
}
</style>
@@ -1,221 +0,0 @@
<template>
<Teleport to="body">
<div
v-if="currentId !== null && currentTarget"
class="dx-badge"
:class="{ 'dx-badge--copied': justCopied }"
:style="badgePosition"
@click.stop="copyToClipboard"
>
<span class="dx-badge__num">#{{ currentId }}</span>
<span class="dx-badge__meta">{{ tagLabel }} · "{{ textPreview }}"</span>
</div>
</Teleport>
<Teleport to="body">
<div v-if="overlayMode" class="dx-mini-layer">
<div v-for="el in overlayElements" :key="el.id" class="dx-mini" :style="miniStyleFor(el.rect)">
#{{ el.id }}
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue';
import { useDevIndices } from '../composables/useDevIndices';
const {
currentId,
currentTarget,
hoverEnabled,
overlayMode,
setTarget,
reset,
pauseHover,
walkToParent,
walkToChild,
toggleOverlay,
} = useDevIndices();
const cursorX = ref(0);
const cursorY = ref(0);
const justCopied = ref(false);
let mousemoveRAF: number | null = null;
const tagLabel = computed(() => {
const t = currentTarget.value;
if (!t) return '';
return t.tagName.toLowerCase();
});
const textPreview = computed(() => {
const t = currentTarget.value;
if (!t) return '';
const text = (t.textContent ?? '').trim().slice(0, 24);
return text || '—';
});
const badgePosition = computed(() => ({
left: `${cursorX.value + 12}px`,
top: `${cursorY.value + 12}px`,
}));
interface OverlayItem {
id: number;
rect: DOMRect;
}
const overlayElements = ref<OverlayItem[]>([]);
function refreshOverlayElements() {
const nodes = Array.from(document.querySelectorAll<HTMLElement>('[data-dx]'));
overlayElements.value = nodes
.map((el) => {
const idAttr = el.getAttribute('data-dx');
const id = Number(idAttr);
if (!Number.isFinite(id)) return null;
return { id, rect: el.getBoundingClientRect() };
})
.filter((x): x is OverlayItem => x !== null);
}
function miniStyleFor(rect: DOMRect) {
return {
left: `${rect.left}px`,
top: `${rect.top}px`,
};
}
watch(overlayMode, (on) => {
if (on) {
refreshOverlayElements();
window.addEventListener('resize', refreshOverlayElements);
window.addEventListener('scroll', refreshOverlayElements, true);
} else {
overlayElements.value = [];
window.removeEventListener('resize', refreshOverlayElements);
window.removeEventListener('scroll', refreshOverlayElements, true);
}
});
function onMousemove(e: MouseEvent) {
if (!hoverEnabled.value) return;
cursorX.value = e.clientX;
cursorY.value = e.clientY;
if (mousemoveRAF !== null) return;
mousemoveRAF = requestAnimationFrame(() => {
mousemoveRAF = null;
const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
if (!el) {
setTarget(null);
return;
}
const withDx = el.closest('[data-dx]') as HTMLElement | null;
setTarget(withDx);
});
}
function onKeydown(e: KeyboardEvent) {
if (e.altKey && e.shiftKey && (e.key === 'I' || e.key === 'i')) {
e.preventDefault();
toggleOverlay();
return;
}
if (e.altKey && e.key === 'ArrowUp') {
e.preventDefault();
walkToParent();
pauseHover(800);
return;
}
if (e.altKey && e.key === 'ArrowDown') {
e.preventDefault();
walkToChild();
pauseHover(800);
return;
}
if (e.key === 'Escape') {
reset();
pauseHover(2000);
}
}
async function copyToClipboard() {
if (currentId.value === null) return;
try {
await navigator.clipboard.writeText(`#${currentId.value}`);
justCopied.value = true;
setTimeout(() => (justCopied.value = false), 400);
} catch {
// clipboard may be unavailable in some contexts; silent fail OK in dev tool
}
}
onMounted(() => {
document.addEventListener('mousemove', onMousemove);
document.addEventListener('keydown', onKeydown);
});
onBeforeUnmount(() => {
document.removeEventListener('mousemove', onMousemove);
document.removeEventListener('keydown', onKeydown);
if (mousemoveRAF !== null) cancelAnimationFrame(mousemoveRAF);
if (overlayMode.value) {
window.removeEventListener('resize', refreshOverlayElements);
window.removeEventListener('scroll', refreshOverlayElements, true);
}
});
</script>
<style scoped>
.dx-badge {
position: fixed;
z-index: 999999;
pointer-events: auto;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 10px;
background: #0f6e56;
color: #fff;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 11px;
line-height: 1.4;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.24);
user-select: none;
transition: background 120ms ease;
}
.dx-badge--copied {
background: #21a16e;
}
.dx-badge__num {
background: rgba(255, 255, 255, 0.22);
padding: 1px 6px;
border-radius: 3px;
font-weight: 600;
}
.dx-badge__meta {
letter-spacing: 0.02em;
opacity: 0.92;
}
.dx-mini-layer {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 999998;
}
.dx-mini {
position: fixed;
background: #0f6e56;
color: #fff;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 9px;
line-height: 1;
padding: 1px 3px;
border-radius: 2px;
pointer-events: none;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
}
</style>
@@ -57,10 +57,7 @@ const emit = defineEmits<{
</td>
<td class="num text-caption text-medium-emphasis">{{ tx.id }}</td>
<td>{{ tx.description }}</td>
<td
class="text-end num"
:class="{ 'text-error': tx.amount < 0, 'text-success': tx.amount > 0 }"
>
<td class="text-end num" :class="{ 'text-error': tx.amount < 0, 'text-success': tx.amount > 0 }">
{{ formatRub(tx.amount) }}
</td>
</tr>
@@ -26,25 +26,16 @@ function formatRub(v: number): string {
<div>
<h1 class="text-h4 mb-2 page-title">Тенанты</h1>
<div class="page-stats text-body-2 text-medium-emphasis">
<span
><span class="num">{{ stats.total }}</span> всего</span
>
<span><span class="num">{{ stats.total }}</span> всего</span>
<span class="sep">·</span>
<span
><span class="num text-success">{{ stats.active }}</span> активны</span
>
<span><span class="num text-success">{{ stats.active }}</span> активны</span>
<span class="sep">·</span>
<span
><span class="num">{{ stats.trial }}</span> trial</span
>
<span><span class="num">{{ stats.trial }}</span> trial</span>
<span class="sep">·</span>
<span
><span class="num text-warning">{{ stats.overdue }}</span> просрочка</span
>
<span><span class="num text-warning">{{ stats.overdue }}</span> просрочка</span>
<span class="sep">·</span>
<span
>выручка месяц <span class="num text-primary">{{ formatRub(stats.monthlyRevenueRub) }}</span></span
>
<span>выручка месяц
<span class="num text-primary">{{ formatRub(stats.monthlyRevenueRub) }}</span></span>
</div>
</div>
<div class="d-flex ga-2">
@@ -86,7 +86,6 @@ function statusColor(s: TenantStatus): string {
variant="text"
size="small"
density="comfortable"
:aria-label="`Войти как клиент (impersonation) для ${item.name}`"
:disabled="item.status === 'suspended'"
:data-testid="`impersonate-btn-${item.id}`"
@click.stop="emit('impersonate', item)"
@@ -4,8 +4,17 @@
* (Все / Пополнения / Списания / Возвраты). Sprint 4 Phase B/2 — split BillingView.
*/
import { computed, ref } from 'vue';
import { BILLING_TABS, MOCK_TRANSACTIONS, type BillingTransaction } from '../../composables/mockBilling';
import { formatCost, statusChipColor, statusLabel, txAmountClass } from '../../composables/billingFormatters';
import {
BILLING_TABS,
MOCK_TRANSACTIONS,
type BillingTransaction,
} from '../../composables/mockBilling';
import {
formatCost,
statusChipColor,
statusLabel,
txAmountClass,
} from '../../composables/billingFormatters';
const activeTab = ref<(typeof BILLING_TABS)[number]['id']>('all');
@@ -93,7 +102,7 @@ const filteredTransactions = computed<BillingTransaction[]>(() => {
color: #66635c;
}
.tx-amount-up {
color: #1b6e3b;
color: #2e8b57;
}
.tx-amount-down {
color: #b83a3a;
@@ -29,7 +29,10 @@ defineProps<{
<span class="ru">&nbsp;</span>
</div>
<div class="runway mt-3">
<div class="runway-bar" role="img" :aria-label="`Хватит на ${balance.runwayDays} дня из ${balance.runwayMax}`">
<div
class="runway-bar"
:aria-label="`Хватит на ${balance.runwayDays} дня из ${balance.runwayMax}`"
>
<span
v-for="i in balance.runwayMax"
:key="i"
@@ -1,15 +1,10 @@
<script setup lang="ts">
/**
* DashboardKpiRow — 3 KPI-карты (получено лидов / конверсия / активные проекты).
* Numerics через JetBrains Mono с tabular-nums + count-up анимация (motion #1).
* Numerics через JetBrains Mono с tabular-nums.
*
* Sprint 4 Phase B/3 — split DashboardView (audit O-refactor-04 закрытие).
* Task 14 (Quiet Luxury) — добавлены ld-kpi__value/ld-kpi__label классы и
* count-up через useCountUp композабл. Respects prefers-reduced-motion.
*/
import { onMounted, ref, watch, type Ref } from 'vue';
import { useCountUp } from '../../composables/useCountUp';
export interface Kpi {
label: string;
value: string;
@@ -18,85 +13,17 @@ export interface Kpi {
sub: string;
}
const props = defineProps<{
defineProps<{
kpis: Kpi[];
}>();
/**
* Парсит KPI value-строку в число. Поддерживает:
* - целые ('247', '8')
* - дробные ('18.4')
* - с пробелами как тысячными ('14 250')
*/
function parseNumeric(raw: string): { value: number; precision: number } {
const cleaned = raw.replace(/\s+/g, '').replace(',', '.');
const value = parseFloat(cleaned);
if (Number.isNaN(value)) return { value: 0, precision: 0 };
const dotIdx = cleaned.indexOf('.');
const precision = dotIdx === -1 ? 0 : cleaned.length - dotIdx - 1;
return { value, precision };
}
/**
* Форматирует число обратно с пробелами как тысячными
* (чтобы '14 250' выводилось так же, а не '14250').
*/
function formatNumber(value: number, precision: number): string {
const fixed = precision === 0 ? Math.round(value).toString() : value.toFixed(precision);
const [intPart, decPart] = fixed.split('.');
const withSpaces = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
return decPart === undefined ? withSpaces : `${withSpaces}.${decPart}`;
}
interface AnimationSlot {
target: Ref<number>;
display: Ref<number>;
start: () => void;
precision: number;
}
const slots: AnimationSlot[] = [];
function rebuildSlots(): void {
slots.length = 0;
for (const kpi of props.kpis) {
const { value, precision } = parseNumeric(kpi.value);
const target = ref(value);
const { display, start } = useCountUp(target, { duration: 600, precision });
slots.push({ target, display, start, precision });
}
}
rebuildSlots();
// Если props.kpis сменился (новый range / refetch) — пересобираем слоты
// и перезапускаем анимацию.
watch(
() => props.kpis,
() => {
rebuildSlots();
slots.forEach((s) => s.start());
},
{ deep: true },
);
onMounted(() => {
slots.forEach((s) => s.start());
});
function displayFor(idx: number): string {
const slot = slots[idx];
if (!slot) return '';
return formatNumber(slot.display.value, slot.precision);
}
</script>
<template>
<v-col v-for="(kpi, idx) in kpis" :key="kpi.label" cols="12" sm="6" md="3">
<v-col v-for="kpi in kpis" :key="kpi.label" cols="12" sm="6" md="3">
<v-card variant="outlined" class="kpi-card pa-4">
<div class="kpi-label ld-kpi__label ld-label text-body-2 text-medium-emphasis">{{ kpi.label }}</div>
<div class="kpi-value ld-kpi__value ld-mono">
{{ displayFor(idx) }}
<div class="kpi-label text-body-2 text-medium-emphasis">{{ kpi.label }}</div>
<div class="kpi-value">
{{ kpi.value }}
<span v-if="kpi.unit" class="kpi-unit">{{ kpi.unit }}</span>
</div>
<div class="kpi-foot text-caption text-medium-emphasis mt-2">
@@ -162,7 +89,7 @@ function displayFor(idx: number): string {
font-weight: 500;
}
.delta-up {
color: #1b6e3b;
color: #2e8b57;
}
.delta-down {
color: #b83a3a;
@@ -29,7 +29,13 @@ function formatRelative(minutes: number): string {
<div class="hero-eyebrow text-caption text-medium-emphasis">Сделка #{{ deal.id }}</div>
<div class="hero-row mt-1">
<h2 class="hero-name text-h5">{{ deal.name }}</h2>
<v-btn icon="mdi-close" variant="text" size="small" aria-label="Закрыть панель" @click="$emit('close')" />
<v-btn
icon="mdi-close"
variant="text"
size="small"
aria-label="Закрыть панель"
@click="$emit('close')"
/>
</div>
<div class="hero-meta mt-2">
<a :href="`tel:${deal.phone.replace(/[^+\d]/g, '')}`" class="phone-link">{{ deal.phone }}</a>
@@ -41,7 +47,11 @@ function formatRelative(minutes: number): string {
</div>
<div v-if="status" class="status-row mt-3">
<v-chip size="small" variant="tonal" :style="{ color: status.colorHex, borderColor: status.colorHex }">
<v-chip
size="small"
variant="tonal"
:style="{ color: status.colorHex, borderColor: status.colorHex }"
>
<span class="status-dot" :style="{ background: status.colorHex }" />
{{ status.nameRu }}
</v-chip>
@@ -16,18 +16,12 @@
*/
import type { MockDeal } from '../../composables/mockDeals';
import type { LeadStatus } from '../../composables/leadStatuses';
import StatusPill from '../ui/StatusPill.vue';
withDefaults(
defineProps<{
deals: MockDeal[];
selectedIds: number[];
statusBySlug: Map<string, LeadStatus>;
// Task 15: row height from density toggle (44 comfortable / 36 compact).
rowHeight?: number;
}>(),
{ rowHeight: 44 },
);
defineProps<{
deals: MockDeal[];
selectedIds: number[];
statusBySlug: Map<string, LeadStatus>;
}>();
const emit = defineEmits<{
'update:selectedIds': [value: number[]];
@@ -67,8 +61,7 @@ function formatCost(cost: number): string {
items-per-page="-1"
hide-default-footer
hover
:density="rowHeight && rowHeight < 40 ? 'compact' : 'comfortable'"
:row-props="() => ({ class: 'ld-hover-lift ld-stagger-row', style: { height: rowHeight + 'px' } })"
density="comfortable"
@update:model-value="onSelectedUpdate"
@click:row="(_e: Event, { item }: { item: MockDeal }) => emit('row-click', item)"
>
@@ -92,18 +85,23 @@ function formatCost(cost: number): string {
</v-avatar>
<div>
<div class="deal-name">{{ item.name }}</div>
<div class="deal-phone text-caption text-medium-emphasis ld-mono-s">{{ item.phone }}</div>
<div class="deal-phone text-caption text-medium-emphasis">{{ item.phone }}</div>
</div>
</div>
</template>
<template #[`item.statusSlug`]="{ item }: { item: MockDeal }">
<!-- Task 15: StatusPill заменяет v-chip + ручной dot. Label fallback на slug
если nameRu отсутствует (leadStatuses store ещё не загружен). -->
<StatusPill
:slug="item.statusSlug"
:label="statusBySlug.get(item.statusSlug)?.nameRu ?? item.statusSlug"
/>
<v-chip
size="small"
variant="tonal"
:style="{
color: statusBySlug.get(item.statusSlug)?.colorHex,
borderColor: statusBySlug.get(item.statusSlug)?.colorHex,
}"
>
<span class="status-dot" :style="{ background: statusBySlug.get(item.statusSlug)?.colorHex }" />
{{ statusBySlug.get(item.statusSlug)?.nameRu }}
</v-chip>
</template>
<template #[`item.manager`]="{ item }: { item: MockDeal }">
@@ -116,28 +114,11 @@ function formatCost(cost: number): string {
</template>
<template #[`item.cost`]="{ item }: { item: MockDeal }">
<span class="num ld-mono">{{ formatCost(item.cost) }}</span>
<span class="num">{{ formatCost(item.cost) }}</span>
</template>
<template #[`item.receivedMinutesAgo`]="{ item }: { item: MockDeal }">
<span class="num ld-mono-s text-medium-emphasis">{{ formatRelative(item.receivedMinutesAgo) }}</span>
</template>
<template #[`header.data-table-select`]="{ allSelected, selectAll, someSelected }">
<v-checkbox-btn
:model-value="allSelected"
:indeterminate="someSelected && !allSelected"
aria-label="Выбрать все сделки"
@update:model-value="selectAll"
/>
</template>
<template #[`item.data-table-select`]="{ isSelected, toggleSelect, internalItem, item }">
<v-checkbox-btn
:model-value="isSelected(internalItem)"
:aria-label="`Выбрать сделку «${(item as MockDeal).name}»`"
@update:model-value="(v: boolean | null) => toggleSelect(internalItem)"
/>
<span class="num text-medium-emphasis">{{ formatRelative(item.receivedMinutesAgo) }}</span>
</template>
</v-data-table>
@@ -11,8 +11,7 @@ defineProps<{
<template>
<h1 class="err-code">
{{ code[0] }}<span class="accent">{{ code[1] }}</span
>{{ code[2] }}
{{ code[0] }}<span class="accent">{{ code[1] }}</span>{{ code[2] }}
</h1>
</template>
@@ -53,7 +53,7 @@ function statusColor(s: string): string {
<p v-if="code === '404'" class="err-help text-caption">
Что-то не так? Напишите в
<a href="mailto:support@liderra.app" class="err-help__link">support@liderra.app</a>
<a href="mailto:support@liderra.app" class="text-primary">support@liderra.app</a>
</p>
</template>
@@ -99,11 +99,4 @@ function statusColor(s: string): string {
color: #7a8c87;
margin-top: 16px;
}
.err-help__link {
color: #d3dad8;
text-decoration: underline;
}
.err-help__link:hover {
color: #ffffff;
}
</style>
@@ -18,12 +18,7 @@ function formatCost(cost: number): string {
</script>
<template>
<v-card
variant="outlined"
class="kanban-card ld-hover-lift pa-3 mb-2"
density="compact"
@click="emit('open', deal.id)"
>
<v-card variant="outlined" class="kanban-card pa-3 mb-2" density="compact" @click="emit('open', deal.id)">
<div class="card-name">{{ deal.name }}</div>
<div class="card-phone text-caption text-medium-emphasis">{{ deal.phone }}</div>
<div class="card-meta mt-2">
@@ -54,7 +54,7 @@ function onDraggableChange(event: DraggableChangeEvent) {
<div class="kanban-column">
<header class="column-head" :style="{ '--accent': status.colorHex }">
<div class="column-head-row">
<span class="column-name ld-label">{{ status.nameRu }}</span>
<span class="column-name">{{ status.nameRu }}</span>
<span class="column-count">{{ deals.length }}</span>
</div>
<div class="column-total">{{ formatTotal(total) }}</div>
+88 -151
View File
@@ -1,22 +1,19 @@
<script setup lang="ts">
/**
* Sprint 4 Phase B/1 (audit O-refactor-04 хвост): sidebar выделен из AppLayout.
* Task 12 (Portal Redesign Quiet Luxury): двухтоновый shell + ⌘K stub + group-eyebrows
* + active-marker pseudo-element + JetBrains Mono badges.
*
* Brand mark + nav-tree (3 группы: Работа, Финансы, Команда).
* Counts для «Сделки» — mock.
* Counts для «Напоминания» — живой из remindersStore; «Сделки»/«Менеджеры» — mock.
*/
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import Kbd from '../ui/Kbd.vue';
import { useRemindersStore } from '../../stores/reminders';
interface NavItem {
title: string;
icon: string;
to: string;
countKey?: 'deals' | 'reminders' | 'managers';
count?: number;
countKey?: string;
}
interface NavGroup {
eyebrow: string;
@@ -26,15 +23,23 @@ interface NavGroup {
const drawerOpen = defineModel<boolean>('drawerOpen', { default: true });
const route = useRoute();
const reminders = useRemindersStore();
const navGroups = computed<NavGroup[]>(() => [
{
eyebrow: 'Работа',
items: [
{ title: 'Проекты', icon: 'mdi-folder-multiple-outline', to: '/projects' },
{ title: 'Дашборд', icon: 'mdi-view-dashboard-outline', to: '/dashboard' },
{ title: 'Сделки', icon: 'mdi-format-list-bulleted', to: '/deals', count: 247 },
{ title: 'Канбан', icon: 'mdi-view-column-outline', to: '/kanban' },
{ title: 'Дашборд', icon: 'mdi-view-dashboard-outline', to: '/dashboard' },
{ title: 'Проекты', icon: 'mdi-folder-multiple-outline', to: '/projects' },
{
title: 'Напоминания',
icon: 'mdi-clock-outline',
to: '/reminders',
countKey: 'reminders',
count: reminders.counts.active,
},
],
},
{
@@ -46,174 +51,106 @@ const navGroups = computed<NavGroup[]>(() => [
},
{
eyebrow: 'Команда',
items: [{ title: 'Настройки', icon: 'mdi-cog-outline', to: '/settings' }],
items: [
{ title: 'Менеджеры', icon: 'mdi-account-group-outline', to: '/managers', count: 4 },
{ title: 'Настройки', icon: 'mdi-cog-outline', to: '/settings' },
],
},
]);
function resolveCount(item: NavItem): number {
return item.count ?? 0;
}
defineExpose({ navGroups });
</script>
<template>
<aside class="ld-sidebar" :data-open="drawerOpen">
<div class="ld-sidebar__brand">
<span class="ld-sidebar__brand-name">Лидерра<span class="ld-sidebar__brand-dot">.</span></span>
<v-navigation-drawer v-model="drawerOpen" color="secondary" theme="dark" :width="240" :rail="false" class="app-drawer">
<div class="brand-block">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 48 48" width="22" height="22">
<path
d="M16 14 L16 34 L32 34"
stroke="#012019"
stroke-width="4.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
<circle cx="32" cy="34" r="3.5" fill="#0F6E56" />
</svg>
</span>
<span class="brand-text">Лидерра<span class="brand-dot">.</span></span>
</div>
<div class="ld-cmdk-stub" role="button" tabindex="0">
<span class="ld-cmdk-stub__placeholder">Поиск, команды</span>
<Kbd dark>K</Kbd>
</div>
<nav class="ld-sidebar__nav">
<div v-for="(group, gi) in navGroups" :key="gi" class="ld-nav-group">
<div class="ld-nav-group__eyebrow">{{ group.eyebrow }}</div>
<RouterLink
<v-list nav density="comfortable" class="app-nav">
<template v-for="group in navGroups" :key="group.eyebrow">
<v-list-subheader class="nav-eyebrow">{{ group.eyebrow }}</v-list-subheader>
<v-list-item
v-for="item in group.items"
:key="item.to"
:to="item.to"
class="ld-nav-item"
:class="{ 'ld-nav-item--active': route.path === item.to }"
:prepend-icon="item.icon"
:active="route.path === item.to"
rounded="lg"
class="nav-item"
>
<span class="ld-nav-item__title">{{ item.title }}</span>
<span
v-if="resolveCount(item) > 0"
class="ld-nav-item__badge ld-mono"
:data-testid="item.countKey ? `nav-count-${item.countKey}` : undefined"
>{{ resolveCount(item) }}</span
>
</RouterLink>
</div>
</nav>
</aside>
<v-list-item-title>{{ item.title }}</v-list-item-title>
<template v-if="item.count !== undefined && item.count > 0" #append>
<span
class="nav-count"
:data-testid="item.countKey ? `nav-count-${item.countKey}` : undefined"
>{{ item.count }}</span>
</template>
</v-list-item>
</template>
</v-list>
</v-navigation-drawer>
</template>
<style scoped>
.ld-sidebar {
background: linear-gradient(180deg, var(--liderra-noir) 0%, #04261e 100%);
color: #e8e2d4;
padding: 20px 14px;
width: 232px;
height: 100vh;
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
z-index: 1006;
overflow-y: auto;
.app-drawer {
border-right: 1px solid rgba(255, 255, 255, 0.06);
}
.ld-sidebar__brand {
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
padding: 0 8px;
margin-bottom: 22px;
}
.ld-sidebar__brand-name {
color: var(--liderra-ivory);
}
.ld-sidebar__brand-dot {
color: var(--liderra-teal);
}
.ld-cmdk-stub {
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
padding: 8px 11px;
border-radius: var(--radius-8);
font-size: 12px;
color: #9b9484;
margin-bottom: 18px;
cursor: pointer;
transition: background 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
.ld-cmdk-stub:hover {
background: rgba(255, 255, 255, 0.1);
}
.ld-sidebar__nav {
flex: 1;
}
.ld-nav-group {
margin-bottom: 6px;
}
.ld-nav-group__eyebrow {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.14em;
color: #6b7470;
margin: 14px 8px 4px;
font-weight: 500;
}
.ld-nav-item {
.brand-block {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px;
border-radius: var(--radius-6);
font-size: 13px;
color: #b8b0a0;
text-decoration: none;
position: relative;
transition:
color 200ms cubic-bezier(0.16, 1, 0.3, 1),
background 200ms cubic-bezier(0.16, 1, 0.3, 1);
margin-bottom: 1px;
padding: 18px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.ld-nav-item:hover {
color: #e8e2d4;
background: rgba(255, 255, 255, 0.04);
.brand-mark {
width: 24px;
height: 24px;
border-radius: 5px;
background: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ld-nav-item--active {
color: var(--liderra-ivory);
background: rgba(15, 110, 86, 0.22);
.brand-text {
font-weight: 600;
font-size: 16px;
letter-spacing: -0.01em;
color: #fff;
}
.ld-nav-item--active::before {
content: '';
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
width: 2px;
background: var(--liderra-teal);
border-radius: 2px;
transform-origin: center;
animation: ld-marker-grow 250ms cubic-bezier(0.16, 1, 0.3, 1);
.brand-dot {
color: #32c8a9;
}
@keyframes ld-marker-grow {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
.ld-nav-item__title {
flex: 1;
}
.ld-nav-item__badge {
font-size: 10px;
background: rgba(255, 255, 255, 0.08);
padding: 1px 6px;
border-radius: 4px;
color: #b8b0a0;
.nav-eyebrow {
font-size: 11px !important;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #7a8c87 !important;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum' 1;
padding-top: 16px !important;
}
.ld-nav-item--active .ld-nav-item__badge {
background: rgba(255, 255, 255, 0.1);
color: var(--liderra-ivory);
.nav-count {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
font-size: 11px;
color: #7a8c87;
background: rgba(255, 255, 255, 0.05);
padding: 2px 7px;
border-radius: 10px;
}
</style>
@@ -90,6 +90,8 @@ async function handleLogout(): Promise<void> {
<v-app-bar-nav-icon class="d-md-none" @click="emit('toggle-drawer')" />
<div class="crumb">
<span class="text-medium-emphasis">Рабочая область</span>
<v-icon size="14" class="mx-1">mdi-chevron-right</v-icon>
<strong>{{ pageTitle }}</strong>
</div>
@@ -166,7 +168,13 @@ async function handleLogout(): Promise<void> {
<v-menu offset="8">
<template #activator="{ props }">
<v-btn v-bind="props" variant="text" size="small" class="user-chip ml-2" aria-label="Меню пользователя">
<v-btn
v-bind="props"
variant="text"
size="small"
class="user-chip ml-2"
aria-label="Меню пользователя"
>
<v-avatar size="28" color="primary" class="mr-2">
<span class="text-caption">{{ userInitials }}</span>
</v-avatar>
@@ -185,16 +193,7 @@ async function handleLogout(): Promise<void> {
<style scoped>
.app-topbar {
background: linear-gradient(180deg, var(--liderra-noir) 0%, #04261e 100%) !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
color: #e8e2d4 !important;
}
.app-topbar :deep(.v-toolbar__content) {
padding-left: 240px;
color: #e8e2d4;
}
.app-topbar :deep(.v-icon) {
color: #b8b0a0;
border-bottom: 1px solid #d9d5cd !important;
}
.crumb {
display: flex;
@@ -202,30 +201,20 @@ async function handleLogout(): Promise<void> {
gap: 4px;
font-size: 14px;
margin-left: 8px;
color: #e8e2d4;
}
.crumb strong {
color: var(--liderra-ivory);
font-weight: 600;
}
.searchbar {
text-transform: none;
color: #b8b0a0 !important;
border-color: rgba(255, 255, 255, 0.12) !important;
}
.search-kbd {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 10px;
padding: 1px 5px;
border: 1px solid rgba(255, 255, 255, 0.16);
border: 1px solid #d9d5cd;
border-radius: 3px;
background: rgba(255, 255, 255, 0.06);
color: #9b9484;
background: #f0ede4;
color: #66635c;
margin-left: 6px;
}
.user-chip :deep(.v-btn__content) {
color: #e8e2d4;
}
.notification-pip {
position: absolute;
top: 4px;
@@ -1,23 +0,0 @@
<template>
<Story title="BulkActionsBar">
<Variant title="1 selected">
<BulkActionsBar />
</Variant>
<Variant title="Many selected">
<BulkActionsBar />
</Variant>
</Story>
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
import BulkActionsBar from './BulkActionsBar.vue';
import { useProjectsStore } from '../../stores/projectsStore';
const store = useProjectsStore();
onMounted(() => {
store.selectedIds.add(1);
store.selectedIds.add(2);
store.selectedIds.add(3);
});
</script>
@@ -1,108 +0,0 @@
<template>
<v-card class="bulk-actions-bar" elevation="6">
<DevIndexBadge :index="20" label="BulkActionsBar" :dialog-mode="true" style="top: 4px; right: 4px" />
<v-card-text class="d-flex align-center gap-3 flex-wrap">
<strong>Выбрано: {{ store.selectedIds.size }}</strong>
<v-divider vertical />
<v-btn color="primary" variant="outlined" data-testid="bulk-regions" @click="regionsOpen = true">
🌍 Регионы
</v-btn>
<v-btn color="primary" variant="outlined" data-testid="bulk-days" @click="daysOpen = true">
📅 Дни сбора
</v-btn>
<v-btn color="primary" variant="outlined" data-testid="bulk-limit" @click="limitOpen = true">
🎯 Лимит лидов
</v-btn>
<v-divider vertical />
<v-btn color="warning" prepend-icon="mdi-pause" data-testid="bulk-pause" @click="confirmAndRun('pause')">
Приостановить
</v-btn>
<v-btn color="success" prepend-icon="mdi-play" data-testid="bulk-resume" @click="confirmAndRun('resume')">
Возобновить
</v-btn>
<v-divider vertical />
<v-btn
color="error"
prepend-icon="mdi-archive"
data-testid="bulk-archive"
@click="confirmAndRun('archive')"
>
Архивировать
</v-btn>
<v-spacer />
<v-btn variant="text" data-testid="bulk-clear" @click="store.clearSelection">Снять выбор</v-btn>
</v-card-text>
<RegionsBulkDialog
v-model="regionsOpen"
:count="store.selectedIds.size"
@apply="(p) => runBulk({ action: 'update_regions', ...p })"
/>
<DaysBulkDialog
v-model="daysOpen"
:count="store.selectedIds.size"
@apply="(p) => runBulk({ action: 'update_days', ...p })"
/>
<LimitBulkDialog
v-model="limitOpen"
:count="store.selectedIds.size"
@apply="(p) => runBulk({ action: 'update_limit', ...p })"
/>
</v-card>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useProjectsStore } from '../../stores/projectsStore';
import DevIndexBadge from '../DevIndexBadge.vue';
import RegionsBulkDialog from './RegionsBulkDialog.vue';
import DaysBulkDialog from './DaysBulkDialog.vue';
import LimitBulkDialog from './LimitBulkDialog.vue';
const store = useProjectsStore();
const regionsOpen = ref(false);
const daysOpen = ref(false);
const limitOpen = ref(false);
const messages: Record<string, string> = {
pause: 'Приостановить выбранные проекты?',
resume: 'Возобновить выбранные проекты?',
archive:
'Архивировать выбранные проекты?\nДействие необратимо в Plan 5 (восстановление потребует ручного запроса).',
};
async function confirmAndRun(action: 'pause' | 'resume' | 'archive') {
if (!window.confirm(messages[action])) return;
await runBulk({ action });
}
async function runBulk(payload: Parameters<typeof store.bulkUpdate>[0]) {
const result = await store.bulkUpdate(payload);
if (result.skipped.length > 0) {
window.alert(
`Применено: ${result.updated}. Пропущено: ${result.skipped.length} (конфликт с уже доставленными лидами).`,
);
}
}
defineExpose({ regionsOpen, daysOpen, limitOpen });
</script>
<style scoped>
.bulk-actions-bar {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
max-width: calc(100vw - 48px);
}
</style>
@@ -1,13 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
import DaysBulkDialog from './DaysBulkDialog.vue';
const open = ref(true);
</script>
<template>
<Story title="projects/DaysBulkDialog">
<Variant title="open (5 projects)">
<DaysBulkDialog v-model="open" :count="5" />
</Variant>
</Story>
</template>
@@ -1,93 +0,0 @@
<template>
<v-dialog v-model="open" max-width="560">
<v-card>
<v-card-title>Дни сбора лидов для {{ count }} проектов</v-card-title>
<v-card-text>
<div class="mb-4">
<div class="text-caption text-success font-weight-medium mb-2"> Добавить дни</div>
<div class="d-flex gap-2">
<v-btn
v-for="d in WEEKDAYS"
:key="`add-${d.bit}`"
:data-testid="`day-add-${d.bit}`"
:color="addMask & d.bit ? 'success' : undefined"
:variant="addMask & d.bit ? 'flat' : 'outlined'"
size="small"
@click="toggleAdd(d.bit)"
>{{ d.short }}</v-btn
>
</div>
</div>
<div>
<div class="text-caption text-error font-weight-medium mb-2"> Убрать дни</div>
<div class="d-flex gap-2">
<v-btn
v-for="d in WEEKDAYS"
:key="`remove-${d.bit}`"
:data-testid="`day-remove-${d.bit}`"
:color="removeMask & d.bit ? 'error' : undefined"
:variant="removeMask & d.bit ? 'flat' : 'outlined'"
size="small"
@click="toggleRemove(d.bit)"
>{{ d.short }}</v-btn
>
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn data-testid="cancel" @click="open = false">Отмена</v-btn>
<v-btn color="primary" data-testid="apply" :disabled="addMask === 0 && removeMask === 0" @click="apply"
>Применить к {{ count }}</v-btn
>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { WEEKDAYS } from '../../constants/weekdays';
const props = defineProps<{ modelValue: boolean; count: number }>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
apply: [payload: { add: number; remove: number }];
}>();
const open = ref(props.modelValue);
const addMask = ref(0);
const removeMask = ref(0);
watch(
() => props.modelValue,
(val) => {
open.value = val;
if (val) {
addMask.value = 0;
removeMask.value = 0;
}
},
);
watch(open, (val) => {
emit('update:modelValue', val);
});
function toggleAdd(bit: number) {
addMask.value ^= bit;
if (addMask.value & bit) removeMask.value &= ~bit;
}
function toggleRemove(bit: number) {
removeMask.value ^= bit;
if (removeMask.value & bit) addMask.value &= ~bit;
}
function apply() {
emit('apply', { add: addMask.value, remove: removeMask.value });
addMask.value = 0;
removeMask.value = 0;
open.value = false;
}
</script>
@@ -1,13 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
import LimitBulkDialog from './LimitBulkDialog.vue';
const open = ref(true);
</script>
<template>
<Story title="projects/LimitBulkDialog">
<Variant title="open (5 projects)">
<LimitBulkDialog v-model="open" :count="5" />
</Variant>
</Story>
</template>
@@ -1,107 +0,0 @@
<template>
<v-dialog v-model="open" max-width="480">
<v-card>
<v-card-title>Лимит лидов для {{ count }} проектов</v-card-title>
<v-card-text>
<template v-if="!useReplace">
<v-text-field
v-model.number="addValue"
type="number"
min="0"
label="➕ Прибавить к лимиту"
suffix="лидов/день"
data-testid="add-input"
density="compact"
/>
<v-text-field
v-model.number="removeValue"
type="number"
min="0"
label="➖ Убавить лимит"
suffix="лидов/день"
data-testid="remove-input"
density="compact"
class="mt-2"
/>
</template>
<template v-else>
<v-text-field
v-model.number="replaceValue"
type="number"
min="0"
label="Установить лимит"
suffix="лидов/день"
data-testid="replace-input"
density="compact"
/>
</template>
<v-checkbox
v-model="useReplace"
label="Заменить на абсолютное значение"
data-testid="replace-toggle"
density="compact"
hide-details
class="mt-3"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn data-testid="cancel" @click="open = false">Отмена</v-btn>
<v-btn color="primary" data-testid="apply" :disabled="!canApply" @click="apply"
>Применить к {{ count }}</v-btn
>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
const props = defineProps<{ modelValue: boolean; count: number }>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
apply: [payload: { delta?: number; replace?: number }];
}>();
const open = ref(props.modelValue);
const useReplace = ref(false);
const addValue = ref<number | null>(null);
const removeValue = ref<number | null>(null);
const replaceValue = ref<number | null>(null);
watch(
() => props.modelValue,
(val) => {
open.value = val;
if (val) {
useReplace.value = false;
addValue.value = null;
removeValue.value = null;
replaceValue.value = null;
}
},
);
watch(open, (val) => {
emit('update:modelValue', val);
});
const canApply = computed(() => {
if (useReplace.value) return replaceValue.value !== null && replaceValue.value >= 0;
return (addValue.value ?? 0) > 0 || (removeValue.value ?? 0) > 0;
});
function apply() {
if (useReplace.value && replaceValue.value !== null) {
emit('apply', { replace: replaceValue.value });
} else {
const delta = (addValue.value ?? 0) - (removeValue.value ?? 0);
emit('apply', { delta });
}
addValue.value = null;
removeValue.value = null;
replaceValue.value = null;
open.value = false;
}
</script>
@@ -1,16 +1,14 @@
<template>
<v-card class="project-card ld-hover-lift" :class="{ paused: !project.is_active }" elevation="1">
<v-card class="project-card" :class="{ paused: !project.is_active }" elevation="1">
<v-card-item>
<template #prepend>
<label class="card-check" data-testid="card-select">
<input
type="checkbox"
:checked="selected"
:aria-label="`Выбрать проект «${project.name}»`"
@change="$emit('toggle-select', project.id)"
/>
<span class="card-check__box" />
</label>
<v-checkbox
:model-value="selected"
data-testid="card-select"
hide-details
density="compact"
@change="$emit('toggle-select', project.id)"
/>
</template>
<v-card-title>
@@ -23,13 +21,7 @@
<template #append>
<v-menu>
<template #activator="{ props: menuProps }">
<v-btn
icon="mdi-dots-vertical"
variant="text"
size="small"
:aria-label="`Меню действий проекта «${project.name}»`"
v-bind="menuProps"
/>
<v-btn icon="mdi-dots-vertical" variant="text" size="small" v-bind="menuProps" />
</template>
<v-list density="compact">
<v-list-item @click="$emit('edit', project)">
@@ -37,12 +29,8 @@
<v-list-item-title>Редактировать</v-list-item-title>
</v-list-item>
<v-list-item @click="$emit('toggle-active', project)">
<template #prepend
><v-icon>{{ project.is_active ? 'mdi-pause' : 'mdi-play' }}</v-icon></template
>
<v-list-item-title>{{
project.is_active ? 'Приостановить' : 'Возобновить'
}}</v-list-item-title>
<template #prepend><v-icon>{{ project.is_active ? 'mdi-pause' : 'mdi-play' }}</v-icon></template>
<v-list-item-title>{{ project.is_active ? 'Приостановить' : 'Возобновить' }}</v-list-item-title>
</v-list-item>
<v-list-item @click="$emit('sync-now', project)">
<template #prepend><v-icon>mdi-refresh</v-icon></template>
@@ -60,22 +48,10 @@
<v-card-text>
<div v-if="project.is_active" class="mb-2">
<div class="d-flex justify-space-between">
<span class="text-caption"
><span class="ld-mono">{{ project.delivered_today }}</span> /
<span class="ld-mono">{{ project.daily_limit_target }}</span> лидов</span
>
<span class="text-caption text-medium-emphasis"
><span class="ld-mono">{{ progressPercent }}</span
>%</span
>
<span class="text-caption">{{ project.delivered_today }} / {{ project.daily_limit_target }} лидов</span>
<span class="text-caption text-medium-emphasis">{{ progressPercent }}%</span>
</div>
<v-progress-linear
:model-value="progressPercent"
:color="progressColor"
height="6"
rounded
:aria-label="`Прогресс дневной нормы: ${progressPercent}%`"
/>
<v-progress-linear :model-value="progressPercent" :color="progressColor" height="6" rounded />
</div>
<div v-else class="text-caption text-medium-emphasis mb-2">На паузе</div>
@@ -89,7 +65,20 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { Project } from '../../stores/projectsStore';
interface Project {
id: number;
name: string;
signal_type: 'site' | 'call' | 'sms';
signal_identifier?: string | null;
sms_senders?: string[] | null;
sms_keyword?: string | null;
daily_limit_target: number;
delivered_today: number;
is_active: boolean;
archived_at: string | null;
sync_status: 'ok' | 'pending' | 'failed';
}
const props = defineProps<{ project: Project; selected: boolean }>();
defineEmits<{
@@ -100,28 +89,39 @@ defineEmits<{
archive: [project: Project];
}>();
const typeLabel = computed(() => ({ site: 'Сайт', call: 'Звонок', sms: 'СМС' })[props.project.signal_type]);
const typeLabel = computed(
() => ({ site: 'Сайт', call: 'Звонок', sms: 'СМС' })[props.project.signal_type],
);
const typeColor = computed(
() => ({ site: 'blue-lighten-4', call: 'orange-lighten-4', sms: 'purple-lighten-4' })[props.project.signal_type],
() =>
({ site: 'blue-lighten-4', call: 'orange-lighten-4', sms: 'purple-lighten-4' })[
props.project.signal_type
],
);
const identifierDisplay = computed(() => {
if (props.project.signal_type === 'sms') {
return [(props.project.sms_senders ?? []).join(', '), props.project.sms_keyword].filter(Boolean).join(' · ');
return [(props.project.sms_senders ?? []).join(', '), props.project.sms_keyword]
.filter(Boolean)
.join(' · ');
}
return props.project.signal_identifier ?? '';
});
const progressPercent = computed(() =>
Math.min(100, Math.round((props.project.delivered_today / props.project.daily_limit_target) * 100)),
Math.min(
100,
Math.round((props.project.delivered_today / props.project.daily_limit_target) * 100),
),
);
const progressColor = computed(() => (progressPercent.value >= 90 ? 'success' : 'primary'));
const syncStatusLabel = computed(
() => ({ ok: 'Sync OK', pending: 'Sync pending', failed: 'Sync failed' })[props.project.sync_status],
() => ({ ok: 'Sync OK', pending: 'Sync pending', failed: 'Sync failed' })[
props.project.sync_status
],
);
const syncStatusIcon = computed(
() =>
({ ok: 'mdi-check-circle', pending: 'mdi-clock-outline', failed: 'mdi-alert-circle' })[
props.project.sync_status
],
() => ({ ok: 'mdi-check-circle', pending: 'mdi-clock-outline', failed: 'mdi-alert-circle' })[
props.project.sync_status
],
);
const syncStatusColor = computed(
() => ({ ok: 'success', pending: 'warning', failed: 'error' })[props.project.sync_status],
@@ -132,49 +132,4 @@ const syncStatusColor = computed(
.project-card.paused {
opacity: 0.75;
}
.card-check {
display: inline-flex;
align-items: center;
cursor: pointer;
padding: 4px;
}
.card-check input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.card-check__box {
width: 16px;
height: 16px;
border: 1px solid var(--liderra-line);
border-radius: var(--radius-6);
background: var(--liderra-surface);
display: inline-block;
position: relative;
transition:
border-color 200ms cubic-bezier(0.16, 1, 0.3, 1),
background-color 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
.card-check:hover .card-check__box {
border-color: var(--liderra-line-strong);
}
.card-check input:focus-visible + .card-check__box {
outline: 2px solid var(--liderra-teal);
outline-offset: 2px;
}
.card-check input:checked + .card-check__box {
background: rgba(15, 110, 86, 0.1);
border-color: var(--liderra-teal);
}
.card-check input:checked + .card-check__box::after {
content: '';
position: absolute;
left: 4px;
top: 0;
width: 5px;
height: 9px;
border: solid var(--liderra-teal);
border-width: 0 1.5px 1.5px 0;
transform: rotate(45deg);
}
</style>
@@ -1,13 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
import RegionsBulkDialog from './RegionsBulkDialog.vue';
const open = ref(true);
</script>
<template>
<Story title="projects/RegionsBulkDialog">
<Variant title="open (5 projects)">
<RegionsBulkDialog v-model="open" :count="5" />
</Variant>
</Story>
</template>
@@ -1,93 +0,0 @@
<template>
<v-dialog v-model="open" max-width="560">
<v-card>
<v-card-title>Регионы для {{ count }} проектов</v-card-title>
<v-card-text>
<div class="mb-4">
<div class="text-caption text-success font-weight-medium mb-2"> Добавить</div>
<div class="d-flex flex-wrap gap-2">
<v-chip
v-for="r in FEDERAL_DISTRICTS"
:key="`add-${r.bit}`"
:data-testid="`region-add-${r.bit}`"
:color="addMask & r.bit ? 'success' : undefined"
:variant="addMask & r.bit ? 'flat' : 'outlined'"
size="small"
@click="toggleAdd(r.bit)"
>{{ r.label }}</v-chip
>
</div>
</div>
<div>
<div class="text-caption text-error font-weight-medium mb-2"> Убрать</div>
<div class="d-flex flex-wrap gap-2">
<v-chip
v-for="r in FEDERAL_DISTRICTS"
:key="`remove-${r.bit}`"
:data-testid="`region-remove-${r.bit}`"
:color="removeMask & r.bit ? 'error' : undefined"
:variant="removeMask & r.bit ? 'flat' : 'outlined'"
size="small"
@click="toggleRemove(r.bit)"
>{{ r.label }}</v-chip
>
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn data-testid="cancel" @click="open = false">Отмена</v-btn>
<v-btn color="primary" data-testid="apply" :disabled="addMask === 0 && removeMask === 0" @click="apply"
>Применить к {{ count }}</v-btn
>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { FEDERAL_DISTRICTS } from '../../constants/federal-districts';
const props = defineProps<{ modelValue: boolean; count: number }>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
apply: [payload: { add: number; remove: number }];
}>();
const open = ref(props.modelValue);
const addMask = ref(0);
const removeMask = ref(0);
watch(
() => props.modelValue,
(val) => {
open.value = val;
if (val) {
addMask.value = 0;
removeMask.value = 0;
}
},
);
watch(open, (val) => {
emit('update:modelValue', val);
});
function toggleAdd(bit: number) {
addMask.value ^= bit;
if (addMask.value & bit) removeMask.value &= ~bit;
}
function toggleRemove(bit: number) {
removeMask.value ^= bit;
if (removeMask.value & bit) addMask.value &= ~bit;
}
function apply() {
emit('apply', { add: addMask.value, remove: removeMask.value });
addMask.value = 0;
removeMask.value = 0;
open.value = false;
}
</script>
@@ -134,7 +134,7 @@ function formatAbsolute(iso: string | null): string {
.empty-hint {
font-size: 12px;
margin-top: 6px;
color: #6b6356;
color: #9a9690;
}
.reminder-row {
@@ -33,7 +33,9 @@ const sessions: Session[] = [
эта сессия
</v-chip>
</div>
<div class="session-meta text-caption text-medium-emphasis">{{ s.location }} · {{ s.when }}</div>
<div class="session-meta text-caption text-medium-emphasis">
{{ s.location }} · {{ s.when }}
</div>
</div>
<v-btn v-if="!s.current" variant="text" size="small" color="error"> Завершить </v-btn>
</li>
@@ -99,8 +99,8 @@ async function confirmDisable(): Promise<void> {
безопасном месте.
</template>
<template v-else>
Защитите аккаунт двухфакторной авторизацией. Поддерживаются Google Authenticator, Yandex Key, 1Password
и другие TOTP-приложения.
Защитите аккаунт двухфакторной авторизацией. Поддерживаются Google Authenticator, Yandex Key,
1Password и другие TOTP-приложения.
</template>
</p>
<div class="d-flex ga-2 flex-wrap">
@@ -1,9 +0,0 @@
<script setup lang="ts">
import DensityToggle from './DensityToggle.vue';
</script>
<template>
<Story title="UI/DensityToggle">
<Variant title="Default"><DensityToggle /></Variant>
</Story>
</template>
@@ -1,60 +0,0 @@
<script setup lang="ts">
import { useDensity, type Density } from '../../composables/useDensity';
const { density, setDensity } = useDensity();
const emit = defineEmits<{ change: [Density] }>();
function pick(d: Density): void {
setDensity(d);
emit('change', d);
}
</script>
<template>
<div class="ld-density-toggle" role="group" aria-label="Плотность таблицы">
<button
type="button"
class="ld-density-toggle__btn"
:class="{ 'ld-density-toggle__btn--active': density === 'compact' }"
@click="pick('compact')"
>
Компакт
</button>
<button
type="button"
class="ld-density-toggle__btn"
:class="{ 'ld-density-toggle__btn--active': density === 'comfortable' }"
@click="pick('comfortable')"
>
Комфорт
</button>
</div>
</template>
<style scoped>
.ld-density-toggle {
display: inline-flex;
background: var(--liderra-surface);
border: 1px solid var(--liderra-line);
border-radius: var(--radius-8);
padding: 2px;
}
.ld-density-toggle__btn {
background: transparent;
border: none;
padding: 5px 10px;
border-radius: var(--radius-6);
font-size: 11px;
color: var(--liderra-muted);
cursor: pointer;
font-family: inherit;
transition:
background 200ms cubic-bezier(0.16, 1, 0.3, 1),
color 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
.ld-density-toggle__btn--active {
background: rgba(1, 32, 25, 0.08);
color: var(--liderra-noir);
}
</style>
@@ -1,13 +0,0 @@
<script setup lang="ts">
import FilterChip from './FilterChip.vue';
</script>
<template>
<Story title="UI/FilterChip">
<Variant title="Default">
<FilterChip label="Статус" />
<FilterChip label="Проект" :count="2" />
<FilterChip label="Менеджер" :active="true" :count="3" />
</Variant>
</Story>
</template>
@@ -1,54 +0,0 @@
<script setup lang="ts">
defineProps<{
label: string;
count?: number;
active?: boolean;
}>();
defineEmits<{ click: [] }>();
</script>
<template>
<button type="button" class="ld-filter-chip" :class="{ 'ld-filter-chip--active': active }" @click="$emit('click')">
<span>{{ label }}</span>
<span v-if="count && count > 0" class="ld-filter-chip__count">{{ count }}</span>
<span class="ld-filter-chip__caret"></span>
</button>
</template>
<style scoped>
.ld-filter-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border-radius: var(--radius-8);
background: var(--liderra-surface);
border: 1px solid var(--liderra-line);
font-size: 12px;
color: var(--liderra-noir);
cursor: pointer;
font-family: inherit;
transition: border-color 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
.ld-filter-chip:hover {
border-color: var(--liderra-line-strong);
}
.ld-filter-chip--active {
border-color: var(--liderra-teal);
background: rgba(15, 110, 86, 0.06);
color: var(--liderra-teal);
}
.ld-filter-chip__count {
font-family: 'JetBrains Mono', ui-monospace, monospace;
background: rgba(15, 110, 86, 0.12);
color: var(--liderra-teal);
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
}
.ld-filter-chip__caret {
font-size: 9px;
opacity: 0.6;
}
</style>
@@ -1,12 +0,0 @@
<script setup lang="ts">
import Kbd from './Kbd.vue';
</script>
<template>
<Story title="UI/Kbd">
<Variant title="Light"><Kbd>K</Kbd> <Kbd>Esc</Kbd> <Kbd>/</Kbd></Variant>
<Variant title="Dark (sidebar)">
<div style="background: #012019; padding: 14px; border-radius: 8px"><Kbd dark>K</Kbd></div>
</Variant>
</Story>
</template>
-28
View File
@@ -1,28 +0,0 @@
<script setup lang="ts">
defineProps<{ dark?: boolean }>();
</script>
<template>
<kbd class="ld-kbd" :class="{ 'ld-kbd--dark': dark }">
<slot />
</kbd>
</template>
<style scoped>
.ld-kbd {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
background: rgba(1, 32, 25, 0.06);
color: var(--liderra-muted);
border: 1px solid var(--liderra-line);
line-height: 1.4;
}
.ld-kbd--dark {
background: rgba(255, 255, 255, 0.08);
color: rgba(232, 226, 212, 0.85);
border-color: rgba(255, 255, 255, 0.1);
}
</style>
@@ -1,29 +0,0 @@
<script setup lang="ts">
import StatusPill from './StatusPill.vue';
import { STATUS_PILL_SLUGS } from '../../composables/useStatusPill';
const labelMap: Record<string, string> = {
new: 'Новый',
in_progress: 'В работе',
callback: 'Перезвонить',
quality: 'Качественный',
meeting_set: 'Встреча',
won: 'Продано',
refund: 'Возврат',
duplicate: 'Дубль',
junk: 'Спам',
no_answer: 'Нет ответа',
cancelled: 'Отменено',
closed: 'Закрыто',
postponed: 'Отложено',
archived: 'Архив',
};
</script>
<template>
<Story title="UI/StatusPill" :layout="{ type: 'grid', width: 200 }">
<Variant v-for="slug in STATUS_PILL_SLUGS" :key="slug" :title="slug">
<StatusPill :slug="slug" :label="labelMap[slug]" />
</Variant>
</Story>
</template>
@@ -1,38 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useStatusPill } from '../../composables/useStatusPill';
const props = defineProps<{
slug: string;
label?: string;
}>();
const style = computed<Record<string, string>>(() => {
const s = useStatusPill(props.slug);
const css: Record<string, string> = {
background: s.bg,
color: s.color,
};
if (s.fontWeight) css['font-weight'] = String(s.fontWeight);
if (s.textDecoration) css['text-decoration'] = s.textDecoration;
return css;
});
</script>
<template>
<span class="ld-status-pill" :style="style">{{ label ?? slug }}</span>
</template>
<style scoped>
.ld-status-pill {
display: inline-flex;
align-items: center;
padding: 3px 9px;
border-radius: var(--radius-full);
font-size: 11px;
font-weight: 500;
transition:
background 300ms cubic-bezier(0.16, 1, 0.3, 1),
color 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
</style>
@@ -56,3 +56,4 @@ export interface AdminTenantDetail extends AdminTenant {
avgLeadCost: number;
runwayDays: number; // balance / avgDailySpend
}
@@ -1,67 +0,0 @@
/**
* useCountUp — RAF-tween анимация числа (Quiet Luxury KPI cards).
*
* - easeOutQuint easing
* - respects prefers-reduced-motion (instant value)
* - re-animates when target ref changes
*
* Spec: docs/superpowers/plans/2026-05-12-portal-redesign-quiet-luxury-plan.md (Task 6).
*/
import { ref, watch, type Ref } from 'vue';
export interface CountUpOptions {
duration?: number; // ms
precision?: number; // знаков после запятой
}
export interface CountUpHandle {
display: Ref<number>;
start: () => void;
}
const easeOutQuint = (t: number): number => 1 - Math.pow(1 - t, 5);
function prefersReducedMotion(): boolean {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
export function useCountUp(target: Ref<number>, opts: CountUpOptions = {}): CountUpHandle {
const duration = opts.duration ?? 600;
const precision = opts.precision ?? 0;
const display = ref(0);
let raf: number | null = null;
let startTime = 0;
let fromValue = 0;
function tick(now: number): void {
const elapsed = now - startTime;
const t = Math.min(elapsed / duration, 1);
const eased = easeOutQuint(t);
const value = fromValue + (target.value - fromValue) * eased;
display.value = precision === 0 ? Math.round(value) : parseFloat(value.toFixed(precision));
if (t < 1) {
raf = requestAnimationFrame(tick);
} else {
display.value = target.value;
raf = null;
}
}
function start(): void {
if (prefersReducedMotion()) {
display.value = target.value;
return;
}
if (raf !== null) cancelAnimationFrame(raf);
fromValue = display.value;
startTime = performance.now();
raf = requestAnimationFrame(tick);
}
watch(target, () => {
if (display.value !== target.value) start();
});
return { display, start };
}
@@ -46,7 +46,10 @@ export function triggerCsvDownload(csv: string, filename: string): void {
* BOM нужен чтобы Excel корректно распознавал UTF-8.
*/
export function buildCsvString(headers: string[], rows: (string | number)[][]): string {
const lines = [headers.join(';'), ...rows.map((row) => row.map((v) => csvEscape(String(v))).join(';'))];
const lines = [
headers.join(';'),
...rows.map((row) => row.map((v) => csvEscape(String(v))).join(';')),
];
// String.fromCharCode(0xfeff) вместо литерального BOM — иначе ESLint
// no-irregular-whitespace.
return String.fromCharCode(0xfeff) + lines.join('\r\n');
@@ -1,44 +0,0 @@
import { computed, ref, watch, type ComputedRef, type Ref } from 'vue';
export type Density = 'comfortable' | 'compact';
export const DENSITY_KEY = 'liderra:density';
export interface DensityHandle {
density: Ref<Density>;
rowHeight: ComputedRef<number>;
setDensity: (d: Density) => void;
toggle: () => void;
}
function loadInitial(): Density {
if (typeof localStorage === 'undefined') return 'comfortable';
const raw = localStorage.getItem(DENSITY_KEY);
return raw === 'compact' ? 'compact' : 'comfortable';
}
export function useDensity(): DensityHandle {
const density = ref<Density>(loadInitial());
const rowHeight = computed<number>(() => (density.value === 'compact' ? 36 : 44));
function setDensity(d: Density): void {
density.value = d;
}
function toggle(): void {
density.value = density.value === 'comfortable' ? 'compact' : 'comfortable';
}
watch(
density,
(v) => {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(DENSITY_KEY, v);
}
},
{ flush: 'sync' },
);
return { density, rowHeight, setDensity, toggle };
}
@@ -1,111 +0,0 @@
import { ref, type Ref } from 'vue';
export interface DevIndicesApi {
currentTarget: Ref<HTMLElement | null>;
currentId: Ref<number | null>;
overlayMode: Ref<boolean>;
hoverEnabled: Ref<boolean>;
setTarget(el: HTMLElement | null): void;
toggleOverlay(): void;
walkToParent(): void;
walkToChild(): void;
pauseHover(ms: number): void;
reset(): void;
}
// Module-level singleton state — shared across all consumers
const currentTarget = ref<HTMLElement | null>(null);
const currentId = ref<number | null>(null);
const overlayMode = ref(false);
const hoverEnabled = ref(true);
let pauseTimer: ReturnType<typeof setTimeout> | null = null;
function parseId(el: HTMLElement | null): number | null {
if (!el) return null;
const raw = el.getAttribute('data-dx');
if (raw == null) return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
}
function setTarget(el: HTMLElement | null): void {
if (el == null) {
currentTarget.value = null;
currentId.value = null;
return;
}
const id = parseId(el);
if (id == null) return;
currentTarget.value = el;
currentId.value = id;
}
function toggleOverlay(): void {
overlayMode.value = !overlayMode.value;
}
function findAncestorWithDx(el: HTMLElement | null): HTMLElement | null {
let cur: HTMLElement | null = el?.parentElement ?? null;
while (cur) {
if (cur.hasAttribute('data-dx')) return cur;
cur = cur.parentElement;
}
return null;
}
function findFirstDescendantWithDx(el: HTMLElement | null): HTMLElement | null {
if (!el) return null;
// BFS: find first descendant with data-dx
const queue: HTMLElement[] = Array.from(el.children) as HTMLElement[];
while (queue.length) {
const cur = queue.shift()!;
if (cur.hasAttribute('data-dx')) return cur;
queue.push(...(Array.from(cur.children) as HTMLElement[]));
}
return null;
}
function walkToParent(): void {
const parent = findAncestorWithDx(currentTarget.value);
if (parent) setTarget(parent);
}
function walkToChild(): void {
const child = findFirstDescendantWithDx(currentTarget.value);
if (child) setTarget(child);
}
function pauseHover(ms: number): void {
hoverEnabled.value = false;
if (pauseTimer) clearTimeout(pauseTimer);
pauseTimer = setTimeout(() => {
hoverEnabled.value = true;
pauseTimer = null;
}, ms);
}
function reset(): void {
currentTarget.value = null;
currentId.value = null;
overlayMode.value = false;
hoverEnabled.value = true;
if (pauseTimer) {
clearTimeout(pauseTimer);
pauseTimer = null;
}
}
export function useDevIndices(): DevIndicesApi {
return {
currentTarget,
currentId,
overlayMode,
hoverEnabled,
setTarget,
toggleOverlay,
walkToParent,
walkToChild,
pauseHover,
reset,
};
}
@@ -1,54 +0,0 @@
/**
* Маппинг slug'ов lead_statuses → стилевые токены пилюли.
* Slugs синхронизированы с db/schema.sql:2076 (источник истины).
*
* Spec §8. Используется компонентом StatusPill.vue.
*/
export interface PillStyle {
bg: string;
color: string;
fontWeight?: number;
textDecoration?: 'line-through' | 'none';
}
export const STATUS_PILL_SLUGS = [
'new',
'in_progress',
'callback',
'quality',
'meeting_set',
'won',
'refund',
'duplicate',
'junk',
'no_answer',
'cancelled',
'closed',
'postponed',
'archived',
] as const;
export type StatusPillSlug = (typeof STATUS_PILL_SLUGS)[number];
const STYLES: Record<StatusPillSlug, PillStyle> = {
new: { bg: 'rgba(15,110,86,0.12)', color: '#0F6E56' },
in_progress: { bg: 'rgba(63,124,149,0.12)', color: '#2A5A6E' },
callback: { bg: 'rgba(217,164,65,0.18)', color: '#A07820' },
quality: { bg: 'rgba(46,139,87,0.15)', color: '#2E8B57' },
meeting_set: { bg: 'rgba(122,91,163,0.15)', color: '#7A5BA3' },
won: { bg: 'rgba(46,139,87,0.22)', color: '#1F6940', fontWeight: 600 },
refund: { bg: 'rgba(204,110,80,0.15)', color: '#B0563D' },
duplicate: { bg: 'rgba(1,32,25,0.08)', color: '#3A3A3A' },
junk: { bg: 'rgba(184,58,58,0.10)', color: '#B83A3A' },
no_answer: { bg: 'rgba(107,99,86,0.15)', color: '#6B6356' },
cancelled: { bg: 'rgba(107,99,86,0.18)', color: '#6B6356', textDecoration: 'line-through' },
closed: { bg: 'rgba(1,32,25,0.10)', color: '#3A3A3A' },
postponed: { bg: 'rgba(15,110,86,0.06)', color: '#6B6356' },
archived: { bg: '#012019', color: '#E8E2D4' },
};
const FALLBACK: PillStyle = { bg: 'rgba(1,32,25,0.08)', color: '#3A3A3A' };
export function useStatusPill(slug: string): PillStyle {
return STYLES[slug as StatusPillSlug] ?? FALLBACK;
}
@@ -1,18 +0,0 @@
export interface FederalDistrict {
bit: number; // 1, 2, 4, ..., 128
label: string;
}
// 8 ФО РФ — соответствует schema `projects.region_mask BETWEEN 0 AND 255`.
// Используется в bulk-операциях по проектам (грубое выделение).
// Для тонкого pick'а subject-level см. constants/regions.ts.
export const FEDERAL_DISTRICTS: FederalDistrict[] = [
{ bit: 1, label: 'Центральный' },
{ bit: 2, label: 'Северо-Западный' },
{ bit: 4, label: 'Южный' },
{ bit: 8, label: 'Северо-Кавказский' },
{ bit: 16, label: 'Приволжский' },
{ bit: 32, label: 'Уральский' },
{ bit: 64, label: 'Сибирский' },
{ bit: 128, label: 'Дальневосточный' },
];
-16
View File
@@ -1,16 +0,0 @@
export interface Weekday {
bit: number; // 1, 2, 4, ..., 64
label: string;
short: string;
}
// Соответствует schema `projects.delivery_days_mask BETWEEN 0 AND 127` (7 бит).
export const WEEKDAYS: Weekday[] = [
{ bit: 1, label: 'Понедельник', short: 'Пн' },
{ bit: 2, label: 'Вторник', short: 'Вт' },
{ bit: 4, label: 'Среда', short: 'Ср' },
{ bit: 8, label: 'Четверг', short: 'Чт' },
{ bit: 16, label: 'Пятница', short: 'Пт' },
{ bit: 32, label: 'Суббота', short: 'Сб' },
{ bit: 64, label: 'Воскресенье', short: 'Вс' },
];
+11 -4
View File
@@ -1,8 +1,18 @@
import { defineSetupVue3 } from '@histoire/plugin-vue';
import { createPinia } from 'pinia';
import { createMemoryHistory, createRouter } from 'vue-router';
import { vuetify } from './plugins/vuetify';
/**
* Histoire setup — регистрирует Vuetify + Vue Router (memory-history) для каждой story.
*
* - Vuetify: без него VApp/VBtn/VCard не рендерятся (требует createVuetify-инстанс).
* - vue-router: компоненты используют RouterLink/useRoute. В Histoire-iframe
* нет HTML5 history API — используем memory-history с минимальным набором
* stub-маршрутов (story-context, не реальные пути).
*
* vuetify/styles импортируется внутри plugins/vuetify.ts — повторно
* импортировать здесь не нужно (TS 6 strict не видит side-effect d.ts).
*/
export const setupVue3 = defineSetupVue3(({ app }) => {
const router = createRouter({
history: createMemoryHistory(),
@@ -13,11 +23,9 @@ export const setupVue3 = defineSetupVue3(({ app }) => {
{ path: '/forgot', component: { template: '<div />' } },
{ path: '/2fa', component: { template: '<div />' } },
{ path: '/recovery', component: { template: '<div />' } },
{ path: '/recovery-use', component: { template: '<div />' } },
{ path: '/dashboard', component: { template: '<div />' } },
{ path: '/deals', component: { template: '<div />' } },
{ path: '/kanban', component: { template: '<div />' } },
{ path: '/projects', component: { template: '<div />' } },
{ path: '/reminders', component: { template: '<div />' } },
{ path: '/billing', component: { template: '<div />' } },
{ path: '/reports', component: { template: '<div />' } },
@@ -27,5 +35,4 @@ export const setupVue3 = defineSetupVue3(({ app }) => {
});
app.use(vuetify);
app.use(router);
app.use(createPinia());
});
+4 -6
View File
@@ -15,7 +15,6 @@
import { useAuthStore } from '../stores/auth';
import { computed } from 'vue';
import { RouterView, useRoute, useRouter } from 'vue-router';
import DevIndexBadge from '../components/DevIndexBadge.vue';
interface NavItem {
title: string;
@@ -66,7 +65,7 @@ const currentPageTitle = computed(() => {
<template>
<v-app>
<v-navigation-drawer color="#012019" theme="dark" :width="240" class="admin-drawer">
<v-navigation-drawer color="secondary" theme="dark" :width="240" class="admin-drawer">
<div class="brand-block">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 48 48" width="22" height="22">
@@ -85,7 +84,7 @@ const currentPageTitle = computed(() => {
</div>
<div class="brand-sub">ADMIN</div>
<v-list nav density="comfortable" class="app-nav" role="navigation" aria-label="Админ навигация">
<v-list nav density="comfortable" class="app-nav">
<v-list-item
v-for="item in navItems"
:key="item.to"
@@ -131,7 +130,6 @@ const currentPageTitle = computed(() => {
<v-main class="admin-main">
<RouterView />
</v-main>
<DevIndexBadge :index="route.meta.devIndex" :label="route.meta.devLabel" />
</v-app>
</template>
@@ -169,7 +167,7 @@ const currentPageTitle = computed(() => {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 10px;
letter-spacing: 0.16em;
color: #e06155;
color: #b94837;
padding: 0 20px 14px;
text-transform: uppercase;
font-weight: 600;
@@ -180,7 +178,7 @@ const currentPageTitle = computed(() => {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
font-size: 11px;
color: #8a9c95;
color: #7a8c87;
background: rgba(255, 255, 255, 0.05);
padding: 2px 7px;
border-radius: 10px;
+5 -10
View File
@@ -17,7 +17,6 @@ import { useRemindersStore } from '../stores/reminders';
import { usePolling } from '../composables/usePolling';
import AppSidebar from '../components/layout/AppSidebar.vue';
import AppTopbar from '../components/layout/AppTopbar.vue';
import DevIndexBadge from '../components/DevIndexBadge.vue';
const auth = useAuthStore();
const notifications = useNotificationsStore();
@@ -29,12 +28,14 @@ const drawerOpen = ref(true);
// Тот же навигационный pool что в AppSidebar для crumb-resolution в topbar
// (sidebar и topbar независимые, но navGroups совпадают по контракту).
const navItems = computed(() => [
{ title: 'Проекты', to: '/projects' },
{ title: 'Дашборд', to: '/dashboard' },
{ title: 'Сделки', to: '/deals' },
{ title: 'Канбан', to: '/kanban' },
{ title: 'Дашборд', to: '/dashboard' },
{ title: 'Проекты', to: '/projects' },
{ title: 'Напоминания', to: '/reminders' },
{ title: 'Биллинг', to: '/billing' },
{ title: 'Отчёты', to: '/reports' },
{ title: 'Менеджеры', to: '/managers' },
{ title: 'Настройки', to: '/settings' },
]);
@@ -66,19 +67,13 @@ usePolling(loadReminderCounts, { intervalMs: 60_000, enabled: true });
<AppTopbar :page-title="currentPageTitle" @toggle-drawer="drawerOpen = !drawerOpen" />
<v-main class="app-main">
<RouterView v-slot="{ Component, route: r }">
<Transition :name="(r.meta.transition as string) ?? 'ld-route-fadeup'" mode="out-in">
<component :is="Component" :key="r.fullPath" />
</Transition>
</RouterView>
<RouterView />
</v-main>
<DevIndexBadge :index="route.meta.devIndex" :label="route.meta.devLabel" />
</v-app>
</template>
<style scoped>
.app-main {
background: #f6f3ec;
padding-left: 232px;
}
</style>
+1 -5
View File
@@ -10,10 +10,7 @@
* - Слева: brand mark "Лидерра.", цитата с акцентом, footer с ссылками на оферту/политику.
* - Справа: <RouterView /> рендерит конкретный auth-экран (LoginView и т.п.).
*/
import { RouterView, useRoute } from 'vue-router';
import DevIndexBadge from '../components/DevIndexBadge.vue';
const route = useRoute();
import { RouterView } from 'vue-router';
</script>
<template>
@@ -54,7 +51,6 @@ const route = useRoute();
</v-col>
</v-row>
</v-main>
<DevIndexBadge :index="route.meta.devIndex" :label="route.meta.devLabel" />
</v-app>
</template>
+11 -190
View File
@@ -1,173 +1,29 @@
// @ts-expect-error vuetify/styles — CSS-импорт без d.ts
import 'vuetify/styles';
import { h, type Component } from 'vue';
import { createVuetify } from 'vuetify';
import type { ThemeDefinition, IconSet, IconProps } from 'vuetify';
import {
Activity, AlertCircle, AlertTriangle, Archive, ArrowDown, ArrowLeft, ArrowRightLeft,
ArrowUp, Bell, BellOff, Calendar, CalendarDays, Camera, Check, CheckCircle, ChevronDown,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ChevronsUpDown, ChevronUp,
Circle, CircleDot, CircleStop, Clock, Code,
Columns3, Copy, CreditCard, Download, Eye, EyeOff, FilterX, FileText, FlaskConical,
Folder, Folders, Globe, HelpCircle, Info, Key, KeyRound, LayoutDashboard, List, LogOut, Mail,
Megaphone, Menu, MessageSquare, MessageSquareText, Minus, MoreVertical, Paperclip, Pause,
Pencil, Phone, Play, Plus,
PlusCircle, Puzzle, ReceiptText, RefreshCw, RotateCcw, RotateCw, RussianRuble, Save, Search,
Settings, Shield, ShieldCheck, ShieldOff, Square, SquareCheck, SquareMinus, Star, StarHalf,
Tag, Trash2, User, UserCheck, UserCog, UserPlus,
Users, Wallet, Webhook, X, XCircle,
} from 'lucide-vue-next';
import type { ThemeDefinition } from 'vuetify';
/**
* Палитра Forest extended (Iteration 1 Quiet Luxury redesign).
* Spec: docs/superpowers/specs/2026-05-12-portal-redesign-quiet-luxury-design.md §3
* CSS-токены: app/resources/css/tokens.css (single source of truth)
* Палитра Forest (BRANDBOOK_v2 §3, v8 handoff Платона).
*
* 14 OKLCH-статусов воронки маппятся на slugs из db/schema.sql:2076 (lead_statuses)
* через `useStatusPill` composable, НЕ через Vuetify theme.
* Источник истины `liderra_v8_handoff/docs/BRANDBOOK_v2.md`. 14 OKLCH-статусов
* воронки маппятся на 14 slug'ов из `db/schema.sql:2076` (lead_statuses)
* НЕ на 14 «обобщённых» из BRANDBOOK §3.6 (расхождение зафиксировано в
* `Открытые_вопросы` v1.13).
*/
const liderraForest: ThemeDefinition = {
dark: false,
colors: {
background: '#F6F3EC',
background: '#F6F3EC', // warm ivory — page bg
surface: '#FFFFFF',
primary: '#0F6E56',
primary: '#0F6E56', // Teal — неоспариваемый primary
'on-primary': '#FFFFFF',
secondary: '#012019',
secondary: '#012019', // теало-нуар — sidebar
'on-secondary': '#F6F3EC',
success: '#2E8B57',
warning: '#D9A441',
error: '#B83A3A',
info: '#3F7C95',
// Расширения — для data viz и semantic uses
'liderra-plum': '#7A5BA3',
'liderra-salmon': '#CC6E50',
'liderra-teal-deep': '#0A5A47',
'liderra-muted': '#6B6356',
},
};
/**
* Liderra Lucide IconSet (CTO-19 closure, v1.83).
* Spec: docs/superpowers/specs/2026-05-13-cto-19-lucide-icon-migration-design.md
*
* Maps 78 mdi-* semantic-ID strings (used across 51 Vue/TS files in resources/js/)
* to Lucide Vue components for rendering. Views НЕ touched mdi-* строки остаются
* как semantic identifiers, рендерятся через Lucide stroke-based SVG icons.
*
* CLAUDE.md §2 «Иконки: Lucide» бренд-spec compliance.
*/
const lucideMap: Record<string, Component> = {
'mdi-account-arrow-right-outline': UserCheck,
'mdi-account-group-outline': Users,
'mdi-account-outline': User,
'mdi-account-plus-outline': UserPlus,
'mdi-account-switch': UserCog,
'mdi-alert-circle': AlertCircle,
'mdi-alert-circle-outline': AlertCircle,
'mdi-alert-outline': AlertTriangle,
'mdi-api': Webhook,
'mdi-archive': Archive,
'mdi-arrow-down': ArrowDown,
'mdi-arrow-left': ArrowLeft,
'mdi-arrow-up': ArrowUp,
'mdi-autorenew': RotateCw,
'mdi-bell-off-outline': BellOff,
'mdi-bell-outline': Bell,
'mdi-bullhorn-outline': Megaphone,
'mdi-camera': Camera,
'mdi-cash-plus': PlusCircle,
'mdi-chart-box-outline': LayoutDashboard,
'mdi-check': Check,
'mdi-check-circle': CheckCircle,
'mdi-check-circle-outline': CheckCircle,
'mdi-chevron-right': ChevronRight,
'mdi-clock-check-outline': Clock,
'mdi-clock-outline': Clock,
'mdi-close': X,
'mdi-close-circle': XCircle,
'mdi-cog-outline': Settings,
'mdi-comment-outline': MessageSquare,
'mdi-content-copy': Copy,
'mdi-content-save-outline': Save,
'mdi-credit-card-outline': CreditCard,
'mdi-currency-rub': RussianRuble,
'mdi-delete-outline': Trash2,
'mdi-dots-vertical': MoreVertical,
'mdi-download': Download,
'mdi-email-outline': Mail,
'mdi-eye': Eye,
'mdi-eye-off': EyeOff,
'mdi-eye-outline': Eye,
'mdi-file-pdf-box': FileText,
'mdi-filter-off': FilterX,
'mdi-folder-multiple-outline': Folders,
'mdi-folder-outline': Folder,
'mdi-format-list-bulleted': List,
'mdi-key': Key,
'mdi-lock-reset': KeyRound,
'mdi-logout': LogOut,
'mdi-magnify': Search,
'mdi-message-text': MessageSquareText,
'mdi-pause': Pause,
'mdi-pencil': Pencil,
'mdi-pencil-outline': Pencil,
'mdi-phone': Phone,
'mdi-play': Play,
'mdi-plus': Plus,
'mdi-plus-circle-outline': PlusCircle,
'mdi-progress-clock': Clock,
'mdi-pulse': Activity,
'mdi-puzzle-outline': Puzzle,
'mdi-receipt-text-check-outline': ReceiptText,
'mdi-refresh': RefreshCw,
'mdi-restore': RotateCcw,
'mdi-shield-account-outline': ShieldCheck,
'mdi-shield-key': Shield,
'mdi-shield-lock-outline': ShieldCheck,
'mdi-shield-off': ShieldOff,
'mdi-stop-circle-outline': CircleStop,
'mdi-swap-horizontal': ArrowRightLeft,
'mdi-tag-arrow-right': Tag,
'mdi-test-tube': FlaskConical,
'mdi-trash-can-outline': Trash2,
'mdi-view-column-outline': Columns3,
'mdi-view-dashboard-outline': LayoutDashboard,
'mdi-wallet-outline': Wallet,
'mdi-web': Globe,
'mdi-xml': Code,
// Vuetify-internal default mdi-* aliases (CTO-19 closure extension)
'mdi-chevron-down': ChevronDown,
'mdi-chevron-left': ChevronLeft,
'mdi-menu-down': ChevronDown,
'mdi-menu-right': ChevronRight,
'mdi-menu-up': ChevronUp,
'mdi-menu': Menu,
'mdi-page-first': ChevronsLeft,
'mdi-page-last': ChevronsRight,
'mdi-checkbox-marked': SquareCheck,
'mdi-checkbox-blank-outline': Square,
'mdi-minus-box': SquareMinus,
'mdi-radiobox-marked': CircleDot,
'mdi-radiobox-blank': Circle,
'mdi-circle': Circle,
'mdi-information': Info,
'mdi-minus': Minus,
'mdi-calendar': Calendar,
'mdi-calendar-month': CalendarDays,
'mdi-paperclip': Paperclip,
'mdi-unfold-more-horizontal': ChevronsUpDown,
'mdi-window-close': X,
'mdi-cached': RefreshCw,
'mdi-star': Star,
'mdi-star-outline': Star,
'mdi-star-half-full': StarHalf,
};
const liderraLucideSet: IconSet = {
component: (props: IconProps) => {
const Icon = lucideMap[String(props.icon)] || HelpCircle;
return h(Icon, { size: 20, strokeWidth: 1.75 });
},
};
@@ -176,43 +32,8 @@ export const vuetify = createVuetify({
defaultTheme: 'liderraForest',
themes: { liderraForest },
},
icons: {
defaultSet: 'liderra',
sets: { liderra: liderraLucideSet },
},
defaults: {
VBtn: {
variant: 'flat',
rounded: 'lg',
},
VCard: {
rounded: 'lg',
variant: 'flat',
border: true,
},
VTextField: {
variant: 'outlined',
density: 'comfortable',
color: 'primary',
},
VTextarea: {
variant: 'outlined',
density: 'comfortable',
color: 'primary',
},
VSelect: {
variant: 'outlined',
density: 'comfortable',
},
VChip: {
rounded: 'pill',
size: 'small',
},
VDataTable: {
density: 'comfortable',
},
VDialog: {
scrim: 'rgba(1, 32, 25, 0.32)',
},
VBtn: { variant: 'flat' },
VCard: { rounded: 'lg' },
},
});
+26 -117
View File
@@ -11,24 +11,7 @@ import { useAuthStore } from '../stores/auth';
* - meta.requiresAuth=true требует isAuthenticated, иначе redirect /login.
* - meta.guestOnly=true (auth-views) если isAuthenticated, redirect /dashboard.
* - При первом заходе (cold start) `fetchMe()` восстанавливает session-state.
*
* Dev-index badge: meta.devIndex/devLabel пробрасываются в layout-уровне
* (AppLayout/AuthLayout/AdminLayout) через `<DevIndexBadge>` для ручного фидбэка
* на localhost («элемент 16: бага X»). См. components/DevIndexBadge.vue.
*/
declare module 'vue-router' {
interface RouteMeta {
layout?: string;
title?: string;
requiresAuth?: boolean;
guestOnly?: boolean;
errorCode?: string;
devIndex?: number;
devLabel?: string;
transition?: string;
}
}
const routes: RouteRecordRaw[] = [
{
path: '/',
@@ -38,147 +21,91 @@ const routes: RouteRecordRaw[] = [
path: '/login',
name: 'login',
component: () => import('../views/auth/LoginView.vue'),
meta: { layout: 'auth', title: 'Вход', guestOnly: true, devIndex: 1, devLabel: 'Login' },
meta: { layout: 'auth', title: 'Вход', guestOnly: true },
},
{
path: '/register',
name: 'register',
component: () => import('../views/auth/RegisterView.vue'),
meta: { layout: 'auth', title: 'Регистрация', guestOnly: true, devIndex: 2, devLabel: 'Register' },
meta: { layout: 'auth', title: 'Регистрация', guestOnly: true },
},
{
path: '/2fa',
name: '2fa',
component: () => import('../views/auth/TwoFactorView.vue'),
meta: { layout: 'auth', title: 'Двухфакторная проверка', devIndex: 3, devLabel: '2FA' },
meta: { layout: 'auth', title: 'Двухфакторная проверка' },
},
{
path: '/forgot',
name: 'forgot',
component: () => import('../views/auth/ForgotPasswordView.vue'),
meta: { layout: 'auth', title: 'Сброс пароля', guestOnly: true, devIndex: 4, devLabel: 'Forgot password' },
meta: { layout: 'auth', title: 'Сброс пароля', guestOnly: true },
},
{
path: '/recovery',
name: 'recovery',
component: () => import('../views/auth/RecoveryCodesView.vue'),
meta: { layout: 'auth', title: 'Резервные коды', devIndex: 6, devLabel: 'Recovery codes' },
meta: { layout: 'auth', title: 'Резервные коды' },
},
{
path: '/recovery-use',
name: 'recovery-use',
component: () => import('../views/auth/UseRecoveryCodeView.vue'),
meta: { layout: 'auth', title: 'Вход по резервному коду', devIndex: 7, devLabel: 'Use recovery' },
meta: { layout: 'auth', title: 'Вход по резервному коду' },
},
{
path: '/reset/:token',
name: 'reset-password',
component: () => import('../views/auth/ResetPasswordView.vue'),
meta: { layout: 'auth', title: 'Новый пароль', guestOnly: true, devIndex: 5, devLabel: 'Reset password' },
meta: { layout: 'auth', title: 'Новый пароль', guestOnly: true },
},
{
path: '/dashboard',
name: 'dashboard',
component: () => import('../views/DashboardView.vue'),
meta: {
layout: 'app',
title: 'Дашборд',
requiresAuth: true,
transition: 'ld-route-fadeup',
devIndex: 8,
devLabel: 'Dashboard',
},
meta: { layout: 'app', title: 'Дашборд', requiresAuth: true },
},
{
path: '/deals',
name: 'deals',
component: () => import('../views/DealsView.vue'),
meta: {
layout: 'app',
title: 'Сделки',
requiresAuth: true,
transition: 'ld-route-fadeup',
devIndex: 9,
devLabel: 'Сделки',
},
meta: { layout: 'app', title: 'Сделки', requiresAuth: true },
},
{
path: '/kanban',
name: 'kanban',
component: () => import('../views/KanbanView.vue'),
meta: {
layout: 'app',
title: 'Канбан',
requiresAuth: true,
transition: 'ld-route-fadeup',
devIndex: 10,
devLabel: 'Канбан',
},
meta: { layout: 'app', title: 'Канбан', requiresAuth: true },
},
{
path: '/projects',
name: 'projects',
component: () => import('../views/ProjectsView.vue'),
meta: {
layout: 'app',
title: 'Проекты',
requiresAuth: true,
transition: 'ld-route-fadeup',
devIndex: 16,
devLabel: 'Проекты',
},
meta: { layout: 'app', title: 'Проекты', requiresAuth: true },
},
{
path: '/billing',
name: 'billing',
component: () => import('../views/BillingView.vue'),
meta: {
layout: 'app',
title: 'Биллинг и тарифы',
requiresAuth: true,
transition: 'ld-route-fadeup',
devIndex: 13,
devLabel: 'Биллинг',
},
meta: { layout: 'app', title: 'Биллинг и тарифы', requiresAuth: true },
},
{
path: '/settings',
name: 'settings',
component: () => import('../views/SettingsView.vue'),
meta: {
layout: 'app',
title: 'Настройки',
requiresAuth: true,
transition: 'ld-route-fadeup',
devIndex: 14,
devLabel: 'Настройки',
},
meta: { layout: 'app', title: 'Настройки', requiresAuth: true },
},
{
path: '/reports',
name: 'reports',
component: () => import('../views/ReportsView.vue'),
meta: {
layout: 'app',
title: 'Отчёты',
requiresAuth: true,
transition: 'ld-route-fadeup',
devIndex: 12,
devLabel: 'Отчёты',
},
meta: { layout: 'app', title: 'Отчёты', requiresAuth: true },
},
{
path: '/reminders',
name: 'reminders',
component: () => import('../views/RemindersView.vue'),
meta: {
layout: 'app',
title: 'Напоминания',
requiresAuth: true,
transition: 'ld-route-fadeup',
devIndex: 11,
devLabel: 'Напоминания',
},
meta: { layout: 'app', title: 'Напоминания', requiresAuth: true },
},
// Админка SaaS — отдельный layout с под-брендом ADMIN.
// TODO: дополнительный role-guard на super_admin.
@@ -190,86 +117,68 @@ const routes: RouteRecordRaw[] = [
path: '/admin/tenants',
name: 'admin-tenants',
component: () => import('../views/admin/AdminTenantsView.vue'),
meta: { layout: 'admin', title: 'Тенанты', requiresAuth: true, devIndex: 21, devLabel: 'Admin Tenants' },
meta: { layout: 'admin', title: 'Тенанты', requiresAuth: true },
},
{
path: '/admin/tenants/:code',
name: 'admin-tenant-detail',
component: () => import('../views/admin/AdminTenantDetailView.vue'),
meta: { layout: 'admin', title: 'Тенант', requiresAuth: true, devIndex: 22, devLabel: 'Admin Tenant Detail' },
meta: { layout: 'admin', title: 'Тенант', requiresAuth: true },
},
{
path: '/admin/billing',
name: 'admin-billing',
component: () => import('../views/admin/AdminBillingView.vue'),
meta: { layout: 'admin', title: 'Биллинг', requiresAuth: true, devIndex: 23, devLabel: 'Admin Billing' },
meta: { layout: 'admin', title: 'Биллинг', requiresAuth: true },
},
{
path: '/admin/incidents',
name: 'admin-incidents',
component: () => import('../views/admin/AdminIncidentsView.vue'),
meta: { layout: 'admin', title: 'Инциденты', requiresAuth: true, devIndex: 24, devLabel: 'Admin Incidents' },
meta: { layout: 'admin', title: 'Инциденты', requiresAuth: true },
},
{
path: '/admin/system',
name: 'admin-system',
component: () => import('../views/admin/AdminSystemView.vue'),
meta: { layout: 'admin', title: 'Система', requiresAuth: true, devIndex: 25, devLabel: 'Admin System' },
meta: { layout: 'admin', title: 'Система', requiresAuth: true },
},
{
path: '/admin/pricing-tiers',
name: 'admin-pricing-tiers',
component: () => import('../views/admin/AdminPricingTiersView.vue'),
meta: {
layout: 'admin',
title: 'Тарифная сетка',
requiresAuth: true,
devIndex: 27,
devLabel: 'Admin Pricing Tiers',
},
meta: { layout: 'admin', title: 'Тарифная сетка', requiresAuth: true },
},
{
path: '/admin/supplier-prices',
name: 'admin-supplier-prices',
component: () => import('../views/admin/AdminSupplierPricesView.vue'),
meta: {
layout: 'admin',
title: 'Цены поставщиков',
requiresAuth: true,
devIndex: 28,
devLabel: 'Admin Supplier Prices',
},
meta: { layout: 'admin', title: 'Цены поставщиков', requiresAuth: true },
},
{
path: '/admin/impersonation',
name: 'admin-impersonation',
component: () => import('../views/admin/AdminImpersonationView.vue'),
meta: {
layout: 'admin',
title: 'Impersonation',
requiresAuth: true,
devIndex: 26,
devLabel: 'Admin Impersonation',
},
meta: { layout: 'admin', title: 'Impersonation', requiresAuth: true },
},
// Error pages: 403/500 явные + catch-all 404 (всегда последний).
{
path: '/403',
name: 'forbidden',
component: () => import('../views/errors/ErrorView.vue'),
meta: { layout: 'error', errorCode: '403', title: 'Доступ запрещён', devIndex: 15, devLabel: 'Ошибка 403' },
meta: { layout: 'error', errorCode: '403', title: 'Доступ запрещён' },
},
{
path: '/500',
name: 'server-error',
component: () => import('../views/errors/ErrorView.vue'),
meta: { layout: 'error', errorCode: '500', title: 'Ошибка сервера', devIndex: 15, devLabel: 'Ошибка 500' },
meta: { layout: 'error', errorCode: '500', title: 'Ошибка сервера' },
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('../views/errors/ErrorView.vue'),
meta: { layout: 'error', errorCode: '404', title: 'Страница не найдена', devIndex: 15, devLabel: 'Ошибка 404' },
meta: { layout: 'error', errorCode: '404', title: 'Страница не найдена' },
},
];
-209
View File
@@ -1,209 +0,0 @@
import { defineStore } from 'pinia';
import { ref, reactive, watch } from 'vue';
import axios from 'axios';
export interface Project {
id: number;
name: string;
signal_type: 'site' | 'call' | 'sms';
signal_identifier?: string | null;
sms_senders?: string[] | null;
sms_keyword?: string | null;
daily_limit_target: number;
delivered_today: number;
delivered_in_month?: number;
is_active: boolean;
archived_at: string | null;
region_mask?: number;
region_mode?: string;
delivery_days_mask?: number;
sync_status: 'ok' | 'pending' | 'failed';
last_synced_at?: string | null;
}
export const useProjectsStore = defineStore('projects', () => {
const items = ref<Project[]>([]);
const total = ref(0);
const filters = reactive({ signal_type: '', status: '', search: '', page: 1, per_page: 20 });
const selectedIds = ref<Set<number>>(new Set());
const pendingIds = ref<Set<number>>(new Set());
const loading = ref(false);
const selectAllByFilter = ref<boolean>(false);
// Closure state for polling — kept outside returned store surface.
let pollTimeout: ReturnType<typeof setTimeout> | null = null;
let currentDelay = 5000;
const DELAY_OK = 5000;
const DELAY_MAX = 30000;
async function fetch() {
loading.value = true;
try {
const params: Record<string, unknown> = { page: filters.page, per_page: filters.per_page };
if (filters.signal_type) params.signal_type = filters.signal_type;
if (filters.status) params.status = filters.status;
if (filters.search) params.search = filters.search;
const { data } = await axios.get('/api/projects', { params });
items.value = data.data;
total.value = data.meta.total;
} finally {
loading.value = false;
}
}
async function create(payload: Partial<Project>) {
const { data } = await axios.post('/api/projects', payload);
pendingIds.value.add(data.data.id);
await fetch();
return data.data;
}
async function update(id: number, payload: Partial<Project>) {
const { data } = await axios.patch(`/api/projects/${id}`, payload);
await fetch();
return data.data;
}
async function archive(id: number) {
await axios.delete(`/api/projects/${id}`);
await fetch();
}
async function syncNow(id: number) {
await axios.post(`/api/projects/${id}/sync`);
pendingIds.value.add(id);
await fetch();
}
async function toggleActive(project: Project) {
await axios.patch(`/api/projects/${project.id}/toggle-active`, { is_active: !project.is_active });
await fetch();
}
function toggleSelect(id: number) {
selectAllByFilter.value = false; // user opted into manual mode
if (selectedIds.value.has(id)) {
selectedIds.value.delete(id);
} else {
selectedIds.value.add(id);
}
}
function clearSelection() {
selectedIds.value.clear();
}
async function bulkAction(action: 'pause' | 'resume' | 'archive') {
const ids = Array.from(selectedIds.value);
if (!ids.length) return;
await axios.post('/api/projects/bulk', { action, ids });
clearSelection();
await fetch();
}
interface BulkPayload {
action: 'pause' | 'resume' | 'archive' | 'update_regions' | 'update_days' | 'update_limit';
add?: number;
remove?: number;
delta?: number;
replace?: number;
}
interface BulkResponse {
updated: number;
skipped: Array<{ id: number; reason: string }>;
warnings: string[];
}
async function bulkUpdate(payload: BulkPayload): Promise<BulkResponse> {
const body: Record<string, unknown> = { ...payload };
if (selectAllByFilter.value) {
const f: Record<string, unknown> = {};
if (filters.signal_type) f.signal_type = filters.signal_type;
if (filters.status) f.status = filters.status;
if (filters.search) f.search = filters.search;
body.scope = { filter: f };
} else {
body.ids = Array.from(selectedIds.value);
}
const { data } = await axios.post('/api/projects/bulk', body);
clearSelection();
selectAllByFilter.value = false;
await fetch();
return data;
}
// Watch filter changes — clear selection on switch
watch(
() => [filters.signal_type, filters.status, filters.search] as const,
() => {
clearSelection();
selectAllByFilter.value = false;
},
);
function scheduleNext() {
pollTimeout = setTimeout(async () => {
pollTimeout = null;
if (pendingIds.value.size === 0) {
currentDelay = DELAY_OK;
return;
}
try {
const ids = Array.from(pendingIds.value).join(',');
const { data } = await axios.get<{ data: Project[] }>('/api/projects', { params: { ids } });
for (const project of data.data) {
const idx = items.value.findIndex((i) => i.id === project.id);
if (idx !== -1) items.value[idx] = project;
if (project.sync_status === 'ok' || project.sync_status === 'failed') {
pendingIds.value.delete(project.id);
}
}
currentDelay = DELAY_OK;
} catch {
// Exponential backoff to avoid hammering on transient errors.
currentDelay = Math.min(currentDelay * 2, DELAY_MAX);
}
if (pendingIds.value.size > 0) {
scheduleNext();
}
}, currentDelay);
}
function startPolling() {
if (pollTimeout !== null) return;
scheduleNext();
}
function stopPolling() {
if (pollTimeout !== null) {
clearTimeout(pollTimeout);
pollTimeout = null;
}
currentDelay = DELAY_OK;
}
return {
items,
total,
filters,
selectedIds,
pendingIds,
loading,
selectAllByFilter,
fetch,
create,
update,
archive,
syncNow,
toggleActive,
toggleSelect,
clearSelection,
bulkAction,
bulkUpdate,
startPolling,
stopPolling,
};
});
+10 -3
View File
@@ -73,11 +73,18 @@ const activeView = ref<'overview' | 'charges'>('overview');
<v-tabs-window v-model="activeView">
<v-tabs-window-item value="overview">
<v-alert v-if="MOCK_PENDING" type="info" variant="tonal" density="compact" class="mt-4" role="status">
<v-alert
v-if="MOCK_PENDING"
type="info"
variant="tonal"
density="compact"
class="mt-4"
role="status"
>
<strong>1 платёж в обработке</strong> {{ formatPlain(MOCK_PENDING.amount) }} от
{{ MOCK_PENDING.method }}, начат {{ MOCK_PENDING.startedAt }}. Авто-восстановление в
{{ MOCK_PENDING.autoCancelAt }} ({{ MOCK_PENDING.timeoutMinutes }} мин). Кнопки «Отменить» нет это
техническое решение.
{{ MOCK_PENDING.autoCancelAt }} ({{ MOCK_PENDING.timeoutMinutes }} мин). Кнопки «Отменить» нет
это техническое решение.
</v-alert>
<BalanceCard
-14
View File
@@ -61,11 +61,6 @@ const balance: Balance = {
<v-container fluid class="dashboard pa-6">
<DashboardPageHead v-model="range" />
<div class="ld-meta mt-2">
<span class="ld-pulse" aria-hidden="true"></span>
<span>Live · обновлено только что</span>
</div>
<v-row dense class="kpi-row mt-4">
<DashboardKpiRow :kpis="kpis" />
<DashboardBalance :balance="balance" />
@@ -86,13 +81,4 @@ const balance: Balance = {
.dashboard {
max-width: 1440px;
}
.ld-meta {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #66635c;
letter-spacing: 0.02em;
}
</style>
+8 -92
View File
@@ -25,25 +25,11 @@ const NewDealDialog = defineAsyncComponent(() => import('../components/deals/New
import DealsFilters from '../components/deals/DealsFilters.vue';
import DealsBulkBar from '../components/deals/DealsBulkBar.vue';
import DealsTable from '../components/deals/DealsTable.vue';
import FilterChip from '../components/ui/FilterChip.vue';
import DensityToggle from '../components/ui/DensityToggle.vue';
import StatusPill from '../components/ui/StatusPill.vue';
import { useDensity } from '../composables/useDensity';
import { useAuthStore } from '../stores/auth';
import { useLeadStatusesStore } from '../stores/leadStatuses';
import * as dealsApi from '../api/deals';
import { buildCsvString, triggerBlobDownload, triggerCsvDownload } from '../composables/useCsvDownload';
// Task 15: density-toggle composable (persists в localStorage, влияет на row height).
const { rowHeight } = useDensity();
// Task 15: stub-обработчики redesign-filter-chip'ов. На I1 popover'ы Проект/Менеджер
// не реализованы; chiprow служит quiet-luxury визуальной заменой для status-summary'ов.
// Не ломает существующие VSelect'ы в DealsFilters те остаются как полноценный filter UI.
function onRedesignFilterClick(name: string): void {
console.log(`[redesign filterbar] ${name} clicked — popover TBD`);
}
const auth = useAuthStore();
const leadStatusesStore = useLeadStatusesStore();
@@ -463,45 +449,6 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
@clear-filters="clearFilters"
/>
<!-- Task 15: redesign-filterbar (quiet luxury chiprow + density toggle).
Минимальный набор: 3 FilterChip-ярлыка (Статус/Проект/Менеджер) + DensityToggle справа.
Клики на I1 stub'ы (popover'ы TBD); полноценные multi-select'ы остаются в DealsFilters выше.
Status-legend ниже визуализирует пул цветов StatusPill'ов воронки. -->
<div v-if="!trashMode" class="ld-filterbar mt-3">
<div class="ld-filterbar__chips">
<FilterChip
label="Статус"
:count="filteredDeals.length"
:active="false"
@click="onRedesignFilterClick('Статус')"
/>
<FilterChip
label="Проект"
:count="filterProjects.length"
:active="filterProjects.length > 0"
@click="onRedesignFilterClick('Проект')"
/>
<FilterChip
label="Менеджер"
:count="filterManagers.length"
:active="filterManagers.length > 0"
@click="onRedesignFilterClick('Менеджер')"
/>
</div>
<DensityToggle class="ld-filterbar__density" />
</div>
<!-- Status-legend: показывает топ-4 статуса воронки как StatusPill-чипы.
Цели: (1) предпросмотр пула цветов в quiet-luxury палитре,
(2) обеспечивает наличие StatusPill в дереве компонентов (DealsTable inner
slots внутри stubbed VDataTable в тестах, не рендерятся). -->
<div v-if="!trashMode" class="ld-status-legend mt-2">
<StatusPill slug="new" label="Новые" />
<StatusPill slug="in_progress" label="В работе" />
<StatusPill slug="won" label="Выиграно" />
<StatusPill slug="archived" label="Архив" />
</div>
<!-- Bulk-actions bar (показывается только при selected.length > 0) -->
<DealsBulkBar
v-model:status-menu-open="statusMenuOpen"
@@ -527,19 +474,14 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
Backend недоступен показаны mock-данные.
</v-alert>
<!-- Task 15: wrapper с .ld-hover-lift + .ld-stagger-row для quiet-luxury motion
(lift на hover + stagger fade-in строк, motion #2 #4). CSS-переменная
--row-height пробрасывается в DealsTable для динамической плотности. -->
<div class="ld-hover-lift ld-stagger-row mt-4" :style="{ '--row-height': rowHeight + 'px' }">
<DealsTable
:deals="filteredDeals"
:selected-ids="selected"
:status-by-slug="statusBySlug"
:row-height="rowHeight"
@update:selected-ids="selected = $event"
@row-click="openDeal"
/>
</div>
<DealsTable
class="mt-4"
:deals="filteredDeals"
:selected-ids="selected"
:status-by-slug="statusBySlug"
@update:selected-ids="selected = $event"
@row-click="openDeal"
/>
<DealDetailDrawer v-model:open="drawerOpen" :deal="selectedDeal" :tenant-id="auth.user?.tenant_id" />
@@ -615,30 +557,4 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
font-feature-settings: 'tnum';
font-weight: 500;
}
/* Task 15: redesign-filterbar — quiet-luxury chiprow + density toggle */
.ld-filterbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.ld-filterbar__chips {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.ld-filterbar__density {
flex-shrink: 0;
}
/* Status-legend strip: визуальная палитра StatusPill пула. */
.ld-status-legend {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
</style>
+1 -1
View File
@@ -161,7 +161,7 @@ defineExpose({ dealsByStatus, totalDeals, newDealOpen, onDealCreated, fetchError
Backend недоступен показаны mock-данные.
</v-alert>
<div class="kanban-board mt-4" tabindex="0" role="region" aria-label="Канбан-доска воронки продаж">
<div class="kanban-board mt-4">
<KanbanColumn
v-for="status in leadStatuses"
:key="status.slug"
@@ -1,15 +0,0 @@
<template>
<Story title="ProjectsView">
<Variant title="Empty">
<div>Empty state отдельно через axios mock в Histoire run-time неудобно. Тестируется через Vitest.</div>
</Variant>
<Variant title="Live (real API)">
<ProjectsView />
</Variant>
</Story>
</template>
<script setup lang="ts">
import ProjectsView from './ProjectsView.vue';
// Histoire без mock-server story рендерит как есть, реальный API.
</script>
+1 -224
View File
@@ -1,226 +1,3 @@
<template>
<div class="projects-view">
<div class="d-flex justify-space-between align-center mb-4">
<h1 class="text-h4">Проекты</h1>
<v-btn color="primary" prepend-icon="mdi-plus" @click="openCreate">+ Создать проект</v-btn>
</div>
<div class="d-flex gap-3 mb-4">
<v-select
v-model="store.filters.signal_type"
:items="typeFilters"
label="Тип"
clearable
density="comfortable"
style="max-width: 180px"
hide-details
@update:model-value="store.fetch()"
/>
<v-select
v-model="store.filters.status"
:items="statusFilters"
label="Статус"
clearable
density="comfortable"
style="max-width: 180px"
hide-details
@update:model-value="store.fetch()"
/>
<v-text-field
v-model="store.filters.search"
label="Поиск"
prepend-inner-icon="mdi-magnify"
density="comfortable"
hide-details
style="max-width: 240px"
@input="onSearchDebounced"
/>
</div>
<div v-if="!store.loading && store.items.length > 0" class="projects-toolbar d-flex align-center gap-3 mb-3">
<label class="toolbar-check" data-testid="select-all-toggle">
<input
type="checkbox"
aria-label="Выбрать все проекты по текущим фильтрам"
:checked="store.selectAllByFilter"
@change="(e) => onToggleSelectAll((e.target as HTMLInputElement).checked)"
/>
<span
class="toolbar-check__box"
:class="{ 'toolbar-check__box--partial': !store.selectAllByFilter && store.selectedIds.size > 0 }"
/>
</label>
<span class="text-body-2"
>Выбрано: {{ store.selectedIds.size }} из {{ store.total }} (по текущим фильтрам)</span
>
</div>
<div v-if="store.loading" class="text-center py-8">
<v-progress-circular indeterminate color="primary" />
</div>
<div v-else-if="store.items.length === 0" class="text-center py-12 text-medium-emphasis">
Нет проектов. Создайте первый кнопка справа сверху.
</div>
<div v-else class="projects-grid">
<ProjectCard
v-for="project in store.items"
:key="project.id"
:project="project"
:selected="store.selectedIds.has(project.id)"
@toggle-select="store.toggleSelect"
@edit="openEdit"
@toggle-active="store.toggleActive"
@sync-now="(p: Project) => store.syncNow(p.id)"
@archive="(p: Project) => store.archive(p.id)"
/>
</div>
<BulkActionsBar v-if="store.selectedIds.size > 0" />
<NewProjectDialog v-model="createOpen" mode="create" @saved="store.fetch()" />
<EditProjectDialog v-model="editOpen" :project="editing" @saved="store.fetch()" />
</div>
<div>Projects (stub)</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { useProjectsStore, type Project } from '../stores/projectsStore';
import ProjectCard from '../components/projects/ProjectCard.vue';
import BulkActionsBar from '../components/projects/BulkActionsBar.vue';
import NewProjectDialog from './projects/NewProjectDialog.vue';
import EditProjectDialog from './projects/EditProjectDialog.vue';
const store = useProjectsStore();
const createOpen = ref(false);
const editOpen = ref(false);
const editing = ref<Project | null>(null);
const typeFilters = [
{ title: 'Сайт', value: 'site' },
{ title: 'Звонок', value: 'call' },
{ title: 'СМС', value: 'sms' },
];
const statusFilters = [
{ title: 'Активные', value: 'active' },
{ title: 'На паузе', value: 'paused' },
{ title: 'Архивные', value: 'archived' },
];
let searchTimer: ReturnType<typeof setTimeout> | null = null;
function onSearchDebounced() {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => store.fetch(), 300);
}
function openCreate() {
createOpen.value = true;
}
function openEdit(project: Project) {
editing.value = project;
editOpen.value = true;
}
function onToggleSelectAll(value: boolean | null) {
if (value) {
store.selectAllByFilter = true;
store.items.forEach((p: Project) => store.selectedIds.add(p.id));
} else {
store.selectAllByFilter = false;
store.clearSelection();
}
}
watch(
() => store.pendingIds.size,
(size) => {
if (size > 0) store.startPolling();
},
);
onMounted(store.fetch);
onUnmounted(() => store.stopPolling());
</script>
<style scoped>
.projects-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
/* Workaround: MDI-шрифт не подключён в проекте (Диз-4),
`<i class="mdi-close-circle">` рендерится пустым. Подменяем глиф на Unicode ``
и показываем только когда поле имеет значение (Vuetify ставит `.v-field--dirty`). */
.projects-view :deep(.v-field__clearable) {
position: relative;
}
.projects-view :deep(.v-field__clearable .v-icon) {
color: transparent;
width: 20px;
height: 20px;
}
.projects-view :deep(.v-field--dirty .v-field__clearable)::after {
content: '✕';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: rgba(1, 32, 25, 0.55);
font-size: 14px;
font-family: 'Inter', system-ui, sans-serif;
pointer-events: none;
transition: color 150ms ease;
}
.projects-view :deep(.v-field--dirty .v-field__clearable:hover)::after {
color: var(--liderra-noir, #012019);
}
.toolbar-check {
display: inline-flex;
align-items: center;
cursor: pointer;
padding: 4px;
}
.toolbar-check input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.toolbar-check__box {
width: 20px;
height: 20px;
border: 2px solid var(--liderra-noir);
border-radius: 4px;
background: #fff;
display: inline-block;
position: relative;
transition: background 150ms ease;
}
.toolbar-check input:checked + .toolbar-check__box {
background: var(--liderra-teal, #0f6e56);
border-color: var(--liderra-teal, #0f6e56);
}
.toolbar-check input:checked + .toolbar-check__box::after {
content: '';
position: absolute;
left: 5px;
top: 1px;
width: 6px;
height: 11px;
border: solid #fff;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.toolbar-check__box--partial {
background: var(--liderra-teal, #0f6e56);
border-color: var(--liderra-teal, #0f6e56);
}
.toolbar-check__box--partial::after {
content: '';
position: absolute;
left: 3px;
top: 7px;
width: 10px;
height: 2px;
background: #fff;
}
</style>
+6 -1
View File
@@ -113,7 +113,12 @@ async function openDeal(dealId: number): Promise<void> {
<RemindersFilters v-model="activeTab" />
<RemindersList @edit="openEdit" @delete="confirmDelete" @complete="executeComplete" @open-deal="openDeal" />
<RemindersList
@edit="openEdit"
@delete="confirmDelete"
@complete="executeComplete"
@open-deal="openDeal"
/>
<ReminderDialog v-model="editDialogOpen" :reminder="editingReminder" @saved="onSaved" />
+6 -1
View File
@@ -202,7 +202,12 @@ const canSubmit = computed(() => quotaActive.value < quotaMax.value && !submitti
@reset="resetForm"
/>
<ReportJobsList :jobs="jobs" @retry="onRetry" @cancel="onCancel" @request-delete="askDelete" />
<ReportJobsList
:jobs="jobs"
@retry="onRetry"
@cancel="onCancel"
@request-delete="askDelete"
/>
<v-dialog v-model="deleteDialog" max-width="420" persistent>
<v-card>
@@ -2,7 +2,12 @@
<div class="admin-supplier-prices-view">
<h1 class="text-h4 mb-6">Цены поставщиков (закупка)</h1>
<v-card elevation="1">
<v-data-table :headers="headers" :items="suppliers" density="comfortable" class="numeric-tnum">
<v-data-table
:headers="headers"
:items="suppliers"
density="comfortable"
class="numeric-tnum"
>
<template #[`item.cost_rub`]="{ item }">
<v-text-field
v-model="item.cost_rub"
@@ -12,7 +17,6 @@
density="compact"
hide-details
variant="plain"
:aria-label="`Cost (₽) для ${item.name}`"
/>
</template>
<template #[`item.quality_score`]="{ item }">
@@ -25,7 +29,6 @@
density="compact"
hide-details
variant="plain"
:aria-label="`Quality для ${item.name}`"
/>
</template>
<template #[`item.is_active`]="{ item }">
@@ -34,11 +37,15 @@
hide-details
inset
density="compact"
:aria-label="`Active для ${item.name}`"
/>
</template>
<template #[`item.actions`]="{ item }">
<v-btn size="small" color="primary" :loading="!!saving[item.id]" @click="save(item)">
<v-btn
size="small"
color="primary"
:loading="!!saving[item.id]"
@click="save(item)"
>
Сохранить
</v-btn>
</template>
@@ -15,7 +15,12 @@
*/
import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { MOCK_STATS, MOCK_TENANTS, type AdminTenant, type TenantStatus } from '../../composables/mockTenants';
import {
MOCK_STATS,
MOCK_TENANTS,
type AdminTenant,
type TenantStatus,
} from '../../composables/mockTenants';
import { mapApiAdminTenant } from '../../composables/adminTenantsMapper';
import { usePolling } from '../../composables/usePolling';
import * as adminApi from '../../api/admin';
@@ -78,7 +78,7 @@ async function handleSubmit() {
:error-messages="errors.email"
/>
<v-alert type="info" variant="tonal" density="compact" class="mb-2 a11y-info-darker">
<v-alert type="info" variant="tonal" density="compact" class="mb-2">
Лимит <strong>5 попыток в 15 минут</strong>. Если не пришло письмо проверьте спам или попробуйте
через 15 минут.
</v-alert>
@@ -121,11 +121,6 @@ async function handleSubmit() {
gap: 20px;
}
.a11y-info-darker :deep(.v-alert__content),
.a11y-info-darker :deep(.v-alert__content strong) {
color: #2a5a6e;
}
.forgot-header h1 {
font-variation-settings: 'opsz' 26;
letter-spacing: -0.018em;
@@ -50,7 +50,9 @@
{{ item.charge_source === 'prepaid' ? 'prepaid' : '₽' }}
</v-chip>
</template>
<template #[`item.price_rub`]="{ item }"> {{ (item.price_per_lead_kopecks / 100).toFixed(2) }} </template>
<template #[`item.price_rub`]="{ item }">
{{ (item.price_per_lead_kopecks / 100).toFixed(2) }}
</template>
</v-data-table-server>
</div>
</template>
@@ -23,7 +23,6 @@ import ErrorBrand from '../../components/errors/ErrorBrand.vue';
import ErrorIllustration from '../../components/errors/ErrorIllustration.vue';
import ErrorActions from '../../components/errors/ErrorActions.vue';
import ErrorMeta from '../../components/errors/ErrorMeta.vue';
import DevIndexBadge from '../../components/DevIndexBadge.vue';
interface ErrorAction {
label: string;
@@ -137,7 +136,6 @@ const config = computed<ErrorConfig>(() => {
/>
</div>
</v-main>
<DevIndexBadge :index="route.meta.devIndex" :label="route.meta.devLabel" />
</v-app>
</template>
@@ -1,17 +0,0 @@
<template>
<NewProjectDialog
:model-value="modelValue"
mode="edit"
:project="project"
@update:model-value="$emit('update:modelValue', $event)"
@saved="$emit('saved')"
/>
</template>
<script setup lang="ts">
import NewProjectDialog from './NewProjectDialog.vue';
import type { Project } from '../../stores/projectsStore';
defineProps<{ modelValue: boolean; project: Project | null }>();
defineEmits(['update:modelValue', 'saved']);
</script>
@@ -1,34 +0,0 @@
<template>
<Story title="NewProjectDialog">
<Variant title="Site tab (create mode)">
<NewProjectDialog v-model="open" mode="create" />
</Variant>
<Variant title="SMS tab (create mode)">
<NewProjectDialog v-model="open" mode="create" />
</Variant>
<Variant title="Edit mode (readonly signal_type)">
<NewProjectDialog v-model="open" mode="edit" :project="sampleProject" />
</Variant>
</Story>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import NewProjectDialog from './NewProjectDialog.vue';
const open = ref(true);
const sampleProject = {
id: 1,
name: 'Окна СПб',
signal_type: 'site' as const,
signal_identifier: 'okna.ru',
daily_limit_target: 50,
delivered_today: 12,
is_active: true,
archived_at: null,
sync_status: 'ok' as const,
region_mask: 0,
region_mode: 'include' as const,
delivery_days_mask: 127,
};
</script>
@@ -1,253 +0,0 @@
<template>
<v-dialog :model-value="modelValue" max-width="720" @update:model-value="$emit('update:modelValue', $event)">
<v-card style="position: relative">
<DevIndexBadge
:index="mode === 'edit' ? 19 : 18"
:label="mode === 'edit' ? 'EditProjectDialog' : 'NewProjectDialog'"
:dialog-mode="true"
style="top: 12px; right: 12px"
/>
<v-card-title>{{ mode === 'edit' ? 'Редактирование проекта' : 'Новый проект' }}</v-card-title>
<v-card-text>
<v-tabs v-model="form.signal_type" :disabled="mode === 'edit'" color="primary">
<v-tab value="site"><v-icon start>mdi-web</v-icon>Сайт</v-tab>
<v-tab value="call"><v-icon start>mdi-phone</v-icon>Звонок</v-tab>
<v-tab value="sms"><v-icon start>mdi-message-text</v-icon>СМС</v-tab>
</v-tabs>
<v-tabs-window v-model="form.signal_type" class="mt-4">
<v-tabs-window-item value="site">
<v-text-field
v-model="form.signal_identifier"
label="Домен конкурента"
placeholder="okna-konkurent.ru"
:readonly="mode === 'edit'"
class="ld-input-quiet"
:error-messages="errors.signal_identifier"
/>
</v-tabs-window-item>
<v-tabs-window-item value="call">
<v-text-field
v-model="form.signal_identifier"
label="Номер конкурента"
placeholder="79161234567"
hint="Формат: 11 цифр, начинаются с 7"
:readonly="mode === 'edit'"
class="ld-input-quiet"
:error-messages="errors.signal_identifier"
/>
</v-tabs-window-item>
<v-tabs-window-item value="sms">
<v-combobox
v-model="form.sms_senders"
label="Отправители (до 11 символов каждый)"
multiple
chips
clearable
:error-messages="errors.sms_senders"
/>
<v-text-field
v-model="form.sms_keyword"
label="Ключевое слово (опционально)"
hint="Если пусто — проект подключится только к B3"
class="ld-input-quiet"
:error-messages="errors.sms_keyword"
/>
</v-tabs-window-item>
</v-tabs-window>
<v-divider class="my-4" />
<v-text-field
v-model="form.name"
label="Название проекта"
class="ld-input-quiet"
:error-messages="errors.name"
/>
<v-text-field
v-model.number="form.daily_limit_target"
label="Лимит лидов в день"
type="number"
min="1"
max="10000"
class="ld-input-quiet"
:error-messages="errors.daily_limit_target"
/>
<v-autocomplete
v-model="selectedRegions"
:items="REGIONS"
item-title="name"
item-value="code"
label="Регионы (пусто = вся РФ)"
multiple
chips
clearable
/>
<div class="mt-3">
<span class="text-caption">Дни недели приёма</span>
<v-btn-toggle v-model="selectedDays" multiple density="comfortable" class="mt-1">
<v-btn v-for="(day, i) in dayLabels" :key="i" :value="i">{{ day }}</v-btn>
</v-btn-toggle>
<div class="mt-1">
<v-btn size="small" variant="text" @click="setWorkdays('weekdays')">Будни</v-btn>
<v-btn size="small" variant="text" @click="setWorkdays('all')">Все дни</v-btn>
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="close">Отмена</v-btn>
<v-btn color="primary" :loading="saving" data-testid="submit-btn" @click="submit">
{{ mode === 'edit' ? 'Сохранить' : 'Создать' }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue';
import axios from 'axios';
import { REGIONS } from '../../constants/regions';
import type { Project } from '../../stores/projectsStore';
import DevIndexBadge from '../../components/DevIndexBadge.vue';
const props = defineProps<{
modelValue: boolean;
mode?: 'create' | 'edit';
project?: Project | null;
}>();
const emit = defineEmits(['update:modelValue', 'saved']);
const form = reactive({
name: '',
signal_type: 'site' as 'site' | 'call' | 'sms',
signal_identifier: '',
sms_senders: [] as string[],
sms_keyword: '',
daily_limit_target: 50,
region_mask: 0,
region_mode: 'include' as 'include' | 'exclude',
delivery_days_mask: 127,
});
const errors = reactive<Record<string, string[]>>({});
const saving = ref(false);
const selectedRegions = ref<number[]>([]);
watch(selectedRegions, (codes) => {
if (codes.length === 0) {
form.region_mask = 0;
form.region_mode = 'include';
} else {
// 32-bit JS bitwise limit region codes >31 не помещаются в Int32 mask.
// На MVP покрываем 1-31 (см. constants/regions.ts); для >31 нужен bigint
// или array-колонка (Plan 6 schema delta).
form.region_mask = codes.reduce((acc, c) => (c <= 31 ? acc | (1 << c) : acc), 0);
form.region_mode = 'exclude';
}
});
const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
const selectedDays = ref<number[]>([0, 1, 2, 3, 4, 5, 6]);
watch(selectedDays, (days) => {
form.delivery_days_mask = days.reduce((acc, d) => acc | (1 << d), 0);
});
function setWorkdays(preset: 'weekdays' | 'all') {
if (preset === 'weekdays') selectedDays.value = [0, 1, 2, 3, 4];
else selectedDays.value = [0, 1, 2, 3, 4, 5, 6];
}
watch(
() => props.modelValue,
(open) => {
if (open && props.mode === 'edit' && props.project) {
Object.assign(form, props.project);
// TODO: разобрать region_mask обратно в codes (Plan 6 ).
selectedRegions.value = [];
const days: number[] = [];
for (let i = 0; i < 7; i++) if (form.delivery_days_mask & (1 << i)) days.push(i);
selectedDays.value = days;
} else if (open) {
Object.assign(form, {
name: '',
signal_type: 'site',
signal_identifier: '',
sms_senders: [],
sms_keyword: '',
daily_limit_target: 50,
region_mask: 0,
region_mode: 'include',
delivery_days_mask: 127,
});
selectedRegions.value = [];
selectedDays.value = [0, 1, 2, 3, 4, 5, 6];
}
},
);
async function submit() {
saving.value = true;
Object.keys(errors).forEach((k) => delete errors[k]);
try {
if (props.mode === 'edit' && props.project) {
await axios.patch(`/api/projects/${props.project.id}`, { ...form });
} else {
await axios.post('/api/projects', { ...form });
}
emit('saved');
close();
} catch (e: unknown) {
const err = e as { response?: { status?: number; data?: { errors?: Record<string, string[]> } } };
if (err.response?.status === 422 && err.response.data?.errors) {
Object.assign(errors, err.response.data.errors);
}
} finally {
saving.value = false;
}
}
function close() {
emit('update:modelValue', false);
}
</script>
<style scoped>
.ld-input-quiet :deep(.v-field) {
border-radius: var(--radius-8);
}
.ld-input-quiet :deep(.v-field__outline__start),
.ld-input-quiet :deep(.v-field__outline__end),
.ld-input-quiet :deep(.v-field__outline__notch::before),
.ld-input-quiet :deep(.v-field__outline__notch::after) {
border-color: var(--liderra-line);
opacity: 1;
transition: border-color 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
.ld-input-quiet :deep(.v-field:hover .v-field__outline__start),
.ld-input-quiet :deep(.v-field:hover .v-field__outline__end),
.ld-input-quiet :deep(.v-field:hover .v-field__outline__notch::before),
.ld-input-quiet :deep(.v-field:hover .v-field__outline__notch::after) {
border-color: var(--liderra-line-strong);
opacity: 1;
}
.ld-input-quiet :deep(.v-field--focused .v-field__outline__start),
.ld-input-quiet :deep(.v-field--focused .v-field__outline__end),
.ld-input-quiet :deep(.v-field--focused .v-field__outline__notch::before),
.ld-input-quiet :deep(.v-field--focused .v-field__outline__notch::after) {
border-color: var(--liderra-teal);
opacity: 1;
}
.ld-input-quiet :deep(.v-field--error:not(.v-field--disabled) .v-field__outline__start),
.ld-input-quiet :deep(.v-field--error:not(.v-field--disabled) .v-field__outline__end),
.ld-input-quiet :deep(.v-field--error:not(.v-field--disabled) .v-field__outline__notch::before),
.ld-input-quiet :deep(.v-field--error:not(.v-field--disabled) .v-field__outline__notch::after) {
border-color: currentColor;
opacity: 1;
}
</style>
+1 -27
View File
@@ -142,25 +142,9 @@ Route::post('/api/deals/restore', 'App\Http\Controllers\Api\DealBulkActionContro
// Lookup endpoints — заполняют v-select'ы (NewDealDialog, smart-filters).
Route::get('/api/managers', 'App\Http\Controllers\Api\ManagerController@index');
Route::get('/api/projects', 'App\Http\Controllers\Api\ProjectController@index');
Route::get('/api/lead-statuses', 'App\Http\Controllers\Api\LeadStatusController@index');
// Plan 5 Task 2: Projects CRUD — расширенный API с auth:sanctum + RLS.
// Заменяет старый GET /api/projects?tenant_id={id} (без auth, MVP-версия).
// ⚠️ NewDealDialog использовал старый endpoint (tenant_id param, без auth) —
// после этой замены получит 401. Defer fix до Task 7 (frontend phase).
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/projects')->group(function () {
Route::get('/', 'App\Http\Controllers\Api\ProjectController@index')->name('projects.index');
Route::post('/', 'App\Http\Controllers\Api\ProjectController@store')->name('projects.store');
// /bulk MUST be declared before /{id} parameterized routes so the literal
// segment matches before the regex placeholder is even considered.
Route::post('/bulk', 'App\Http\Controllers\Api\ProjectController@bulk')->name('projects.bulk');
Route::get('/{id}', 'App\Http\Controllers\Api\ProjectController@show')->name('projects.show')->where('id', '[0-9]+');
Route::patch('/{id}', 'App\Http\Controllers\Api\ProjectController@update')->name('projects.update')->where('id', '[0-9]+');
Route::delete('/{id}', 'App\Http\Controllers\Api\ProjectController@destroy')->name('projects.destroy')->where('id', '[0-9]+');
Route::post('/{id}/sync', 'App\Http\Controllers\Api\ProjectController@sync')->name('projects.sync')->where('id', '[0-9]+');
Route::patch('/{id}/toggle-active', 'App\Http\Controllers\Api\ProjectController@toggleActive')->name('projects.toggle')->where('id', '[0-9]+');
});
// Receive endpoint для входящих webhook'ов (narrative §5.5).
// Auth — по `tenants.webhook_token` в URL (без middleware, проверка внутри controller).
// На prod: + HMAC-валидация X-Webhook-Signature + per-token rate-limit.
@@ -199,19 +183,9 @@ Route::view('/recovery-use', 'welcome');
Route::view('/dashboard', 'welcome');
Route::view('/deals', 'welcome');
Route::view('/kanban', 'welcome');
Route::view('/projects', 'welcome');
Route::view('/billing', 'welcome');
Route::view('/settings', 'welcome');
Route::view('/reports', 'welcome');
Route::view('/reminders', 'welcome');
Route::view('/admin', 'welcome');
Route::view('/admin/tenants', 'welcome');
Route::view('/admin/billing', 'welcome');
Route::view('/admin/incidents', 'welcome');
Route::view('/admin/system', 'welcome');
Route::view('/admin/pricing-tiers', 'welcome');
Route::view('/admin/supplier-prices', 'welcome');
Route::view('/admin/impersonation', 'welcome');
Route::view('/403', 'welcome');
Route::view('/500', 'welcome');
-45
View File
@@ -1,45 +0,0 @@
import { readFileSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const MANIFEST = resolve(__dirname, '..', 'dev-indices.json');
function usage() {
console.error('Usage: npm run dx <id>');
console.error('Looks up element by dev-index in dev-indices.json.');
process.exit(2);
}
const arg = process.argv[2];
if (!arg || !/^\d+$/.test(arg)) usage();
const id = arg;
if (!existsSync(MANIFEST)) {
console.error(`dev-indices.json not found at ${MANIFEST}`);
console.error('Run "npm run dev" first to generate the manifest.');
process.exit(1);
}
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'));
if (manifest.entries?.[id]) {
const e = manifest.entries[id];
console.log(`#${id}${e.file}:${e.line}`);
console.log(`${e.tag}${e.text ? ` "${e.text}"` : ''}`);
console.log(`parent: ${e.parentChain.join(' > ')}`);
console.log(`signature: ${e.signature}`);
console.log(`created: ${e.createdAt.split('T')[0]}`);
process.exit(0);
}
if (manifest.deleted?.[id]) {
const d = manifest.deleted[id];
console.log(`#${id} (DELETED at ${d.deletedAt.split('T')[0]})`);
console.log(`last seen in ${d.lastFile}`);
console.log(`last signature: ${d.lastSignature}`);
process.exit(0);
}
console.error(`#${id} not found in manifest.`);
process.exit(1);
@@ -1,239 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
it('accepts update_regions action with add/remove bitmask', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$p = Project::factory()->for($tenant)->create(['region_mask' => 1]);
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'update_regions',
'ids' => [$p->id],
'add' => 6, // биты 2+4 = Северо-Западный + Южный
'remove' => 1, // бит 1 = Центральный
])
->assertOk()
->assertJsonStructure(['updated', 'skipped', 'warnings']);
});
it('rejects unknown action', function () {
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'nuke_everything',
'ids' => [1],
])
->assertStatus(422)
->assertJsonValidationErrors(['action']);
});
it('rejects update_limit with both delta and replace', function () {
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'update_limit',
'ids' => [1],
'delta' => 50,
'replace' => 500,
])
->assertStatus(422);
});
it('rejects empty ids without scope', function () {
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'pause',
])
->assertStatus(422);
});
it('accepts empty scope.filter as valid scope (all projects)', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'pause',
'scope' => ['filter' => []],
])
->assertOk();
});
it('applies update_regions add and remove correctly', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$p1 = Project::factory()->for($tenant)->create(['region_mask' => 3]); // 1+2
$p2 = Project::factory()->for($tenant)->create(['region_mask' => 5]); // 1+4
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'update_regions',
'ids' => [$p1->id, $p2->id],
'add' => 16, // 16 = Приволжский
'remove' => 1, // 1 = Центральный
])
->assertOk()
->assertJson(['updated' => 2, 'skipped' => [], 'warnings' => []]);
expect($p1->fresh()->region_mask)->toBe((3 | 16) & ~1); // = 18
expect($p2->fresh()->region_mask)->toBe((5 | 16) & ~1); // = 20
});
it('applies update_days add and remove correctly', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$p = Project::factory()->for($tenant)->create(['delivery_days_mask' => 31]); // Пн-Пт
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'update_days',
'ids' => [$p->id],
'add' => 96, // 32+64 = Сб+Вс
'remove' => 1, // Пн
])
->assertOk()
->assertJson(['updated' => 1, 'skipped' => [], 'warnings' => []]);
expect($p->fresh()->delivery_days_mask)->toBe((31 | 96) & ~1); // = 126
});
it('applies update_limit delta to all projects', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$p1 = Project::factory()->for($tenant)->create(['daily_limit_target' => 100, 'delivered_today' => 0]);
$p2 = Project::factory()->for($tenant)->create(['daily_limit_target' => 200, 'delivered_today' => 0]);
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'update_limit',
'ids' => [$p1->id, $p2->id],
'delta' => 50,
])
->assertOk()
->assertJson(['updated' => 2, 'skipped' => [], 'warnings' => []]);
expect($p1->fresh()->daily_limit_target)->toBe(150);
expect($p2->fresh()->daily_limit_target)->toBe(250);
});
it('skips projects when limit delta would drop below delivered_today', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$p1 = Project::factory()->for($tenant)->create(['daily_limit_target' => 100, 'delivered_today' => 80]);
$p2 = Project::factory()->for($tenant)->create(['daily_limit_target' => 50, 'delivered_today' => 30]);
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'update_limit',
'ids' => [$p1->id, $p2->id],
'delta' => -40, // p1: 100-40=60 < 80 → SKIP; p2: 50-40=10 < 30 → SKIP
])
->assertOk()
->assertJson([
'updated' => 0,
'skipped' => [
['id' => $p1->id, 'reason' => 'below_delivered_today'],
['id' => $p2->id, 'reason' => 'below_delivered_today'],
],
]);
expect($p1->fresh()->daily_limit_target)->toBe(100); // unchanged
expect($p2->fresh()->daily_limit_target)->toBe(50);
});
it('applies update_limit replace with skip for conflicts', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$p1 = Project::factory()->for($tenant)->create(['daily_limit_target' => 100, 'delivered_today' => 30]);
$p2 = Project::factory()->for($tenant)->create(['daily_limit_target' => 200, 'delivered_today' => 150]);
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'update_limit',
'ids' => [$p1->id, $p2->id],
'replace' => 100, // p1: ok (100>=30); p2: 100<150 → skip
])
->assertOk()
->assertJson([
'updated' => 1,
'skipped' => [['id' => $p2->id, 'reason' => 'below_delivered_today']],
]);
expect($p1->fresh()->daily_limit_target)->toBe(100);
expect($p2->fresh()->daily_limit_target)->toBe(200);
});
it('resolves scope.filter to project ids and applies action', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
Project::factory()->for($tenant)->asSiteSignal('example.com')->count(3)->create(['is_active' => true]);
Project::factory()->for($tenant)->asSmsSignal(['SENDER'])->count(2)->create(['is_active' => true]);
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'pause',
'scope' => ['filter' => ['signal_type' => 'site']],
])
->assertOk()
->assertJson(['updated' => 3]);
expect(Project::where('tenant_id', $tenant->id)->where('signal_type', 'site')->where('is_active', false)->count())->toBe(3);
expect(Project::where('tenant_id', $tenant->id)->where('signal_type', 'sms')->where('is_active', true)->count())->toBe(2);
});
it('rejects bulk when scope.filter captures more than 500 projects', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
Project::factory()->for($tenant)->count(501)->create();
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'pause',
'scope' => ['filter' => []],
])
->assertStatus(422)
->assertJsonValidationErrors(['scope']);
});
it('does not affect projects of other tenants (RLS)', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
$userA = User::factory()->for($tenantA)->create();
$pA = Project::factory()->for($tenantA)->create(['is_active' => true]);
$pB = Project::factory()->for($tenantB)->create(['is_active' => true]);
$this->actingAs($userA)
->postJson('/api/projects/bulk', [
'action' => 'pause',
'ids' => [$pA->id, $pB->id],
])
->assertOk()
->assertJson(['updated' => 1]); // Only tenant A's project
expect($pA->fresh()->is_active)->toBeFalse();
expect($pB->fresh()->is_active)->toBeTrue(); // unchanged
});
it('returns 0 updated when ids empty after filter resolution', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'pause',
'scope' => ['filter' => ['signal_type' => 'site']], // no projects exist
])
->assertOk()
->assertJson(['updated' => 0]);
});
+19
View File
@@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
@@ -59,6 +60,24 @@ test('GET /api/managers 404 unknown tenant', function () {
$r->assertStatus(404);
});
test('GET /api/projects возвращает active projects тенанта', function () {
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
Project::create([
'tenant_id' => $this->tenant->id,
'name' => 'Окна Москва', 'is_active' => true,
]);
Project::create([
'tenant_id' => $this->tenant->id,
'name' => 'Архивный', 'is_active' => false,
]);
$r = $this->getJson('/api/projects?tenant_id='.$this->tenant->id);
$r->assertStatus(200);
$projects = $r->json('projects');
expect($projects)->toHaveCount(1);
expect($projects[0]['name'])->toBe('Окна Москва');
});
test('POST /api/deals 422 если manager_id не принадлежит tenant\'у', function () {
$otherTenant = Tenant::factory()->create();
DB::statement('SET app.current_tenant_id = '.$otherTenant->id);
@@ -1,170 +0,0 @@
<?php
declare(strict_types=1);
use App\Jobs\SyncSupplierProjectJob;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Foundation\Testing\DatabaseTransactions;
// TestCase auto-bound via tests/Pest.php (->in('Feature')).
// DatabaseTransactions — per-test isolation.
uses(DatabaseTransactions::class);
/**
* Хелпер: разрешает мок SupplierPortalClient из контейнера и вызывает Job.handle().
* Нельзя использовать (new Job)->handle() без аргументов handle() требует DI-инъекцию
* SupplierPortalClient; прямой вызов без аргументов обходит контейнер и мок не применяется.
*/
function dispatchJobSync(SyncSupplierProjectJob $job): void
{
$client = app(SupplierPortalClient::class);
$job->handle($client);
}
it('site project: links B1+B2+B3 supplier_projects and sets all three IDs', function () {
$tenant = Tenant::factory()->create();
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'okna.ru',
]);
$this->mock(SupplierPortalClient::class, function ($mock) {
$mock->shouldReceive('ensureSupplierProject')->times(3)
->andReturnUsing(fn (string $platform, string $signalType, string $key) => SupplierProject::factory()->create([
'platform' => $platform, // uppercase: B1, B2, B3
'signal_type' => $signalType,
'unique_key' => $key,
'sync_status' => 'ok',
])->id
);
});
dispatchJobSync(new SyncSupplierProjectJob($project->id));
$project->refresh();
expect($project->supplier_b1_project_id)->not->toBeNull();
expect($project->supplier_b2_project_id)->not->toBeNull();
expect($project->supplier_b3_project_id)->not->toBeNull();
});
it('call project: links B1+B2+B3 with phone signal_identifier', function () {
$project = Project::factory()->create([
'signal_type' => 'call',
'signal_identifier' => '79161234567',
]);
$this->mock(SupplierPortalClient::class, function ($mock) {
$mock->shouldReceive('ensureSupplierProject')->times(3)
->andReturn(SupplierProject::factory()->create([
'platform' => 'B1',
'signal_type' => 'call',
'sync_status' => 'ok',
])->id);
});
dispatchJobSync(new SyncSupplierProjectJob($project->id));
expect($project->fresh()->supplier_b1_project_id)->not->toBeNull();
expect($project->fresh()->supplier_b2_project_id)->not->toBeNull();
expect($project->fresh()->supplier_b3_project_id)->not->toBeNull();
});
it('sms project with keyword: links B2+B3 only (no B1)', function () {
$project = Project::factory()->create([
'signal_type' => 'sms',
'sms_senders' => ['TINKOFF'],
'sms_keyword' => 'ипотека',
]);
$this->mock(SupplierPortalClient::class, function ($mock) {
$mock->shouldReceive('ensureSupplierProject')->times(2)
->andReturnUsing(fn (string $platform) => SupplierProject::factory()->create([
'platform' => $platform, // B2 or B3 — both pass CHECK constraint
'signal_type' => 'sms',
'sync_status' => 'ok',
])->id
);
});
dispatchJobSync(new SyncSupplierProjectJob($project->id));
$project->refresh();
expect($project->supplier_b1_project_id)->toBeNull();
expect($project->supplier_b2_project_id)->not->toBeNull();
expect($project->supplier_b3_project_id)->not->toBeNull();
});
it('sms project without keyword: links B3 only', function () {
$project = Project::factory()->create([
'signal_type' => 'sms',
'sms_senders' => ['TINKOFF'],
'sms_keyword' => null,
]);
$this->mock(SupplierPortalClient::class, function ($mock) {
$mock->shouldReceive('ensureSupplierProject')->once()
->andReturn(SupplierProject::factory()->create([
'platform' => 'B3',
'signal_type' => 'sms',
'sync_status' => 'ok',
])->id);
});
dispatchJobSync(new SyncSupplierProjectJob($project->id));
$project->refresh();
expect($project->supplier_b1_project_id)->toBeNull();
expect($project->supplier_b2_project_id)->toBeNull();
expect($project->supplier_b3_project_id)->not->toBeNull();
});
it('portal exception: re-throws for queue retry', function () {
$project = Project::factory()->create([
'signal_type' => 'site',
'signal_identifier' => 'x.ru',
]);
$this->mock(SupplierPortalClient::class, function ($mock) {
$mock->shouldReceive('ensureSupplierProject')
->andThrow(new RuntimeException('timeout'));
});
expect(fn () => dispatchJobSync(new SyncSupplierProjectJob($project->id)))
->toThrow(RuntimeException::class);
});
it('partial success: B1=ok, B2=failed (pre-created row), B3=ok — all three IDs written', function () {
$project = Project::factory()->create([
'signal_type' => 'site',
'signal_identifier' => 'x.ru',
]);
// Pre-create a supplier_project row for B2 with sync_status='failed' —
// the mock returns its ID to simulate a failed B2 sync.
// NOTE: supplier_projects has NO last_error column (schema v8.19);
// "failed" status alone is the observable signal.
$spB2 = SupplierProject::factory()->create([
'platform' => 'B2',
'signal_type' => 'site',
'unique_key' => 'x.ru',
'sync_status' => 'failed',
]);
$this->mock(SupplierPortalClient::class, function ($mock) use ($spB2) {
$spB1 = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site', 'sync_status' => 'ok'])->id;
$spB3 = SupplierProject::factory()->create(['platform' => 'B3', 'signal_type' => 'site', 'sync_status' => 'ok'])->id;
$mock->shouldReceive('ensureSupplierProject')->andReturn($spB1, $spB2->id, $spB3);
});
dispatchJobSync(new SyncSupplierProjectJob($project->id));
$project->refresh();
expect($project->supplier_b2_project_id)->not->toBeNull();
expect(SupplierProject::find($project->supplier_b2_project_id)->sync_status)->toBe('failed');
expect($project->supplier_b1_project_id)->not->toBeNull();
expect($project->supplier_b3_project_id)->not->toBeNull();
});

Some files were not shown because too many files have changed in this diff Show More