Merge plan5-frontend-projects → main
Объединяет 120 commits работы 12.05–13.05.2026 (day +1): — Plan 5 frontend Tasks 7-11 (ProjectController 8 endpoints + schema v8.20) — Quiet Luxury portal redesign (20 commits Direction A) — Dev Element Indices (temporary feedback feature) — Portal full audit 2026-05-12 (14 audit commits + 5 post-audit) — Q.DEFER.002 sub-B / Q.DEFER.003 sub-A+B+C / Q.DEFER.004 sub-A+B closures — Audit-cleanup tail (5 commits) — R15 motion-runtime cleanup merge `323957a` — Registry catch-up v1.77 → v1.82 (commit `9bc0419`) — CTO-19 ✅ closed via Lucide migration (commits `0832997` + `f6e1e64`) — Session-end documentation hygiene (commit `19d12c9`): CLAUDE.md v1.91 / Pravila v1.12 / audit findings.md SAST gap note Регрессия зелёная (verified pre-merge 13.05.2026 day +1 05:49): — Pest --parallel --recreate-databases 742/739/0/3 — Vitest 88 files / 683 passed / 3 skipped — Vite build 3.52s, axe-core 0 iconography violations — lychee 252 OK, gitleaks 0 (373+ commits) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -139,3 +139,9 @@ 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/
|
||||
|
||||
@@ -87,6 +87,12 @@ 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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CLAUDE.md — техконтекст Лидерры
|
||||
|
||||
**Версия:** 1.88 от 12.05.2026 — снятие 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 (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); **§0 cross-refs** обновлены — Pravila v1.10 → v1.11, PSR_v1 v1.7 → v2.0, Tooling v1.15 → v1.16. Связано: 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 — sync schema-метрик после Plan 4 (Billing+CSV+Admin). Schema **v8.11 → v8.19** (накопленный drift от Plans 1+2+3+4): 62 базовых таблиц, 117 индексов, 39 RLS + 5 функций / 13 триггеров. Через `/claude-md-management:revise-claude-md`. Предыдущая v1.86 — закрытие 13 находок третьего аудита (детали в [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.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)).
|
||||
**Назначение:** оперативная карта для 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.11 от 12.05.2026** — sync после PSR_v1 v2.0 (R15 снят): §11.5/§13.2 счётчик «16 правил R0–R15» → «15 правил R0–R14»; §13.9/§13.10 cross-ref «v1.6» → «v2.0»; §13.10 НЕ удалено — оно про R14, не R15; v1.10 наследие — §0 +note про §11 локальное override-исключение, §11.5/§13.2/§13.9/§13.10 sync bumps) |
|
||||
| Продуктовые правила работы 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 правил R0–R14; §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.1–R15.7), R0.6 п.11, R8 motion тай-брейкеры (3), R11.6 motion иерархия, R13 motion-сценарии (5). Шапка count: «16 правил R0–R15» → «15 правил R0–R14». 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 формализованные позиции») |
|
||||
| Главное ТЗ | [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. Метрики: **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) |
|
||||
| Схема БД | [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) |
|
||||
| **Брендбук** | [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 (**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) |
|
||||
| БД | 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). |
|
||||
| Кэш / очереди | Redis 7 |
|
||||
| Pooler | PgBouncer (transaction pooling) |
|
||||
| Облако | Yandex Cloud, регион `ru-central1` (Москва) |
|
||||
@@ -205,7 +205,8 @@ 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 + #20–24 + **#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) на ветке `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).
|
||||
- **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`.
|
||||
- Готово в фазе 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`.
|
||||
@@ -241,7 +242,7 @@ trivy image liderra:latest
|
||||
|
||||
| Файл | Что проверять |
|
||||
|---|---|
|
||||
| `db/schema.sql` | 0 orphan-FK, целостность RLS, метрики сверять с текущей версией (**v8.19** = 62 базовые таблицы + 12 партиций + 117 индексов + 39 RLS-политик + 5 функций + 13 триггеров), 0 дубликатов `CREATE TABLE` |
|
||||
| `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` |
|
||||
| narrative `.md` | Версии в шапке/колонтитуле, 0 «готовится»/«TBD», кросс-ссылки на актуальные имена файлов |
|
||||
| Прил. А–Н | Версия совпадает с narrative; все упомянутые подразделы существуют |
|
||||
| Прил. Н (этот реестр инструментов) | Ровно 29 в активном наборе; 0 дублей; синхронность с этим CLAUDE.md |
|
||||
@@ -254,8 +255,11 @@ trivy image liderra:latest
|
||||
|
||||
Полная история — [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md) (вынесена 09.05.2026 при правке v1.73→v1.74 ради лаконичности шапки). Здесь — последние правки:
|
||||
|
||||
- **v1.88 от 12.05.2026** — снятие 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.
|
||||
|
||||
- **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,4 +1,5 @@
|
||||
*.log
|
||||
.backups/
|
||||
.DS_Store
|
||||
.env
|
||||
.env.backup
|
||||
|
||||
@@ -5,48 +5,160 @@ 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\Models\Tenant;
|
||||
use App\Services\Project\ProjectService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Проекты tenant'а — для NewDealDialog dropdown'а и DealsView/Smart-filters.
|
||||
* Проекты tenant'а — расширенный API для ProjectsView + NewDealDialog.
|
||||
*
|
||||
* На MVP: tenant_id параметром. На prod: middleware('auth:sanctum')+'tenant'.
|
||||
* 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).
|
||||
*/
|
||||
class ProjectController extends Controller
|
||||
{
|
||||
/** GET /api/projects?tenant_id={id} */
|
||||
public function __construct(private readonly ProjectService $projects) {}
|
||||
|
||||
/** GET /api/projects */
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->query('tenant_id', '0');
|
||||
if ($tenantId < 1) {
|
||||
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
|
||||
$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)]);
|
||||
}
|
||||
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if ($tenant === null) {
|
||||
return response()->json(['message' => 'Тенант не найден.'], 404);
|
||||
// Фильтр по типу сигнала
|
||||
if ($type = $request->query('signal_type')) {
|
||||
$query->where('signal_type', $type);
|
||||
}
|
||||
|
||||
$projects = DB::transaction(function () use ($tenantId) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
// Фильтр по статусу жизненного цикла
|
||||
$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();
|
||||
}
|
||||
|
||||
return Project::query()
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'tag', 'type']);
|
||||
});
|
||||
// Поиск по 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 response()->json([
|
||||
'projects' => $projects->map(fn (Project $p) => [
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'tag' => $p->tag,
|
||||
'type' => $p->type,
|
||||
]),
|
||||
'data' => ProjectResource::collection($projects->items()),
|
||||
'meta' => [
|
||||
'current_page' => $projects->currentPage(),
|
||||
'per_page' => $projects->perPage(),
|
||||
'total' => $projects->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?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.');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?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(),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,13 @@ 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;
|
||||
|
||||
/**
|
||||
* Проект (лид-канал) внутри тенанта.
|
||||
@@ -36,6 +38,8 @@ 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',
|
||||
@@ -74,6 +78,8 @@ 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',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -126,4 +132,113 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ class Tenant extends Model
|
||||
'desired_daily_numbers',
|
||||
'delivered_in_month',
|
||||
'api_key_limit',
|
||||
'limits',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -57,6 +58,8 @@ 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',
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
<?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 (0–255)
|
||||
$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,6 +8,7 @@ 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;
|
||||
@@ -29,12 +30,66 @@ use Illuminate\Support\Facades\Cache;
|
||||
* Авторизация: PHPSESSID cookie + X-CSRF-Token header (Redis cache 'supplier:session').
|
||||
* На 401/403 — single retry через dispatch_sync(RefreshSupplierSessionJob).
|
||||
*/
|
||||
final class SupplierPortalClient
|
||||
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>
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
<?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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"$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
|
||||
}
|
||||
Generated
+12
@@ -4,6 +4,9 @@
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"lucide-vue-next": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@histoire/plugin-vue": "^1.0.0-beta.1",
|
||||
@@ -6967,6 +6970,15 @@
|
||||
"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",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"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"
|
||||
@@ -45,5 +46,8 @@
|
||||
"vue-tsc": "^3.2.8",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vuetify": "^3.12.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-vue-next": "^1.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,12 @@ 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
|
||||
@@ -96,6 +102,18 @@ 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
|
||||
@@ -228,6 +246,12 @@ 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
|
||||
@@ -789,13 +813,13 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 20
|
||||
count: 16
|
||||
path: tests/Feature/LookupsTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 5
|
||||
count: 4
|
||||
path: tests/Feature/LookupsTest.php
|
||||
|
||||
-
|
||||
@@ -852,6 +876,42 @@ 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
|
||||
|
||||
@@ -23,3 +23,14 @@ 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/* 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);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -2,6 +2,9 @@ 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).
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* Источник дизайна: liderra_v8_handoff/concepts/v8_login.html (auth),
|
||||
* v8_dashboard.html (app), v8_errors.html (error).
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
import { computed, defineAsyncComponent, type Component } from 'vue';
|
||||
import { RouterView, useRoute } from 'vue-router';
|
||||
import AdminLayout from '../layouts/AdminLayout.vue';
|
||||
import AppLayout from '../layouts/AppLayout.vue';
|
||||
@@ -17,6 +17,11 @@ 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>
|
||||
@@ -24,4 +29,5 @@ const layoutName = computed(() => route.meta.layout ?? 'app');
|
||||
<RouterView v-else-if="layoutName === 'error'" />
|
||||
<AdminLayout v-else-if="layoutName === 'admin'" />
|
||||
<AppLayout v-else />
|
||||
<component :is="DevIndexOverlay" v-if="DevIndexOverlay" />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<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>
|
||||
@@ -0,0 +1,221 @@
|
||||
<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,7 +57,10 @@ 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,16 +26,25 @@ 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,6 +86,7 @@ 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,17 +4,8 @@
|
||||
* (Все / Пополнения / Списания / Возвраты). 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');
|
||||
|
||||
@@ -102,7 +93,7 @@ const filteredTransactions = computed<BillingTransaction[]>(() => {
|
||||
color: #66635c;
|
||||
}
|
||||
.tx-amount-up {
|
||||
color: #2e8b57;
|
||||
color: #1b6e3b;
|
||||
}
|
||||
.tx-amount-down {
|
||||
color: #b83a3a;
|
||||
|
||||
@@ -29,10 +29,7 @@ defineProps<{
|
||||
<span class="ru"> ₽</span>
|
||||
</div>
|
||||
<div class="runway mt-3">
|
||||
<div
|
||||
class="runway-bar"
|
||||
:aria-label="`Хватит на ${balance.runwayDays} дня из ${balance.runwayMax}`"
|
||||
>
|
||||
<div class="runway-bar" role="img" :aria-label="`Хватит на ${balance.runwayDays} дня из ${balance.runwayMax}`">
|
||||
<span
|
||||
v-for="i in balance.runwayMax"
|
||||
:key="i"
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* DashboardKpiRow — 3 KPI-карты (получено лидов / конверсия / активные проекты).
|
||||
* Numerics через JetBrains Mono с tabular-nums.
|
||||
* Numerics через JetBrains Mono с tabular-nums + count-up анимация (motion #1).
|
||||
*
|
||||
* 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;
|
||||
@@ -13,17 +18,85 @@ export interface Kpi {
|
||||
sub: string;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
const props = 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 in kpis" :key="kpi.label" cols="12" sm="6" md="3">
|
||||
<v-col v-for="(kpi, idx) in kpis" :key="kpi.label" cols="12" sm="6" md="3">
|
||||
<v-card variant="outlined" class="kpi-card pa-4">
|
||||
<div class="kpi-label text-body-2 text-medium-emphasis">{{ kpi.label }}</div>
|
||||
<div class="kpi-value">
|
||||
{{ kpi.value }}
|
||||
<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) }}
|
||||
<span v-if="kpi.unit" class="kpi-unit">{{ kpi.unit }}</span>
|
||||
</div>
|
||||
<div class="kpi-foot text-caption text-medium-emphasis mt-2">
|
||||
@@ -89,7 +162,7 @@ defineProps<{
|
||||
font-weight: 500;
|
||||
}
|
||||
.delta-up {
|
||||
color: #2e8b57;
|
||||
color: #1b6e3b;
|
||||
}
|
||||
.delta-down {
|
||||
color: #b83a3a;
|
||||
|
||||
@@ -29,13 +29,7 @@ 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>
|
||||
@@ -47,11 +41,7 @@ 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,12 +16,18 @@
|
||||
*/
|
||||
import type { MockDeal } from '../../composables/mockDeals';
|
||||
import type { LeadStatus } from '../../composables/leadStatuses';
|
||||
import StatusPill from '../ui/StatusPill.vue';
|
||||
|
||||
defineProps<{
|
||||
deals: MockDeal[];
|
||||
selectedIds: number[];
|
||||
statusBySlug: Map<string, LeadStatus>;
|
||||
}>();
|
||||
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 },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selectedIds': [value: number[]];
|
||||
@@ -61,7 +67,8 @@ function formatCost(cost: number): string {
|
||||
items-per-page="-1"
|
||||
hide-default-footer
|
||||
hover
|
||||
density="comfortable"
|
||||
:density="rowHeight && rowHeight < 40 ? 'compact' : 'comfortable'"
|
||||
:row-props="() => ({ class: 'ld-hover-lift ld-stagger-row', style: { height: rowHeight + 'px' } })"
|
||||
@update:model-value="onSelectedUpdate"
|
||||
@click:row="(_e: Event, { item }: { item: MockDeal }) => emit('row-click', item)"
|
||||
>
|
||||
@@ -85,23 +92,18 @@ function formatCost(cost: number): string {
|
||||
</v-avatar>
|
||||
<div>
|
||||
<div class="deal-name">{{ item.name }}</div>
|
||||
<div class="deal-phone text-caption text-medium-emphasis">{{ item.phone }}</div>
|
||||
<div class="deal-phone text-caption text-medium-emphasis ld-mono-s">{{ item.phone }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #[`item.statusSlug`]="{ item }: { item: MockDeal }">
|
||||
<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>
|
||||
<!-- Task 15: StatusPill заменяет v-chip + ручной dot. Label fallback на slug
|
||||
если nameRu отсутствует (leadStatuses store ещё не загружен). -->
|
||||
<StatusPill
|
||||
:slug="item.statusSlug"
|
||||
:label="statusBySlug.get(item.statusSlug)?.nameRu ?? item.statusSlug"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #[`item.manager`]="{ item }: { item: MockDeal }">
|
||||
@@ -114,11 +116,28 @@ function formatCost(cost: number): string {
|
||||
</template>
|
||||
|
||||
<template #[`item.cost`]="{ item }: { item: MockDeal }">
|
||||
<span class="num">{{ formatCost(item.cost) }}</span>
|
||||
<span class="num ld-mono">{{ formatCost(item.cost) }}</span>
|
||||
</template>
|
||||
|
||||
<template #[`item.receivedMinutesAgo`]="{ item }: { item: MockDeal }">
|
||||
<span class="num text-medium-emphasis">{{ formatRelative(item.receivedMinutesAgo) }}</span>
|
||||
<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)"
|
||||
/>
|
||||
</template>
|
||||
</v-data-table>
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ 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="text-primary">support@liderra.app</a>
|
||||
<a href="mailto:support@liderra.app" class="err-help__link">support@liderra.app</a>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
@@ -99,4 +99,11 @@ 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,7 +18,12 @@ function formatCost(cost: number): string {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card variant="outlined" class="kanban-card pa-3 mb-2" density="compact" @click="emit('open', deal.id)">
|
||||
<v-card
|
||||
variant="outlined"
|
||||
class="kanban-card ld-hover-lift 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">{{ status.nameRu }}</span>
|
||||
<span class="column-name ld-label">{{ status.nameRu }}</span>
|
||||
<span class="column-count">{{ deals.length }}</span>
|
||||
</div>
|
||||
<div class="column-total">{{ formatTotal(total) }}</div>
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
<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 для «Напоминания» — живой из remindersStore; «Сделки»/«Менеджеры» — mock.
|
||||
* Counts для «Сделки» — mock.
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useRemindersStore } from '../../stores/reminders';
|
||||
import Kbd from '../ui/Kbd.vue';
|
||||
|
||||
interface NavItem {
|
||||
title: string;
|
||||
icon: string;
|
||||
to: string;
|
||||
countKey?: 'deals' | 'reminders' | 'managers';
|
||||
count?: number;
|
||||
countKey?: string;
|
||||
}
|
||||
interface NavGroup {
|
||||
eyebrow: string;
|
||||
@@ -23,22 +26,15 @@ interface NavGroup {
|
||||
const drawerOpen = defineModel<boolean>('drawerOpen', { default: true });
|
||||
|
||||
const route = useRoute();
|
||||
const reminders = useRemindersStore();
|
||||
|
||||
const navGroups = computed<NavGroup[]>(() => [
|
||||
{
|
||||
eyebrow: 'Работа',
|
||||
items: [
|
||||
{ title: 'Дашборд', icon: 'mdi-view-dashboard-outline', to: '/dashboard' },
|
||||
{ title: 'Проекты', icon: 'mdi-folder-multiple-outline', to: '/projects' },
|
||||
{ title: 'Сделки', icon: 'mdi-format-list-bulleted', to: '/deals', count: 247 },
|
||||
{ title: 'Канбан', icon: 'mdi-view-column-outline', to: '/kanban' },
|
||||
{
|
||||
title: 'Напоминания',
|
||||
icon: 'mdi-clock-outline',
|
||||
to: '/reminders',
|
||||
countKey: 'reminders',
|
||||
count: reminders.counts.active,
|
||||
},
|
||||
{ title: 'Дашборд', icon: 'mdi-view-dashboard-outline', to: '/dashboard' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -50,106 +46,174 @@ const navGroups = computed<NavGroup[]>(() => [
|
||||
},
|
||||
{
|
||||
eyebrow: 'Команда',
|
||||
items: [
|
||||
{ title: 'Менеджеры', icon: 'mdi-account-group-outline', to: '/managers', count: 4 },
|
||||
{ title: 'Настройки', icon: 'mdi-cog-outline', to: '/settings' },
|
||||
],
|
||||
items: [{ title: 'Настройки', icon: 'mdi-cog-outline', to: '/settings' }],
|
||||
},
|
||||
]);
|
||||
|
||||
function resolveCount(item: NavItem): number {
|
||||
return item.count ?? 0;
|
||||
}
|
||||
|
||||
defineExpose({ navGroups });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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
|
||||
<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-for="item in group.items"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
:prepend-icon="item.icon"
|
||||
:active="route.path === item.to"
|
||||
rounded="lg"
|
||||
class="nav-item"
|
||||
class="ld-nav-item"
|
||||
:class="{ 'ld-nav-item--active': route.path === item.to }"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-drawer {
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||||
.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;
|
||||
}
|
||||
.brand-block {
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 18px 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
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;
|
||||
}
|
||||
.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:hover {
|
||||
color: #e8e2d4;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.brand-text {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
letter-spacing: -0.01em;
|
||||
color: #fff;
|
||||
.ld-nav-item--active {
|
||||
color: var(--liderra-ivory);
|
||||
background: rgba(15, 110, 86, 0.22);
|
||||
}
|
||||
.brand-dot {
|
||||
color: #32c8a9;
|
||||
.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);
|
||||
}
|
||||
.nav-eyebrow {
|
||||
font-size: 11px !important;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #7a8c87 !important;
|
||||
@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;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
padding-top: 16px !important;
|
||||
font-feature-settings: 'tnum' 1;
|
||||
}
|
||||
.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;
|
||||
.ld-nav-item--active .ld-nav-item__badge {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--liderra-ivory);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -90,8 +90,6 @@ 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>
|
||||
|
||||
@@ -168,13 +166,7 @@ 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>
|
||||
@@ -193,7 +185,16 @@ async function handleLogout(): Promise<void> {
|
||||
|
||||
<style scoped>
|
||||
.app-topbar {
|
||||
border-bottom: 1px solid #d9d5cd !important;
|
||||
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;
|
||||
}
|
||||
.crumb {
|
||||
display: flex;
|
||||
@@ -201,20 +202,30 @@ 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 #d9d5cd;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 3px;
|
||||
background: #f0ede4;
|
||||
color: #66635c;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #9b9484;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.user-chip :deep(.v-btn__content) {
|
||||
color: #e8e2d4;
|
||||
}
|
||||
.notification-pip {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<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>
|
||||
@@ -0,0 +1,108 @@
|
||||
<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>
|
||||
@@ -0,0 +1,13 @@
|
||||
<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>
|
||||
@@ -0,0 +1,93 @@
|
||||
<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>
|
||||
@@ -0,0 +1,13 @@
|
||||
<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>
|
||||
@@ -0,0 +1,107 @@
|
||||
<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>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import ProjectCard from './ProjectCard.vue';
|
||||
|
||||
const base = {
|
||||
id: 1,
|
||||
name: 'Окна СПб',
|
||||
signal_type: 'site' as const,
|
||||
signal_identifier: 'okna.ru',
|
||||
daily_limit_target: 50,
|
||||
delivered_today: 32,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
sync_status: 'ok' as const,
|
||||
};
|
||||
|
||||
const okProject = base;
|
||||
const pendingProject = { ...base, sync_status: 'pending' as const };
|
||||
const failedProject = { ...base, sync_status: 'failed' as const };
|
||||
const pausedProject = { ...base, is_active: false };
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Projects / ProjectCard" :layout="{ type: 'single', iframe: true }">
|
||||
<Variant title="Sync OK (active)">
|
||||
<v-app>
|
||||
<v-main class="story-pane">
|
||||
<div class="card-wrap">
|
||||
<ProjectCard :project="okProject" :selected="false" />
|
||||
</div>
|
||||
</v-main>
|
||||
</v-app>
|
||||
</Variant>
|
||||
<Variant title="Sync pending">
|
||||
<v-app>
|
||||
<v-main class="story-pane">
|
||||
<div class="card-wrap">
|
||||
<ProjectCard :project="pendingProject" :selected="false" />
|
||||
</div>
|
||||
</v-main>
|
||||
</v-app>
|
||||
</Variant>
|
||||
<Variant title="Sync failed">
|
||||
<v-app>
|
||||
<v-main class="story-pane">
|
||||
<div class="card-wrap">
|
||||
<ProjectCard :project="failedProject" :selected="false" />
|
||||
</div>
|
||||
</v-main>
|
||||
</v-app>
|
||||
</Variant>
|
||||
<Variant title="Paused">
|
||||
<v-app>
|
||||
<v-main class="story-pane">
|
||||
<div class="card-wrap">
|
||||
<ProjectCard :project="pausedProject" :selected="false" />
|
||||
</div>
|
||||
</v-main>
|
||||
</v-app>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.story-pane {
|
||||
background: #f6f3ec;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
}
|
||||
.card-wrap {
|
||||
width: 360px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<v-card class="project-card ld-hover-lift" :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>
|
||||
</template>
|
||||
|
||||
<v-card-title>
|
||||
{{ project.name }}
|
||||
<v-chip size="x-small" :color="typeColor" class="ml-2">{{ typeLabel }}</v-chip>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-subtitle>{{ identifierDisplay }}</v-card-subtitle>
|
||||
|
||||
<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"
|
||||
/>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item @click="$emit('edit', project)">
|
||||
<template #prepend><v-icon>mdi-pencil</v-icon></template>
|
||||
<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>
|
||||
</v-list-item>
|
||||
<v-list-item @click="$emit('sync-now', project)">
|
||||
<template #prepend><v-icon>mdi-refresh</v-icon></template>
|
||||
<v-list-item-title>Синхронизировать</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="$emit('archive', project)">
|
||||
<template #prepend><v-icon>mdi-archive</v-icon></template>
|
||||
<v-list-item-title>Архивировать</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
</v-card-item>
|
||||
|
||||
<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
|
||||
>
|
||||
</div>
|
||||
<v-progress-linear
|
||||
:model-value="progressPercent"
|
||||
:color="progressColor"
|
||||
height="6"
|
||||
rounded
|
||||
:aria-label="`Прогресс дневной нормы: ${progressPercent}%`"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="text-caption text-medium-emphasis mb-2">На паузе</div>
|
||||
|
||||
<v-chip :color="syncStatusColor" size="x-small" variant="tonal">
|
||||
<v-icon start size="x-small">{{ syncStatusIcon }}</v-icon>
|
||||
{{ syncStatusLabel }}
|
||||
</v-chip>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { Project } from '../../stores/projectsStore';
|
||||
|
||||
const props = defineProps<{ project: Project; selected: boolean }>();
|
||||
defineEmits<{
|
||||
'toggle-select': [id: number];
|
||||
edit: [project: Project];
|
||||
'toggle-active': [project: Project];
|
||||
'sync-now': [project: Project];
|
||||
archive: [project: Project];
|
||||
}>();
|
||||
|
||||
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],
|
||||
);
|
||||
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.signal_identifier ?? '';
|
||||
});
|
||||
const progressPercent = computed(() =>
|
||||
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],
|
||||
);
|
||||
const syncStatusIcon = computed(
|
||||
() =>
|
||||
({ 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],
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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>
|
||||
@@ -0,0 +1,13 @@
|
||||
<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>
|
||||
@@ -0,0 +1,93 @@
|
||||
<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: #9a9690;
|
||||
color: #6b6356;
|
||||
}
|
||||
|
||||
.reminder-row {
|
||||
|
||||
@@ -33,9 +33,7 @@ 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">
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import DensityToggle from './DensityToggle.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="UI/DensityToggle">
|
||||
<Variant title="Default"><DensityToggle /></Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<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>
|
||||
@@ -0,0 +1,13 @@
|
||||
<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>
|
||||
@@ -0,0 +1,54 @@
|
||||
<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>
|
||||
@@ -0,0 +1,12 @@
|
||||
<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>
|
||||
@@ -0,0 +1,28 @@
|
||||
<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>
|
||||
@@ -0,0 +1,29 @@
|
||||
<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>
|
||||
@@ -0,0 +1,38 @@
|
||||
<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,4 +56,3 @@ export interface AdminTenantDetail extends AdminTenant {
|
||||
avgLeadCost: number;
|
||||
runwayDays: number; // balance / avgDailySpend
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 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,10 +46,7 @@ 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');
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Маппинг 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;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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: 'Дальневосточный' },
|
||||
];
|
||||
@@ -0,0 +1,42 @@
|
||||
export interface Region {
|
||||
code: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// MVP: 31 региона (коды 1..31) ограничены 32-bit region_mask из Plan 5 Task 9.
|
||||
// Sentinel code:0 = «Вся РФ» (включает все регионы, эквивалент пустой маски).
|
||||
// Имена — официальные субъекты РФ по конституционному порядку нумерации.
|
||||
export const REGIONS: Region[] = [
|
||||
{ code: 0, name: 'Вся РФ' },
|
||||
{ code: 1, name: 'Республика Адыгея' },
|
||||
{ code: 2, name: 'Республика Башкортостан' },
|
||||
{ code: 3, name: 'Республика Бурятия' },
|
||||
{ code: 4, name: 'Республика Алтай' },
|
||||
{ code: 5, name: 'Республика Дагестан' },
|
||||
{ code: 6, name: 'Республика Ингушетия' },
|
||||
{ code: 7, name: 'Кабардино-Балкарская Республика' },
|
||||
{ code: 8, name: 'Республика Калмыкия' },
|
||||
{ code: 9, name: 'Карачаево-Черкесская Республика' },
|
||||
{ code: 10, name: 'Республика Карелия' },
|
||||
{ code: 11, name: 'Республика Коми' },
|
||||
{ code: 12, name: 'Республика Марий Эл' },
|
||||
{ code: 13, name: 'Республика Мордовия' },
|
||||
{ code: 14, name: 'Республика Саха (Якутия)' },
|
||||
{ code: 15, name: 'Республика Северная Осетия — Алания' },
|
||||
{ code: 16, name: 'Республика Татарстан' },
|
||||
{ code: 17, name: 'Республика Тыва' },
|
||||
{ code: 18, name: 'Удмуртская Республика' },
|
||||
{ code: 19, name: 'Республика Хакасия' },
|
||||
{ code: 20, name: 'Чеченская Республика' },
|
||||
{ code: 21, name: 'Чувашская Республика' },
|
||||
{ code: 22, name: 'Алтайский край' },
|
||||
{ code: 23, name: 'Краснодарский край' },
|
||||
{ code: 24, name: 'Красноярский край' },
|
||||
{ code: 25, name: 'Приморский край' },
|
||||
{ code: 26, name: 'Ставропольский край' },
|
||||
{ code: 27, name: 'Хабаровский край' },
|
||||
{ code: 28, name: 'Амурская область' },
|
||||
{ code: 29, name: 'Архангельская область' },
|
||||
{ code: 30, name: 'Астраханская область' },
|
||||
{ code: 31, name: 'Белгородская область' },
|
||||
];
|
||||
@@ -0,0 +1,16 @@
|
||||
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: 'Вс' },
|
||||
];
|
||||
@@ -1,18 +1,8 @@
|
||||
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(),
|
||||
@@ -23,9 +13,11 @@ 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 />' } },
|
||||
@@ -35,4 +27,5 @@ export const setupVue3 = defineSetupVue3(({ app }) => {
|
||||
});
|
||||
app.use(vuetify);
|
||||
app.use(router);
|
||||
app.use(createPinia());
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
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;
|
||||
@@ -65,7 +66,7 @@ const currentPageTitle = computed(() => {
|
||||
|
||||
<template>
|
||||
<v-app>
|
||||
<v-navigation-drawer color="secondary" theme="dark" :width="240" class="admin-drawer">
|
||||
<v-navigation-drawer color="#012019" 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">
|
||||
@@ -84,7 +85,7 @@ const currentPageTitle = computed(() => {
|
||||
</div>
|
||||
<div class="brand-sub">ADMIN</div>
|
||||
|
||||
<v-list nav density="comfortable" class="app-nav">
|
||||
<v-list nav density="comfortable" class="app-nav" role="navigation" aria-label="Админ навигация">
|
||||
<v-list-item
|
||||
v-for="item in navItems"
|
||||
:key="item.to"
|
||||
@@ -130,6 +131,7 @@ const currentPageTitle = computed(() => {
|
||||
<v-main class="admin-main">
|
||||
<RouterView />
|
||||
</v-main>
|
||||
<DevIndexBadge :index="route.meta.devIndex" :label="route.meta.devLabel" />
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
@@ -167,7 +169,7 @@ const currentPageTitle = computed(() => {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.16em;
|
||||
color: #b94837;
|
||||
color: #e06155;
|
||||
padding: 0 20px 14px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
@@ -178,7 +180,7 @@ const currentPageTitle = computed(() => {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
font-size: 11px;
|
||||
color: #7a8c87;
|
||||
color: #8a9c95;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 2px 7px;
|
||||
border-radius: 10px;
|
||||
|
||||
@@ -17,6 +17,7 @@ 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();
|
||||
@@ -28,13 +29,12 @@ const drawerOpen = ref(true);
|
||||
// Тот же навигационный pool что в AppSidebar — для crumb-resolution в topbar
|
||||
// (sidebar и topbar — независимые, но navGroups совпадают по контракту).
|
||||
const navItems = computed(() => [
|
||||
{ title: 'Дашборд', to: '/dashboard' },
|
||||
{ title: 'Проекты', to: '/projects' },
|
||||
{ title: 'Сделки', to: '/deals' },
|
||||
{ title: 'Канбан', to: '/kanban' },
|
||||
{ title: 'Напоминания', to: '/reminders' },
|
||||
{ title: 'Дашборд', to: '/dashboard' },
|
||||
{ title: 'Биллинг', to: '/billing' },
|
||||
{ title: 'Отчёты', to: '/reports' },
|
||||
{ title: 'Менеджеры', to: '/managers' },
|
||||
{ title: 'Настройки', to: '/settings' },
|
||||
]);
|
||||
|
||||
@@ -66,13 +66,19 @@ usePolling(loadReminderCounts, { intervalMs: 60_000, enabled: true });
|
||||
<AppTopbar :page-title="currentPageTitle" @toggle-drawer="drawerOpen = !drawerOpen" />
|
||||
|
||||
<v-main class="app-main">
|
||||
<RouterView />
|
||||
<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>
|
||||
</v-main>
|
||||
<DevIndexBadge :index="route.meta.devIndex" :label="route.meta.devLabel" />
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-main {
|
||||
background: #f6f3ec;
|
||||
padding-left: 232px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
* - Слева: brand mark "Лидерра.", цитата с акцентом, footer с ссылками на оферту/политику.
|
||||
* - Справа: <RouterView /> рендерит конкретный auth-экран (LoginView и т.п.).
|
||||
*/
|
||||
import { RouterView } from 'vue-router';
|
||||
import { RouterView, useRoute } from 'vue-router';
|
||||
import DevIndexBadge from '../components/DevIndexBadge.vue';
|
||||
|
||||
const route = useRoute();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -51,6 +54,7 @@ import { RouterView } from 'vue-router';
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-main>
|
||||
<DevIndexBadge :index="route.meta.devIndex" :label="route.meta.devLabel" />
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,29 +1,173 @@
|
||||
// @ts-expect-error vuetify/styles — CSS-импорт без d.ts
|
||||
import 'vuetify/styles';
|
||||
import { h, type Component } from 'vue';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import type { ThemeDefinition } 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';
|
||||
|
||||
/**
|
||||
* Палитра Forest (BRANDBOOK_v2 §3, v8 handoff Платона).
|
||||
* Палитра 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)
|
||||
*
|
||||
* Источник истины — `liderra_v8_handoff/docs/BRANDBOOK_v2.md`. 14 OKLCH-статусов
|
||||
* воронки маппятся на 14 slug'ов из `db/schema.sql:2076` (lead_statuses) —
|
||||
* НЕ на 14 «обобщённых» из BRANDBOOK §3.6 (расхождение зафиксировано в
|
||||
* `Открытые_вопросы` v1.13).
|
||||
* 14 OKLCH-статусов воронки маппятся на slugs из db/schema.sql:2076 (lead_statuses)
|
||||
* через `useStatusPill` composable, НЕ через Vuetify theme.
|
||||
*/
|
||||
const liderraForest: ThemeDefinition = {
|
||||
dark: false,
|
||||
colors: {
|
||||
background: '#F6F3EC', // warm ivory — page bg
|
||||
background: '#F6F3EC',
|
||||
surface: '#FFFFFF',
|
||||
primary: '#0F6E56', // Teal — неоспариваемый primary
|
||||
primary: '#0F6E56',
|
||||
'on-primary': '#FFFFFF',
|
||||
secondary: '#012019', // теало-нуар — sidebar
|
||||
secondary: '#012019',
|
||||
'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 });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -32,8 +176,43 @@ export const vuetify = createVuetify({
|
||||
defaultTheme: 'liderraForest',
|
||||
themes: { liderraForest },
|
||||
},
|
||||
icons: {
|
||||
defaultSet: 'liderra',
|
||||
sets: { liderra: liderraLucideSet },
|
||||
},
|
||||
defaults: {
|
||||
VBtn: { variant: 'flat' },
|
||||
VCard: { rounded: 'lg' },
|
||||
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)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,7 +11,24 @@ 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: '/',
|
||||
@@ -21,85 +38,147 @@ const routes: RouteRecordRaw[] = [
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('../views/auth/LoginView.vue'),
|
||||
meta: { layout: 'auth', title: 'Вход', guestOnly: true },
|
||||
meta: { layout: 'auth', title: 'Вход', guestOnly: true, devIndex: 1, devLabel: 'Login' },
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: () => import('../views/auth/RegisterView.vue'),
|
||||
meta: { layout: 'auth', title: 'Регистрация', guestOnly: true },
|
||||
meta: { layout: 'auth', title: 'Регистрация', guestOnly: true, devIndex: 2, devLabel: 'Register' },
|
||||
},
|
||||
{
|
||||
path: '/2fa',
|
||||
name: '2fa',
|
||||
component: () => import('../views/auth/TwoFactorView.vue'),
|
||||
meta: { layout: 'auth', title: 'Двухфакторная проверка' },
|
||||
meta: { layout: 'auth', title: 'Двухфакторная проверка', devIndex: 3, devLabel: '2FA' },
|
||||
},
|
||||
{
|
||||
path: '/forgot',
|
||||
name: 'forgot',
|
||||
component: () => import('../views/auth/ForgotPasswordView.vue'),
|
||||
meta: { layout: 'auth', title: 'Сброс пароля', guestOnly: true },
|
||||
meta: { layout: 'auth', title: 'Сброс пароля', guestOnly: true, devIndex: 4, devLabel: 'Forgot password' },
|
||||
},
|
||||
{
|
||||
path: '/recovery',
|
||||
name: 'recovery',
|
||||
component: () => import('../views/auth/RecoveryCodesView.vue'),
|
||||
meta: { layout: 'auth', title: 'Резервные коды' },
|
||||
meta: { layout: 'auth', title: 'Резервные коды', devIndex: 6, devLabel: 'Recovery codes' },
|
||||
},
|
||||
{
|
||||
path: '/recovery-use',
|
||||
name: 'recovery-use',
|
||||
component: () => import('../views/auth/UseRecoveryCodeView.vue'),
|
||||
meta: { layout: 'auth', title: 'Вход по резервному коду' },
|
||||
meta: { layout: 'auth', title: 'Вход по резервному коду', devIndex: 7, devLabel: 'Use recovery' },
|
||||
},
|
||||
{
|
||||
path: '/reset/:token',
|
||||
name: 'reset-password',
|
||||
component: () => import('../views/auth/ResetPasswordView.vue'),
|
||||
meta: { layout: 'auth', title: 'Новый пароль', guestOnly: true },
|
||||
meta: { layout: 'auth', title: 'Новый пароль', guestOnly: true, devIndex: 5, devLabel: 'Reset password' },
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'dashboard',
|
||||
component: () => import('../views/DashboardView.vue'),
|
||||
meta: { layout: 'app', title: 'Дашборд', requiresAuth: true },
|
||||
meta: {
|
||||
layout: 'app',
|
||||
title: 'Дашборд',
|
||||
requiresAuth: true,
|
||||
transition: 'ld-route-fadeup',
|
||||
devIndex: 8,
|
||||
devLabel: 'Dashboard',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/deals',
|
||||
name: 'deals',
|
||||
component: () => import('../views/DealsView.vue'),
|
||||
meta: { layout: 'app', title: 'Сделки', requiresAuth: true },
|
||||
meta: {
|
||||
layout: 'app',
|
||||
title: 'Сделки',
|
||||
requiresAuth: true,
|
||||
transition: 'ld-route-fadeup',
|
||||
devIndex: 9,
|
||||
devLabel: 'Сделки',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/kanban',
|
||||
name: 'kanban',
|
||||
component: () => import('../views/KanbanView.vue'),
|
||||
meta: { layout: 'app', title: 'Канбан', requiresAuth: true },
|
||||
meta: {
|
||||
layout: 'app',
|
||||
title: 'Канбан',
|
||||
requiresAuth: true,
|
||||
transition: 'ld-route-fadeup',
|
||||
devIndex: 10,
|
||||
devLabel: 'Канбан',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/projects',
|
||||
name: 'projects',
|
||||
component: () => import('../views/ProjectsView.vue'),
|
||||
meta: {
|
||||
layout: 'app',
|
||||
title: 'Проекты',
|
||||
requiresAuth: true,
|
||||
transition: 'ld-route-fadeup',
|
||||
devIndex: 16,
|
||||
devLabel: 'Проекты',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/billing',
|
||||
name: 'billing',
|
||||
component: () => import('../views/BillingView.vue'),
|
||||
meta: { layout: 'app', title: 'Биллинг и тарифы', requiresAuth: true },
|
||||
meta: {
|
||||
layout: 'app',
|
||||
title: 'Биллинг и тарифы',
|
||||
requiresAuth: true,
|
||||
transition: 'ld-route-fadeup',
|
||||
devIndex: 13,
|
||||
devLabel: 'Биллинг',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
component: () => import('../views/SettingsView.vue'),
|
||||
meta: { layout: 'app', title: 'Настройки', requiresAuth: true },
|
||||
meta: {
|
||||
layout: 'app',
|
||||
title: 'Настройки',
|
||||
requiresAuth: true,
|
||||
transition: 'ld-route-fadeup',
|
||||
devIndex: 14,
|
||||
devLabel: 'Настройки',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/reports',
|
||||
name: 'reports',
|
||||
component: () => import('../views/ReportsView.vue'),
|
||||
meta: { layout: 'app', title: 'Отчёты', requiresAuth: true },
|
||||
meta: {
|
||||
layout: 'app',
|
||||
title: 'Отчёты',
|
||||
requiresAuth: true,
|
||||
transition: 'ld-route-fadeup',
|
||||
devIndex: 12,
|
||||
devLabel: 'Отчёты',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/reminders',
|
||||
name: 'reminders',
|
||||
component: () => import('../views/RemindersView.vue'),
|
||||
meta: { layout: 'app', title: 'Напоминания', requiresAuth: true },
|
||||
meta: {
|
||||
layout: 'app',
|
||||
title: 'Напоминания',
|
||||
requiresAuth: true,
|
||||
transition: 'ld-route-fadeup',
|
||||
devIndex: 11,
|
||||
devLabel: 'Напоминания',
|
||||
},
|
||||
},
|
||||
// Админка SaaS — отдельный layout с под-брендом ADMIN.
|
||||
// TODO: дополнительный role-guard на super_admin.
|
||||
@@ -111,68 +190,86 @@ const routes: RouteRecordRaw[] = [
|
||||
path: '/admin/tenants',
|
||||
name: 'admin-tenants',
|
||||
component: () => import('../views/admin/AdminTenantsView.vue'),
|
||||
meta: { layout: 'admin', title: 'Тенанты', requiresAuth: true },
|
||||
meta: { layout: 'admin', title: 'Тенанты', requiresAuth: true, devIndex: 21, devLabel: 'Admin Tenants' },
|
||||
},
|
||||
{
|
||||
path: '/admin/tenants/:code',
|
||||
name: 'admin-tenant-detail',
|
||||
component: () => import('../views/admin/AdminTenantDetailView.vue'),
|
||||
meta: { layout: 'admin', title: 'Тенант', requiresAuth: true },
|
||||
meta: { layout: 'admin', title: 'Тенант', requiresAuth: true, devIndex: 22, devLabel: 'Admin Tenant Detail' },
|
||||
},
|
||||
{
|
||||
path: '/admin/billing',
|
||||
name: 'admin-billing',
|
||||
component: () => import('../views/admin/AdminBillingView.vue'),
|
||||
meta: { layout: 'admin', title: 'Биллинг', requiresAuth: true },
|
||||
meta: { layout: 'admin', title: 'Биллинг', requiresAuth: true, devIndex: 23, devLabel: 'Admin Billing' },
|
||||
},
|
||||
{
|
||||
path: '/admin/incidents',
|
||||
name: 'admin-incidents',
|
||||
component: () => import('../views/admin/AdminIncidentsView.vue'),
|
||||
meta: { layout: 'admin', title: 'Инциденты', requiresAuth: true },
|
||||
meta: { layout: 'admin', title: 'Инциденты', requiresAuth: true, devIndex: 24, devLabel: 'Admin Incidents' },
|
||||
},
|
||||
{
|
||||
path: '/admin/system',
|
||||
name: 'admin-system',
|
||||
component: () => import('../views/admin/AdminSystemView.vue'),
|
||||
meta: { layout: 'admin', title: 'Система', requiresAuth: true },
|
||||
meta: { layout: 'admin', title: 'Система', requiresAuth: true, devIndex: 25, devLabel: 'Admin System' },
|
||||
},
|
||||
{
|
||||
path: '/admin/pricing-tiers',
|
||||
name: 'admin-pricing-tiers',
|
||||
component: () => import('../views/admin/AdminPricingTiersView.vue'),
|
||||
meta: { layout: 'admin', title: 'Тарифная сетка', requiresAuth: true },
|
||||
meta: {
|
||||
layout: 'admin',
|
||||
title: 'Тарифная сетка',
|
||||
requiresAuth: true,
|
||||
devIndex: 27,
|
||||
devLabel: 'Admin Pricing Tiers',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/admin/supplier-prices',
|
||||
name: 'admin-supplier-prices',
|
||||
component: () => import('../views/admin/AdminSupplierPricesView.vue'),
|
||||
meta: { layout: 'admin', title: 'Цены поставщиков', requiresAuth: true },
|
||||
meta: {
|
||||
layout: 'admin',
|
||||
title: 'Цены поставщиков',
|
||||
requiresAuth: true,
|
||||
devIndex: 28,
|
||||
devLabel: 'Admin Supplier Prices',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/admin/impersonation',
|
||||
name: 'admin-impersonation',
|
||||
component: () => import('../views/admin/AdminImpersonationView.vue'),
|
||||
meta: { layout: 'admin', title: 'Impersonation', requiresAuth: true },
|
||||
meta: {
|
||||
layout: 'admin',
|
||||
title: 'Impersonation',
|
||||
requiresAuth: true,
|
||||
devIndex: 26,
|
||||
devLabel: 'Admin Impersonation',
|
||||
},
|
||||
},
|
||||
// Error pages: 403/500 явные + catch-all 404 (всегда последний).
|
||||
{
|
||||
path: '/403',
|
||||
name: 'forbidden',
|
||||
component: () => import('../views/errors/ErrorView.vue'),
|
||||
meta: { layout: 'error', errorCode: '403', title: 'Доступ запрещён' },
|
||||
meta: { layout: 'error', errorCode: '403', title: 'Доступ запрещён', devIndex: 15, devLabel: 'Ошибка 403' },
|
||||
},
|
||||
{
|
||||
path: '/500',
|
||||
name: 'server-error',
|
||||
component: () => import('../views/errors/ErrorView.vue'),
|
||||
meta: { layout: 'error', errorCode: '500', title: 'Ошибка сервера' },
|
||||
meta: { layout: 'error', errorCode: '500', title: 'Ошибка сервера', devIndex: 15, devLabel: 'Ошибка 500' },
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
component: () => import('../views/errors/ErrorView.vue'),
|
||||
meta: { layout: 'error', errorCode: '404', title: 'Страница не найдена' },
|
||||
meta: { layout: 'error', errorCode: '404', title: 'Страница не найдена', devIndex: 15, devLabel: 'Ошибка 404' },
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
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,
|
||||
};
|
||||
});
|
||||
@@ -73,18 +73,11 @@ 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
|
||||
|
||||
@@ -61,6 +61,11 @@ 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" />
|
||||
@@ -81,4 +86,13 @@ 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>
|
||||
|
||||
@@ -25,11 +25,25 @@ 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();
|
||||
|
||||
@@ -449,6 +463,45 @@ 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"
|
||||
@@ -474,14 +527,19 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
|
||||
Backend недоступен — показаны mock-данные.
|
||||
</v-alert>
|
||||
|
||||
<DealsTable
|
||||
class="mt-4"
|
||||
:deals="filteredDeals"
|
||||
:selected-ids="selected"
|
||||
:status-by-slug="statusBySlug"
|
||||
@update:selected-ids="selected = $event"
|
||||
@row-click="openDeal"
|
||||
/>
|
||||
<!-- 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>
|
||||
|
||||
<DealDetailDrawer v-model:open="drawerOpen" :deal="selectedDeal" :tenant-id="auth.user?.tenant_id" />
|
||||
|
||||
@@ -557,4 +615,30 @@ 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>
|
||||
|
||||
@@ -161,7 +161,7 @@ defineExpose({ dealsByStatus, totalDeals, newDealOpen, onDealCreated, fetchError
|
||||
Backend недоступен — показаны mock-данные.
|
||||
</v-alert>
|
||||
|
||||
<div class="kanban-board mt-4">
|
||||
<div class="kanban-board mt-4" tabindex="0" role="region" aria-label="Канбан-доска воронки продаж">
|
||||
<KanbanColumn
|
||||
v-for="status in leadStatuses"
|
||||
:key="status.slug"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<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>
|
||||
@@ -0,0 +1,226 @@
|
||||
<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>
|
||||
</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>
|
||||
@@ -113,12 +113,7 @@ 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" />
|
||||
|
||||
|
||||
@@ -202,12 +202,7 @@ 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,12 +2,7 @@
|
||||
<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"
|
||||
@@ -17,6 +12,7 @@
|
||||
density="compact"
|
||||
hide-details
|
||||
variant="plain"
|
||||
:aria-label="`Cost (₽) для ${item.name}`"
|
||||
/>
|
||||
</template>
|
||||
<template #[`item.quality_score`]="{ item }">
|
||||
@@ -29,6 +25,7 @@
|
||||
density="compact"
|
||||
hide-details
|
||||
variant="plain"
|
||||
:aria-label="`Quality для ${item.name}`"
|
||||
/>
|
||||
</template>
|
||||
<template #[`item.is_active`]="{ item }">
|
||||
@@ -37,15 +34,11 @@
|
||||
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,12 +15,7 @@
|
||||
*/
|
||||
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">
|
||||
<v-alert type="info" variant="tonal" density="compact" class="mb-2 a11y-info-darker">
|
||||
Лимит — <strong>5 попыток в 15 минут</strong>. Если не пришло письмо — проверьте спам или попробуйте
|
||||
через 15 минут.
|
||||
</v-alert>
|
||||
@@ -121,6 +121,11 @@ 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,9 +50,7 @@
|
||||
{{ 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,6 +23,7 @@ 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;
|
||||
@@ -136,6 +137,7 @@ const config = computed<ErrorConfig>(() => {
|
||||
/>
|
||||
</div>
|
||||
</v-main>
|
||||
<DevIndexBadge :index="route.meta.devIndex" :label="route.meta.devLabel" />
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<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>
|
||||
@@ -0,0 +1,34 @@
|
||||
<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>
|
||||
@@ -0,0 +1,253 @@
|
||||
<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>
|
||||
+27
-1
@@ -142,9 +142,25 @@ 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.
|
||||
@@ -183,9 +199,19 @@ 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');
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
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);
|
||||
@@ -0,0 +1,239 @@
|
||||
<?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]);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user