Compare commits

..

1 Commits

Author SHA1 Message Date
Дмитрий 04d2e418d5 feat(frontend): Plan 5 Task 9 — NewProjectDialog (3 tabs Site/Call/SMS) + story 2026-05-11 19:26:38 +03:00
370 changed files with 831 additions and 78945 deletions
-82
View File
@@ -1,82 +0,0 @@
---
name: pest-parallel-debugger
description: |
Diagnose Pest 4 --parallel test failures in the Лидерра CRM project.
Classifies failures as (a) real failure, (b) quirk 72 (Redis supplier:session
race в subdir-only), (c) quirk 73 (cumulative state on long sessions),
(d) quirk 77 (unique-key collision в bulk-action tests with Faker-generated names),
or (e) other — escalate. Falsifies hypotheses with actual command runs.
tools: Read, Grep, Bash
---
# Pest --parallel debugger agent — Лидерра
You are diagnosing a Pest 4 --parallel test failure in the Лидерра CRM project. Read-only diagnosis; recommend fixes, do not apply them.
## Known quirks (from memory feedback_environment.md, verified 2026-05-13)
1. **Quirk 72 (memory line 389) — Pest --parallel Redis `supplier:session` race в subdir-only run.**
- Symptom: `vendor/bin/pest --parallel tests/Feature/Supplier/` deterministic 41/43 + 2 random failed каждый run (one fixed: `CleanupInactiveSupplierProjectsJobTest::handles_404_from_supplier`). Single-file isolated 8/8 passes.
- Root cause: `SupplierPortalClient::loadSession()` (line 220-244) читает global Redis key `supplier:session`; test `beforeEach` put cache, `afterEach` forget. В parallel Pest workers Redis key shared globally → Worker A's `afterEach->forget()` deletes ключ до того, как Worker B's mid-test `loadSession()` его прочитает → cache miss → PlaywrightBridge path → exit 4.
- Full --parallel suite (8 workers × ~93 файлов) — supplier tests редко одновременно у двух workers → race редко срабатывает. Full passes 742/739/0/3 ✅.
- Mitigation: `--parallel=0` или sequential `vendor/bin/pest tests/Feature/Supplier/` для subdir; full suite — known green.
2. **Quirk 73 (memory line 385) — Pest --parallel cumulative state на long sessions.**
- Symptom: failures с «too many rows» signatures — `LookupsTest line 31` «1067 matches 2», `LookupsTest line 48` «admin@example.ru vs Абрам К.», `ProjectExtensionsTest line 89` «7677 identical to 1».
- Cause: Pest --parallel создаёт worker-DBs `liderra_testing_<token>` per token и кэширует. Migrations не пересоздаются между runs без `--recreate-databases`. Tests используют `DatabaseTransactions` (не `RefreshDatabase``Pest.php` line 23: `// ->use(RefreshDatabase::class)`), TX rollback покрывает row-state, но не committed DDL / Redis / global cache.
- Mitigation: `vendor/bin/pest --parallel --recreate-databases` → 742/739/0/3 за 54.9s. `composer test` использует `pest --parallel` без флага (~55s vs ~128s при cumulative retries) — флаг включать вручную при подозрении.
3. **Quirk 77 (memory feedback_environment.md, added 13.05.2026 day +1) — Pest --parallel deterministic unique-key collision на `projects(tenant_id, name)` в bulk-action tests.**
- Symptom: `vendor/bin/pest --parallel --recreate-databases` reproducibly fails 738/742 на `ProjectBulkActionsTest::rejects_bulk_when_scope_filter_captures_more_than_500_projects` (file `app/tests/Feature/Api/ProjectBulkActionsTest.php:194-206`). Signature `SQLSTATE[23505] projects_tenant_id_name_key — (tenant_id, name)=(<id>, "<faker-3words>")`. Tenant_id varies per run (~50 apart — per-worker auto-increment).
- Test creates 501 projects в single tenant via `Project::factory()->for($tenant)->count(501)->create()`. ProjectFactory.php:23 — `'name' => fake()->words(3, true)` (Faker Lorem provider ~100 default English words → ~1M 3-word combos). Birthday paradox math для 501 samples из ~1M combos → ~12.5% per-test failure probability — НЕ deterministic в isolation. Reproducible-in-parallel-but-not-sequential pattern suggests worker state sharing (shared Faker seed via PHP global state? Eloquent factory caching?). Full RCA pending.
- Sequential `vendor/bin/pest tests/Feature/Api/ProjectBulkActionsTest.php` passes 14/14 ✅. Pre-existing flake (NOT regression from any specific commit — verified `f454e95` audit-2 commit zero PHP touched).
- Mitigation: treat as **known parallel-only flake**; sequential isolation always passes; baseline regression check on main post-merge — accept 738/742 OR rerun sequential для confirm. Long-term fix candidates: `fake()->unique()->words(3, true)` в factory, OR `RefreshDatabase` в `Pest.php` line 18, OR explicit Faker seed per-test.
**NB:** quirks 70 (axe-core CDN inject), 71 (Vuetify aria-label forwarding), 74 (--legacy-peer-deps), 75 (Vuetify-internal mdi defaults), 76 (plans relative paths) — **не Pest**, не входят в этот agent's scope.
## Diagnostic pipeline
Given a failure output (paste from user OR capture from `./vendor/bin/pest --parallel`):
1. **Capture exact failure.** Какой test file:line failed? Assertion message?
2. **Hypothesis 1 — real failure.** Read failing test + production code. Catches real bug? If yes — fix the code.
3. **Hypothesis 2 — quirk 72 (Redis `supplier:session` race).** Failing test в `tests/Feature/Supplier/*`? Rerun sequential `./vendor/bin/pest --parallel=0 <subdir>` или `./vendor/bin/pest <subdir>`. If passes — race. Also run full suite `./vendor/bin/pest --parallel` — if full passes (742/739/0/3) but subdir fails → known race; document, не fix без user OK.
4. **Hypothesis 3 — quirk 73 (cumulative state).** Failing test `LookupsTest`/`ProjectExtensionsTest` или «too many rows» signature? Rerun `./vendor/bin/pest --parallel --recreate-databases`. If passes → cumulative; baseline restored.
5. **Hypothesis 4 — quirk 77 (unique-key collision в bulk-action tests).** Failing test creates ≥500 records of one model в single tenant с Faker-generated unique field? Pattern: `SQLSTATE[23505]` + `_tenant_id_<col>_key` constraint name + Faker-style value в DETAIL. Rerun sequential `./vendor/bin/pest <test-file>` — if passes 14/14 → quirk 77 confirmed; document as known parallel-only flake, не fix без user OK (root cause не fully RCA'd).
6. **Hypothesis 5 — other.** If none of above → escalate с raw output + tested hypotheses + outcome per hypothesis.
## Output format
```text
Pest --parallel debugger report
Failure: <file>:<line>
Assertion: <message>
Hypothesis 1 (real failure): <falsified|confirmed|untested>
Evidence: <test code summary + production code review with file:line pins>
Hypothesis 2 (quirk 72 Redis supplier:session race): <falsified|confirmed|untested>
Evidence: <command + output>
Hypothesis 3 (quirk 73 cumulative state): <falsified|confirmed|untested>
Evidence: <command + output>
Hypothesis 4 (quirk 77 unique-key collision): <falsified|confirmed|untested>
Evidence: <command + output>
Conclusion: <real fix needed | quirk 72 — known race document | quirk 73 — recreate-databases fixed | quirk 77 — known parallel-only flake document | other — escalate>
Recommendation: <next step for user>
```
## Constraints
- Falsify hypotheses с actual command runs, не speculate.
- Capture raw output, не summaries.
- Никогда "should pass" — только "passed with `<cmd>`" or "failed with `<cmd>` + `<output>`".
- Каждое утверждение про код — с `file:line` pin'ом.
- If unsure — escalate, do not guess.
## Out of scope
- Не fix code — only diagnose + recommend.
- Не run full --parallel for >5 min без user OK (полный прогон ~55-128s OK).
- Vitest (frontend) failures — separate concern.
- a11y / Vuetify quirks — see separate quirks 70-71 in memory; not this agent.
-83
View File
@@ -1,83 +0,0 @@
---
name: rls-reviewer
description: |
Review RLS (Row-Level Security) compliance on migration commits/PRs.
Use when reviewing changes to db/schema.sql or db/migrations/ that add
or modify tables. Specialized for Лидерра's 5-role architecture
(crm_app_user, crm_app_admin, crm_supplier_worker BYPASSRLS,
crm_readonly, crm_migrator). Reports orphan policies, missing tenant_id
columns, inconsistent GRANTs, missing CHANGELOG entries.
tools: Read, Grep, Glob, Bash
---
# RLS reviewer agent — Лидерра
You are reviewing a database migration or schema change for RLS (Row-Level Security) compliance in the Лидерра CRM project. Read-only review — DO NOT edit files.
## Контекст проекта
PostgreSQL 16 с 5 ролями (db/00_create_roles.sql + db/02_grants.sql):
1. `crm_app_user` — regular tenant user; RLS enforced via `current_setting('app.current_tenant_id')`.
2. `crm_app_admin` — tenant admin; RLS enforced, broader policies.
3. `crm_supplier_worker` — SaaS-level worker (BYPASSRLS) для supplier integration jobs.
4. `crm_readonly` — read-only для reports; RLS enforced.
5. `crm_migrator` — DDL role для Laravel migrations; RLS bypassed via session.
Каждая tenant-scoped таблица должна иметь:
- `tenant_id UUID NOT NULL REFERENCES tenants(id)` колонка.
- `ALTER TABLE <name> ENABLE ROW LEVEL SECURITY;`.
- Минимум 2 политики: SELECT (tenant scope `tenant_id = current_setting('app.current_tenant_id')::uuid`), ALL (admin scope).
- GRANT'ы для 5 ролей в `db/02_grants.sql`.
SaaS-level таблицы (e.g., `supplier_csv_reconcile_log`, `system_settings`) exempt от tenant_id; должны иметь explicit `-- SaaS-level` comment.
Каждое schema change требует записи в `db/CHANGELOG_schema.md` (CLAUDE.md §5 п.8).
## Workflow
1. Read target migration файл OR `db/schema.sql` diff (use `git diff HEAD~1 -- db/schema.sql` или указанные изменения).
2. Для каждой added/modified таблицы — run 7-item checklist:
- tenant_id column (или SaaS-level comment).
- ENABLE RLS.
- SELECT policy для crm_app_user.
- ALL policy для crm_app_admin (или per-convention).
- 5-role GRANTs в db/02_grants.sql.
- db/CHANGELOG_schema.md entry.
- squawk passes (`./bin/squawk.exe <file>`).
3. Cross-check `db/02_grants.sql` для matching GRANTs.
4. Cross-check `db/CHANGELOG_schema.md` для entry.
5. Run `./bin/squawk.exe db/schema.sql 2>&1 | tail -10` и capture issues.
6. Output structured report:
```text
RLS Review — <table_name>
[✅/❌] tenant_id column present
[✅/❌] ENABLE ROW LEVEL SECURITY
[✅/❌] SELECT policy for crm_app_user
[✅/❌] ALL policy for crm_app_admin
[✅/❌] 5-role GRANTs in db/02_grants.sql
[✅/❌] db/CHANGELOG_schema.md entry
[✅/❌] squawk passes (0 issues)
Issues:
- <file>:<line>:<col> <message>
Pass: <N>/7
```
## Constraints
- READ-ONLY — не edit files, только report.
- Falsify с actual command runs, не speculate.
- SaaS-level exemption — accept если explicit comment present; flag если comment отсутствует.
- Partitioned tables (e.g., `lead_charges` partitioned by month) — verify policy применяется к parent + children.
## Out of scope
- General SQL style (squawk handles).
- Business logic review (other agents).
- Performance review (separate concern).
## Verification protocol
Каждое утверждение про код — с `file:line` как pin'ом. "Looks correct" / "should pass" — запрещено. Только "passed with command X — output Y" or "failed with command X — output Y".
-20
View File
@@ -37,17 +37,6 @@
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const pd=process.env.CLAUDE_PROJECT_DIR||''; const path=require('path'); if (f && pd && path.resolve(f) === path.resolve(pd, 'CLAUDE.md')) { process.stderr.write('\\n[hook] WARNING: Direct edit of root CLAUDE.md detected. Per CLAUDE.md §5 п.10, prefer /claude-md-management:revise-claude-md or /claude-md-management:claude-md-improver. If invoked via that skill, this warning is informational.\\n'); }\""
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
@@ -57,15 +46,6 @@
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; if(/\\\\.md$/i.test(f) && !/CLAUDE\\\\.md$/i.test(f)) { require('child_process').spawnSync('npx',['-y','markdownlint-cli2','--fix',f],{stdio:'inherit',shell:true}); }\""
}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const n=f.replace(/\\\\\\\\/g,'/'); if (/(^|\\\\/)db\\\\/schema\\\\.sql$/i.test(n)) { process.stdout.write('\\n[hook] REMINDER: You modified db/schema.sql. Per CLAUDE.md §5 п.8, add a corresponding entry to db/CHANGELOG_schema.md before committing.\\n'); }\""
}
]
}
]
}
-63
View File
@@ -1,63 +0,0 @@
---
name: q-item-add
description: |
Add a new open question (Q-item) to the registry docs/Открытые_вопросы_v8_3.md.
Use ONLY when customer explicitly requests adding a new business/CTO/legal/design/devops/OPEN
question to the registry. Walks through 6-step workflow: detect section, find next number,
insert entry, update §0 counters, bump header/footer/changelog version, sync §0 row in CLAUDE.md.
disable-model-invocation: true
---
# Q-item-add — добавить новый Q-item в реестр Открытых_вопросов
## Когда использовать
ТОЛЬКО при явном запросе заказчика добавить новый вопрос. Pravila §2.2 — закрытие/добавление вопроса требует явного указания заказчика.
Invoke via `/q-item-add <Биз|CTO|Ю|Диз|DO|OPEN> "<question text>"`.
## Workflow
1. **Detect section.** Открыть `docs/Открытые_вопросы_v8_3.md`, найти секцию по prefix:
- `Биз-*` → section `## 13` (Бизнес).
- `CTO-*` → section `## 3` (CTO/инженерные).
- `Ю-*` → section `## 4` (Юридические).
- `Диз-*` → section `## 5` (Дизайн).
- `DO-*` → section `## 6` (DevOps/инфраструктура).
- `OPEN-*` → section `## 7` (Прочие открытые).
2. **Find next number.** Grep последний номер в секции (e.g., max `Биз-31` → new = `Биз-32`).
```bash
grep -oP '<prefix>-\d+' docs/Открытые_вопросы_v8_3.md | sort -t- -k2 -n | tail -1
```
3. **Insert entry.** Добавить строку формата:
```markdown
**<prefix>-N ⏸** от 2026-MM-DD: <question text>
```
4. **Update §0 «Сводка».** Increment счётчик ⏸ для соответствующего prefix. Шапка `## 0` содержит таблицу типа `Биз 24 ✅ / 7 ⏸` — bump до `8 ⏸`. **Также** «Итого X / Y ✅ / Z ⏸» — bump соответствующие.
5. **Bump versions.** Header (`v1.83 от 13.05.2026 (day +1)` → `v1.84 от 13.05.2026 (day +1)`), footer (last line same), добавить запись в `## 9. История версий`.
6. **Sync CLAUDE.md.** В `CLAUDE.md` §0 row «Открытые вопросы» bump `v1.83+` → `v1.84+`. Помним: CLAUDE.md правится ТОЛЬКО через `/claude-md-management:revise-claude-md` (§5 п.10) — финальный шаг делегируем заказчику или этому skill'у через sub-invocation.
## Validation
После save:
```bash
./bin/lychee.exe --config .lychee.toml docs/Открытые_вопросы_v8_3.md 2>&1 | tail -3
```
Expected: 0 broken links.
Counter arithmetic check: sum of ✅ + ⏸ + 🟦 per prefix = total per prefix.
## Не использовать когда
- Заказчик говорит «закрываем X» — это closure (replace ⏸ → ✅ + дата), не addition. Skip skill, do targeted Edit.
- Item уже существует с тем же текстом — duplicate; уточнить у заказчика или обновить existing.
- Заказчик не давал явного «добавь X в реестр» — Pravila §2.2 запрещает proactive добавление.
-96
View File
@@ -1,96 +0,0 @@
---
name: rls-check
description: |
Verify Row-Level Security on a new or modified table in db/schema.sql.
Use when adding a new table, adding/removing tenant_id column, or modifying
RLS policies. Walks through 7-step checklist (tenant_id, ENABLE RLS, 2+ policies,
5-role GRANTs, db/CHANGELOG_schema.md entry, squawk, smoke test).
disable-model-invocation: true
---
# RLS-check — verify RLS на таблице
## Когда использовать
При добавлении или модификации таблицы в `db/schema.sql`, особенно перед коммитом. Инкапсулирует 7-item checklist; lefthook pre-commit job 7 (squawk) ловит только часть.
Invoke via `/rls-check <table_name>`.
## Checklist
1. **tenant_id column.** Grep `db/schema.sql` для `CREATE TABLE <name>`. Verify:
- `tenant_id UUID NOT NULL REFERENCES tenants(id)` присутствует, **OR**
- SaaS-level exemption — explicit comment типа `-- SaaS-level: no tenant_id (justification)`.
```bash
grep -A30 "CREATE TABLE.*\b<name>\b" db/schema.sql | grep -E "tenant_id|SaaS-level"
```
2. **ENABLE RLS.** Должна быть строка `ALTER TABLE <name> ENABLE ROW LEVEL SECURITY;`.
```bash
grep -E "ALTER TABLE\s+<name>\s+ENABLE ROW LEVEL SECURITY" db/schema.sql
```
3. **Policies — минимум 2.**
- SELECT для `crm_app_user`/`crm_app_admin` с tenant scope: `USING (tenant_id = current_setting('app.current_tenant_id')::uuid)`.
- ALL для `crm_app_admin` (или per-table convention).
- SaaS-level: BYPASSRLS role pattern (e.g., `crm_supplier_worker`).
```bash
grep -B1 -A5 "ON <name>" db/schema.sql | grep "POLICY"
```
4. **Role GRANTs.** В `db/02_grants.sql` должны быть GRANT'ы для 5 ролей. Проверить по pattern existing tables.
```bash
grep -E "GRANT.*ON\s+<name>" db/02_grants.sql
```
Expected: ≥5 GRANT statements (по одному на роль) или group GRANT.
5. **CHANGELOG entry.** В `db/CHANGELOG_schema.md` должна быть запись с датой + table name + summary (CLAUDE.md §5 п.8).
```bash
grep "<name>" db/CHANGELOG_schema.md
```
6. **squawk lint.**
```bash
./bin/squawk.exe db/schema.sql 2>&1 | tail -10
```
Expected: exit 0, no issues.
7. **Smoke test.** `tests/Feature/RlsSmokeTest.php` (или новый тест для конкретной таблицы) должен assert'ить, что user в tenant A не видит row из tenant B для новой таблицы.
```bash
cd app && ./vendor/bin/pest --filter RlsSmokeTest 2>&1 | tail -10
```
Expected: all assertions pass.
## Output
Print результат per item + total:
```text
RLS-check: <table_name>
[✅] tenant_id column
[✅] ENABLE RLS
[✅] SELECT policy
[✅] ALL policy
[✅] 5-role GRANTs
[✅] CHANGELOG entry
[✅] squawk passes
[✅] smoke test passes
Pass: 8/8
```
Or failure listing: `[❌] tenant_id column missing — db/schema.sql:NNNN`.
## Не использовать когда
- Modifying existing well-RLS'd table без новых columns — overhead.
- Tables explicitly outside RLS (e.g., Laravel `migrations`, `cache` — internal).
-85
View File
@@ -1,85 +0,0 @@
name: Accessibility (Pa11y live)
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
a11y:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP 8.3
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: pdo, pdo_pgsql, redis, mbstring, intl, bcmath
coverage: none
- name: Setup Node 20
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install root JS deps
run: npm ci --no-audit --no-fund
- name: Install app composer deps
working-directory: app
run: composer install --no-progress --no-interaction --prefer-dist --optimize-autoloader
- name: Install app JS deps
working-directory: app
run: npm ci --no-audit --no-fund
- name: Bootstrap .env + key
working-directory: app
run: |
cp .env.example .env
php artisan key:generate --force
- name: Prepare SQLite for CI (avoid pg-on-CI fixture cost)
working-directory: app
run: |
touch database/database.sqlite
sed -i 's/DB_CONNECTION=.*/DB_CONNECTION=sqlite/' .env
sed -i 's|DB_DATABASE=.*|DB_DATABASE=/home/runner/work/${{ github.event.repository.name }}/${{ github.event.repository.name }}/app/database/database.sqlite|' .env
- name: Build frontend assets
working-directory: app
run: npm run build
- name: Start Laravel dev-server
working-directory: app
run: nohup php artisan serve --host=127.0.0.1 --port=8000 > /tmp/laravel-serve.log 2>&1 &
- name: Wait for dev-server ready
run: |
for i in {1..30}; do
if curl -s -o /dev/null http://127.0.0.1:8000/login; then
echo "Dev-server up after ${i}s"
exit 0
fi
sleep 1
done
echo "Dev-server did not start within 30s"
tail -50 /tmp/laravel-serve.log
exit 1
- name: Run Pa11y (live Vue)
run: npm run a11y
- name: Upload Pa11y screenshots
if: always()
uses: actions/upload-artifact@v4
with:
name: a11y-screenshots
path: bin/a11y-screenshots/
if-no-files-found: warn
retention-days: 14
-6
View File
@@ -139,9 +139,3 @@ app/infection-summary.log
# Plan 3 Task 5 — Playwright Node subprocess (~200MB chromium downloads on prod)
app/playwright/node_modules/
# Superpowers using-git-worktrees — локальные worktrees вне репо
.claude/worktrees/
# Vitest coverage output (app/coverage/) — генерируется npm run test:coverage
/app/coverage/
-6
View File
@@ -87,12 +87,6 @@ paths = [
'''app/composer\.lock''',
# Pest-тесты с фиктивными data-фикстурами (не реальные ПДн)
'''app/tests/.*\.php''',
# Database seeders с демо-данными (admin@demo.local + +7916123XXXX демо-телефоны)
'''app/database/seeders/.*\.php''',
# Audit-internal docs (findings/blocked/report/plan) — содержат демо-телефоны и
# script-смешанные artifacts как finding'и для review (не реальные ПДн)
'''docs/superpowers/audits/.*\.md''',
'''docs/superpowers/plans/.*\.md''',
# Mock-данные для UI-разводки фронтенда (фиктивные имена/телефоны)
'''app/resources/js/composables/mockDeals\.ts''',
# Vitest-тесты с assertion на mock-данные (mock-телефоны из mockDeals)
-14
View File
@@ -23,20 +23,6 @@
"command": "npx",
"args": ["-y", "semgrep-mcp"],
"comment": "Фаза 3 #25 — Semgrep MCP (SAST). Семантический поиск/анализ кода через Semgrep rules в Claude Code. Пакет: npmjs.com/package/semgrep-mcp — если 404, запустить 'npm search semgrep mcp' для актуального имени."
},
"sentry": {
"command": "npx",
"args": ["-y", "@sentry/mcp-server"],
"env": {
"SENTRY_URL": "${SENTRY_URL}",
"SENTRY_AUTH_TOKEN": "${SENTRY_AUTH_TOKEN}"
},
"comment": "Off-phase tool — Sentry MCP для self-hosted экземпляра в Yandex Cloud (CLAUDE.md §2). Pending формализация в Tooling §3.3 #34 — sync нормативки отдельным планом. Package: @sentry/mcp-server@0.33.0+ (official sentry-bot, repo getsentry/sentry-mcp, bin sentry-mcp). Env vars: SENTRY_URL (https://sentry.<your-domain>.ru), SENTRY_AUTH_TOKEN (PAT scope: sentry:read). Credentials в .env.local (gitignored), Claude Code считывает env из shell startup. Если env пустые — MCP server fail gracefully."
},
"redis": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-redis", "redis://localhost:6379"],
"comment": "Off-phase tool — Redis MCP для Memurai (Windows service, Redis 7-совместимый, localhost:6379). Pending формализация в Tooling §3.3 #35 — sync нормативки отдельным планом. Package: @modelcontextprotocol/server-redis@2025.4.25 — DEPRECATED по статусу npm («Package no longer supported»), но Anthropic source, простой протокол, рабочий. Post-MVP migration на community alternative (e.g., @easy-mcps/redis-mcp-server@1.0.8 или @wenit/redis-mcp-server@1.0.3) когда подтвердим trust. READ-ONLY use — отладка очередей, кэша, Pest --parallel race (memory quirk 72). НЕ для prod (нет prod). Если в будущем prod Redis с auth — отдельный entry redis-prod с url через env var."
}
}
}
+15 -26
View File
File diff suppressed because one or more lines are too long
-334
View File
@@ -1,334 +0,0 @@
# Лидерра CRM — Production Deployment Runbook
**Version:** 1.0 от 2026-05-13
**Stack:** Laravel 13 · Vue 3 + Vuetify 3 · PostgreSQL 16 · Redis 7 · PHP 8.3
**Cloud:** Yandex Cloud, region `ru-central1`
---
## 1. System Requirements
| Component | Version | Notes |
|---|---|---|
| PHP | 8.3+ | Extensions: pdo_pgsql, pgsql, redis, bcmath, mbstring, openssl, tokenizer, xml, ctype, fileinfo, pcntl |
| PostgreSQL | 16 | ICU collation support required (`--with-icu` compile flag) |
| Redis | 7.x | Sessions, queues, cache |
| Node.js | 20+ | Frontend build only |
| Composer | 2.x | |
| Supervisor | 4.x | Queue worker process management |
---
## 2. Environment Configuration
Copy `.env.example` to `.env` and set all required values:
```bash
cp app/.env.example app/.env
```
Critical variables:
```ini
APP_ENV=production
APP_KEY= # php artisan key:generate
APP_URL=https://crm.example.com
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=liderra
DB_USERNAME=crm_migrator # migration role — full DDL rights
DB_PASSWORD=<secret>
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=<secret>
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
CACHE_STORE=redis
MAIL_MAILER=smtp
MAIL_HOST=smtp.unisender.com
MAIL_PORT=587
MAIL_USERNAME=<unisender-go-api-key>
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=noreply@liderra.ru
MAIL_FROM_NAME="Лидерра"
```
---
## 3. Database Setup
### 3.1 Create database with ICU collation
```sql
-- Run as PostgreSQL superuser
CREATE DATABASE liderra
ENCODING 'UTF8'
LOCALE_PROVIDER 'icu'
ICU_LOCALE 'ru-RU'
TEMPLATE template0;
```
### 3.2 Create application roles
```bash
# Run as PostgreSQL superuser
psql -U postgres liderra < db/00_create_roles.sql
```
The script creates 5 roles: `crm_app_user`, `crm_app_admin`, `crm_readonly`, `crm_migrator`, `crm_supplier_worker` (BYPASSRLS).
### 3.3 Run migrations
```bash
cd app
php artisan migrate --force
```
This loads `db/schema.sql` (v8.19+) via the single bootstrap migration `load_initial_schema.php`.
### 3.4 Apply grants
```bash
psql -U postgres liderra < db/02_grants.sql
```
### 3.5 Create initial partition tables
Partitioned tables (`lead_audit_log`, `supplier_session_log`, etc.) require month-based child partitions to exist before any inserts:
```bash
cd app
php artisan partitions:create-months
```
Run this once after migration. The scheduler maintains partitions automatically thereafter (see §7).
### 3.6 Apply pg_audit extension (pre-prod)
```sql
-- Run as PostgreSQL superuser
CREATE EXTENSION IF NOT EXISTS pgaudit;
```
---
## 4. Application Bootstrap
```bash
cd app
# Install PHP dependencies
composer install --no-dev --optimize-autoloader
# Generate app key (first deploy only)
php artisan key:generate --force
# Clear and cache config/routes/views
php artisan config:cache
php artisan route:cache
php artisan view:cache
# Run storage symlink
php artisan storage:link
```
---
## 5. Frontend Build
```bash
cd app
npm ci
npm run build
```
Output goes to `app/public/build/`. Confirm `app/public/build/manifest.json` exists.
---
## 6. Queue Worker (Supervisor)
Create `/etc/supervisor/conf.d/liderra-worker.conf`:
```ini
[program:liderra-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /path/to/app/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/log/liderra-worker.log
stopwaitsecs=3600
```
```bash
supervisorctl reread
supervisorctl update
supervisorctl start liderra-worker:*
```
---
## 7. Scheduler (Cron)
Add to the deployment user's crontab (`crontab -e`):
```cron
* * * * * cd /path/to/app && php artisan schedule:run >> /dev/null 2>&1
```
The scheduler runs these jobs automatically:
| Command/Job | Schedule | Purpose |
|---|---|---|
| `projects:reset-delivered-today` | daily 00:00 MSK | Reset daily lead counter |
| `projects:reset-monthly` | 1st of month 00:00 MSK | Reset monthly counter for tier lookup |
| `partitions:create-months` | daily | Create PostgreSQL partition tables for upcoming months |
| `RefreshSupplierSessionJob` | hourly + 20:15 MSK | Supplier API session tokens |
| `SyncSupplierProjectsJob` | 20:30 MSK daily | Sync supplier project list |
| `CleanupInactiveSupplierProjectsJob` | 02:00 MSK daily | Archive stale supplier projects |
| `supplier:retry-failed` | hourly | Retry failed supplier lead deliveries |
| `CsvReconcileJob` | hourly | CSV reconciliation (reserve lead intake channel) |
---
## 8. Web Server (Nginx)
```nginx
server {
listen 443 ssl;
server_name crm.example.com;
root /path/to/app/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~* \.(js|css|png|jpg|svg|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
```
---
## 9. Health Checks
```bash
# PHP and Laravel bootstrap
cd app && php artisan about
# Database connection
php artisan db:show
# Scheduler registered entries
php artisan schedule:list
# Queue worker status
supervisorctl status liderra-worker:*
# Redis connection
redis-cli -h 127.0.0.1 ping
# Application HTTP
curl -I https://crm.example.com/login
```
Expected responses:
- `php artisan about` — no errors, APP_ENV=production
- `php artisan schedule:list` — 9 entries including `partitions:create-months`
- Redis: `PONG`
- HTTP `/login`: 200
---
## 10. Deployment Sequence (Rolling Update)
```bash
# 1. Pull latest code
git pull origin main
# 2. Install/update dependencies
cd app && composer install --no-dev --optimize-autoloader
npm ci && npm run build
# 3. Run new migrations (if any)
php artisan migrate --force
# 4. Recache configuration
php artisan config:cache
php artisan route:cache
php artisan view:cache
# 5. Restart queue workers (pick up new code)
supervisorctl restart liderra-worker:*
```
---
## 11. Rollback
```bash
# Revert to previous release tag
git checkout <previous-tag>
cd app
composer install --no-dev --optimize-autoloader
npm ci && npm run build
php artisan migrate:rollback # only if the migration is reversible
php artisan config:cache
supervisorctl restart liderra-worker:*
```
> **Warning:** Schema migrations are not always reversible. Always take a PostgreSQL dump before deploying schema changes.
```bash
pg_dump -U crm_migrator liderra > backup_$(date +%Y%m%d_%H%M%S).sql
```
---
## 12. Development Seed
For staging/dev environments only:
```bash
cd app
php artisan db:seed --class=DemoSeeder --force
```
Creates: `admin@demo.local` / `password`, 3 projects, 14 demo deals.
**Never run DemoSeeder on production.**
---
## 13. Common Issues
| Symptom | Likely Cause | Fix |
|---|---|---|
| `SQLSTATE[08006]` on boot | Wrong `DB_HOST` (use `127.0.0.1`, not `localhost` on Windows) | Set `DB_HOST=127.0.0.1` |
| Partition insert error on new month | `partitions:create-months` not run | `php artisan partitions:create-months` |
| Queue jobs not processing | Supervisor not running or wrong user | `supervisorctl status`; check `stdout_logfile` |
| CSS/JS 404 after deploy | Frontend not rebuilt or `storage:link` missing | `npm run build` + `php artisan storage:link` |
| `jwt expired` from supplier API | Supplier session not refreshed | `php artisan tinker``dispatch(new RefreshSupplierSessionJob)` |
| Scheduler not running | Cron not set up | Verify crontab entry; `php artisan schedule:run --verbose` |
+1 -1
View File
@@ -50,7 +50,7 @@ SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
-1
View File
@@ -1,5 +1,4 @@
*.log
.backups/
.DS_Store
.env
.env.backup
-82
View File
@@ -1,82 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
/**
* Eloquent cast for PostgreSQL native INT[] columns.
*
* Laravel stock 'array' cast uses json_encode/json_decode and sends `[1,2,3]`
* (JSON), which Postgres rejects on INT[] columns (expects `{1,2,3}` array
* literal). This cast:
*
* - get(): parses Postgres array literal `{1,2,3}` (or empty `{}`) into PHP
* int array.
* - set(): serializes PHP array `[1,2,3]` into Postgres literal `{1,2,3}`.
*
* Used for projects.regions INT[] (Plan 6).
*
* @implements CastsAttributes<list<int>, list<int>|null>
*/
class PostgresIntArray implements CastsAttributes
{
/**
* @param array<string, mixed> $attributes
* @return list<int>
*/
public function get(Model $model, string $key, mixed $value, array $attributes): array
{
if ($value === null || $value === '' || $value === '{}') {
return [];
}
// PG returns literal like "{1,2,3}".
if (is_string($value)) {
$trimmed = trim($value, '{}');
if ($trimmed === '') {
return [];
}
return array_map('intval', explode(',', $trimmed));
}
// Defensive: if driver already gave array.
if (is_array($value)) {
return array_values(array_map('intval', $value));
}
return [];
}
/**
* @param array<string, mixed> $attributes
*/
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
{
if ($value === null) {
return null;
}
// Defensive: interface phpdoc says list<int>|null, but $value is mixed at PHP level;
// protect against runtime misuse (e.g., string passed mistakenly).
// @phpstan-ignore function.alreadyNarrowedType
if (! is_array($value)) {
throw new \InvalidArgumentException(
"PostgresIntArray cast expects array for key '{$key}', got ".gettype($value)
);
}
if ($value === []) {
return '{}';
}
$ints = array_map('intval', $value);
return '{'.implode(',', $ints).'}';
}
}
@@ -5,160 +5,48 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\BulkProjectActionRequest;
use App\Http\Requests\StoreProjectRequest;
use App\Http\Requests\UpdateProjectRequest;
use App\Http\Resources\ProjectResource;
use App\Models\Project;
use App\Services\Project\ProjectService;
use App\Models\Tenant;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Проекты tenant'а расширенный API для ProjectsView + NewDealDialog.
* Проекты tenant'а — для NewDealDialog dropdown'а и DealsView/Smart-filters.
*
* index: фильтры по signal_type/status/search, пагинация, batch-fetch по ids.
* show: детальная карточка проекта с supplier_links.
*
* Auth: auth:sanctum + tenant middleware (устанавливает app.current_tenant_id для RLS).
* Task 2 Plan 5 заменяет MVP-версию (tenant_id параметром, без auth).
* На MVP: tenant_id параметром. На prod: middleware('auth:sanctum')+'tenant'.
*/
class ProjectController extends Controller
{
public function __construct(private readonly ProjectService $projects) {}
/** GET /api/projects */
/** GET /api/projects?tenant_id={id} */
public function index(Request $request): JsonResponse
{
$query = Project::query()
->with(['supplierB1', 'supplierB2', 'supplierB3']) // eager-load to avoid N+1 in aggregation helpers
->where('tenant_id', $request->user()->tenant_id);
// Batch-fetch по ids — возвращает без пагинации (для dropdown'ов и т.п.)
if ($ids = $request->query('ids')) {
// '?ids=' batch fetch. Non-numeric and zero values silently dropped via intval+filter
// (intval('abc')=0 → array_filter drops 0). Acceptable for a read-only dropdown:
// invalid input produces empty result, not 422.
$idArray = array_filter(array_map('intval', explode(',', (string) $ids)));
$items = $query->whereIn('id', $idArray)->get();
return response()->json(['data' => ProjectResource::collection($items)]);
$tenantId = (int) $request->query('tenant_id', '0');
if ($tenantId < 1) {
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
}
// Фильтр по типу сигнала
if ($type = $request->query('signal_type')) {
$query->where('signal_type', $type);
$tenant = Tenant::find($tenantId);
if ($tenant === null) {
return response()->json(['message' => 'Тенант не найден.'], 404);
}
// Фильтр по статусу жизненного цикла
$status = $request->query('status');
if ($status === 'archived') {
$query->archived();
} elseif ($status === 'active') {
$query->active()->where('is_active', true);
} elseif ($status === 'paused') {
$query->active()->where('is_active', false);
} else {
// По умолчанию: все не архивированные (active + paused)
$query->active();
}
$projects = DB::transaction(function () use ($tenantId) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// Поиск по name и signal_identifier
if ($search = $request->query('search')) {
$query->where(function ($q) use ($search) {
$q->where('name', 'ilike', "%{$search}%")
->orWhere('signal_identifier', 'ilike', "%{$search}%");
});
}
$perPage = min((int) $request->query('per_page', '20'), 100);
$projects = $query->orderBy('created_at', 'desc')->paginate($perPage);
return Project::query()
->where('is_active', true)
->orderBy('name')
->get(['id', 'name', 'tag', 'type']);
});
return response()->json([
'data' => ProjectResource::collection($projects->items()),
'meta' => [
'current_page' => $projects->currentPage(),
'per_page' => $projects->perPage(),
'total' => $projects->total(),
],
'projects' => $projects->map(fn (Project $p) => [
'id' => $p->id,
'name' => $p->name,
'tag' => $p->tag,
'type' => $p->type,
]),
]);
}
/** POST /api/projects */
public function store(StoreProjectRequest $request): JsonResponse
{
$project = $this->projects->create($request->user()->tenant, $request->validated());
return response()->json(['data' => new ProjectResource($project)], 201);
}
/** PATCH /api/projects/{id} */
public function update(UpdateProjectRequest $request, int $id): JsonResponse
{
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
$updated = $this->projects->update($project, $request->validated());
return response()->json(['data' => new ProjectResource($updated)]);
}
/** GET /api/projects/{id} */
public function show(Request $request, int $id): JsonResponse
{
$project = Project::with(['supplierB1', 'supplierB2', 'supplierB3']) // eager-load to avoid N+1
->where('tenant_id', $request->user()->tenant_id)
->findOrFail($id);
return response()->json(['data' => new ProjectResource($project)]);
}
/** DELETE /api/projects/{id} — soft-archive (sets archived_at, is_active=false) */
public function destroy(Request $request, int $id): JsonResponse
{
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
$this->projects->archive($project);
return response()->json(null, 204);
}
/** POST /api/projects/{id}/sync — re-dispatch SyncSupplierProjectJob */
public function sync(Request $request, int $id): JsonResponse
{
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
$this->projects->triggerSync($project);
return response()->json(['queued' => true, 'sync_status' => 'pending'], 202);
}
/** PATCH /api/projects/{id}/toggle-active — flip is_active flag */
public function toggleActive(Request $request, int $id): JsonResponse
{
$request->validate(['is_active' => ['required', 'boolean']]);
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
$project->update(['is_active' => $request->boolean('is_active')]);
return response()->json(['data' => new ProjectResource($project->fresh())]);
}
/** POST /api/projects/bulk — batch pause/resume/archive/update_regions/update_days/update_limit */
public function bulk(BulkProjectActionRequest $request): JsonResponse
{
$tenantId = $request->user()->tenant_id;
$ids = $this->projects->resolveBulkScope(
$tenantId,
$request->validated('ids'),
$request->validated('scope.filter'),
);
if (count($ids) > ProjectService::BULK_MAX) {
return response()->json([
'errors' => ['scope' => ['Слишком много проектов под фильтр (>500). Уточните фильтры или выберите вручную.']],
], 422);
}
$payload = array_merge($request->validated(), ['ids' => $ids]);
$result = $this->projects->bulkAction($tenantId, $request->validated('action'), $payload);
return response()->json($result);
}
}
@@ -1,70 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class BulkProjectActionRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
$action = $this->input('action');
$rules = [
'action' => ['required', Rule::in([
'pause', 'resume', 'archive',
'update_regions', 'update_days', 'update_limit',
])],
'ids' => ['nullable', 'array', 'max:500'],
'ids.*' => ['integer', 'min:1'],
'scope' => ['nullable', 'array'],
'scope.filter' => ['nullable', 'array'],
'scope.filter.signal_type' => ['nullable', 'string', Rule::in(['site', 'call', 'sms'])],
'scope.filter.status' => ['nullable', 'string', Rule::in(['active', 'paused', 'archived'])],
'scope.filter.search' => ['nullable', 'string', 'max:255'],
];
if ($action === 'update_regions' || $action === 'update_days') {
$maxMask = $action === 'update_regions' ? 255 : 127;
$rules['add'] = ['nullable', 'integer', 'min:0', "max:{$maxMask}"];
$rules['remove'] = ['nullable', 'integer', 'min:0', "max:{$maxMask}"];
}
if ($action === 'update_limit') {
$rules['delta'] = ['nullable', 'integer'];
$rules['replace'] = ['nullable', 'integer', 'min:0'];
}
return $rules;
}
public function withValidator($validator): void
{
$validator->after(function ($v) {
$hasIds = ! empty($this->input('ids'));
$hasScope = $this->has('scope.filter') && is_array($this->input('scope.filter'));
if (! $hasIds && ! $hasScope) {
$v->errors()->add('ids', 'Either ids or scope.filter is required.');
}
if ($this->input('action') === 'update_limit') {
$hasDelta = $this->has('delta');
$hasReplace = $this->has('replace');
if ($hasDelta && $hasReplace) {
$v->errors()->add('delta', 'Cannot use both delta and replace.');
}
if (! $hasDelta && ! $hasReplace) {
$v->errors()->add('delta', 'Either delta or replace is required for update_limit.');
}
}
});
}
}
@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreProjectRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
$signalType = $this->input('signal_type');
$base = [
'name' => ['required', 'string', 'max:255'],
'signal_type' => ['required', Rule::in(['site', 'call', 'sms'])],
'daily_limit_target' => ['required', 'integer', 'min:1', 'max:10000'],
// Plan 6: subject-level regions[] заменил region_mask/region_mode на API-уровне.
// Empty array = "вся РФ" (паритет с legacy region_mask=255 + region_mode='include').
// present = поле должно быть в payload (даже если []), enforces explicit choice.
'regions' => ['present', 'array'],
'regions.*' => ['integer', 'between:1,89'],
'delivery_days_mask' => ['required', 'integer', 'min:1', 'max:127'],
];
if ($signalType === 'site') {
$base['signal_identifier'] = ['required', 'string', 'regex:/^[a-z0-9][a-z0-9\-]*(\.[a-z0-9][a-z0-9\-]*)*\.[a-z]{2,}$/i'];
} elseif ($signalType === 'call') {
$base['signal_identifier'] = ['required', 'string', 'regex:/^7\d{10}$/'];
} elseif ($signalType === 'sms') {
$base['sms_senders'] = ['required', 'array', 'min:1'];
$base['sms_senders.*'] = ['string', 'max:11'];
$base['sms_keyword'] = ['nullable', 'string', 'min:1', 'max:50'];
}
return $base;
}
}
@@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
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'],
// Plan 6: subject-level regions[] заменил region_mask/region_mode на API-уровне.
// sometimes = поле omit-able (preserves prior DB value), массив + each 1..89.
'regions' => ['sometimes', 'array'],
'regions.*' => ['integer', 'between:1,89'],
'delivery_days_mask' => ['sometimes', 'integer', 'min:1', 'max:127'],
'sms_senders' => ['sometimes', 'array', 'min:1'],
'sms_senders.*' => ['string', 'max:11'],
'sms_keyword' => ['sometimes', 'nullable', 'string', 'min:1', 'max:50'],
];
}
}
@@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Project;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Project */
class ProjectResource extends JsonResource
{
public function toArray(Request $request): array
{
/** @var Project $project */
$project = $this->resource;
return [
'id' => $this->id,
'name' => $this->name,
'signal_type' => $this->signal_type,
'signal_identifier' => $this->signal_identifier,
'sms_senders' => $this->sms_senders,
'sms_keyword' => $this->sms_keyword,
'daily_limit_target' => $this->daily_limit_target,
'effective_daily_limit_today' => $this->effective_daily_limit_today,
'delivered_today' => $this->delivered_today,
'delivered_in_month' => $this->delivered_in_month,
'is_active' => $this->is_active,
'archived_at' => $project->archived_at?->toIso8601String(),
'region_mask' => $this->region_mask,
'region_mode' => $this->region_mode,
'delivery_days_mask' => $this->delivery_days_mask,
'sync_status' => $this->aggregateSyncStatus(),
'last_synced_at' => $this->aggregateLastSyncedAt(),
'supplier_links' => $this->when(
$request->routeIs('projects.show'),
fn () => $this->getSupplierLinks(),
),
];
}
}
@@ -207,7 +207,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
* Маппинг:
* daily_limit daily_limit_target
* workdays биты delivery_days_mask (bit 0=Пн, , bit 6=Вс) ISO 1..7
* regions projects.regions INT[] (subject codes 1..89) direct copy
* regions биты region_mask (bit 0=Центральный, , bit 7=Дальневосточный) 1..8
*
* @param EloquentCollection<int, Project> $projects
* @return Collection<int, stdClass>
@@ -219,11 +219,12 @@ class SyncSupplierProjectsJob implements ShouldQueue
$obj->daily_limit = (int) $p->daily_limit_target;
$obj->workdays = $this->bitmaskToList((int) $p->delivery_days_mask, 7);
// Plan 6: projects.regions[] напрямую копируется в supplier_projects.current_regions.
// Empty array = "вся РФ" (паритет с supplier API semantics).
// Legacy region_mask/region_mode игнорируются — они dual-write для PhonePrefixService,
// outbound к supplier использует только regions[]. Cleanup в Plan 6.5.
$obj->regions = array_values((array) $p->regions);
// region_mask=255 (все 8 ФО, default) — catch-all семантика → пустой массив
// у supplier ("без региональных ограничений"). Иначе — список выставленных битов.
$regionMask = (int) $p->region_mask;
$obj->regions = $regionMask === 255
? []
: $this->bitmaskToList($regionMask, 8);
return $obj;
})->values();
-105
View File
@@ -1,105 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Project;
use App\Services\Supplier\SupplierPortalClient;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
/**
* Синхронизирует Лидерра-проект с supplier_projects на B1/B2/B3
* в зависимости от signal_type.
*
* Семантика:
* site / call B1 + B2 + B3
* sms с keyword B2 + B3
* sms без keyword B3
*
* Записывает полученные supplier_projects.id в projects.supplier_b{1,2,3}_project_id.
*
* Retry: 3 попытки с backoff [15s, 60s, 300s].
*
* Spec: docs/superpowers/plans/2026-05-11-plan5-frontend-projects-ui-plan.md Task 4
*/
class SyncSupplierProjectJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
/** @var array<int, int> */
public array $backoff = [15, 60, 300];
public function __construct(public int $projectId) {}
public function handle(SupplierPortalClient $client): void
{
$project = Project::find($this->projectId);
if ($project === null) {
Log::warning("SyncSupplierProjectJob: project {$this->projectId} not found — skipping");
return;
}
$platforms = $this->resolvePlatforms($project);
foreach ($platforms as $platform) {
$uniqueKey = $this->buildUniqueKey($project, $platform);
$supplierProjectId = $client->ensureSupplierProject($platform, $project->signal_type, $uniqueKey);
$column = 'supplier_'.strtolower($platform).'_project_id';
$project->{$column} = $supplierProjectId;
}
$project->save();
}
/**
* Возвращает список uppercase platform-кодов для данного project.
* Коды соответствуют CHECK constraint: 'B1' / 'B2' / 'B3'.
*
* @return array<int, string>
*/
private function resolvePlatforms(Project $project): array
{
if (in_array($project->signal_type, ['site', 'call'], true)) {
return ['B1', 'B2', 'B3'];
}
if ($project->signal_type === 'sms') {
return $project->sms_keyword ? ['B2', 'B3'] : ['B3'];
}
return [];
}
/**
* Строит unique_key для пары (project, platform):
* site/call signal_identifier (домен / телефон)
* sms B2 sender + '+' + keyword
* sms B3 sender
*/
private function buildUniqueKey(Project $project, string $platform): string
{
if (in_array($project->signal_type, ['site', 'call'], true)) {
return (string) $project->signal_identifier;
}
// sms
$sender = (string) ($project->sms_senders[0] ?? '');
if ($platform === 'B2') {
return $sender.'+'.($project->sms_keyword ?? '');
}
// B3
return $sender;
}
}
-123
View File
@@ -4,14 +4,11 @@ declare(strict_types=1);
namespace App\Models;
use App\Casts\PostgresIntArray;
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;
/**
* Проект (лид-канал) внутри тенанта.
@@ -39,16 +36,11 @@ 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',
'region_mask',
'region_mode',
// Plan 6 (schema v8.20): Subject-level regions array (89 codes из resources/js/constants/regions.ts).
// Источник истины с Plan 6+; region_mask/region_mode — DEPRECATED (Plan 6.5 cleanup).
'regions',
'delivery_days_mask',
'assignment_strategy',
'ttfr_target_minutes',
@@ -73,10 +65,6 @@ class Project extends Model
'daily_limit_target' => 'integer',
'effective_daily_limit_today' => 'integer',
'region_mask' => 'integer',
// Plan 6: Subject-level regions array (89 codes). Используется кастомный
// PostgresIntArray cast — Laravel stock 'array' посылает JSON `[1,2,3]`,
// что Postgres отвергает на INT[] (ожидает literal `{1,2,3}`).
'regions' => PostgresIntArray::class,
'delivery_days_mask' => 'integer',
'ttfr_target_minutes' => 'integer',
'effective_limit_calculated_at' => 'datetime',
@@ -86,8 +74,6 @@ class Project extends Model
'sms_senders' => 'array',
'delivered_in_month' => 'integer',
'delivered_today' => 'integer',
// Plan 5 Task 1 (schema v8.20): soft archive.
'archived_at' => 'datetime',
];
}
@@ -140,113 +126,4 @@ class Project extends Model
{
return $query->where('signal_type', $signalType)->where('signal_identifier', $identifier);
}
/**
* Не архивированные проекты (archived_at IS NULL).
*
* Внимание: scope не фильтрует is_active. Приостановленные (is_active=false)
* проекты сюда попадают это разные lifecycle-состояния. Если нужны только
* «работающие» (не архив И не на паузе) комбинируйте:
* ->active()->where('is_active', true).
*
* @param Builder<Project> $query
* @return Builder<Project>
*/
public function scopeActive(Builder $query): Builder
{
return $query->whereNull('archived_at');
}
/**
* Архивированные проекты (archived_at IS NOT NULL).
*
* @param Builder<Project> $query
* @return Builder<Project>
*/
public function scopeArchived(Builder $query): Builder
{
return $query->whereNotNull('archived_at');
}
/**
* Все связанные SupplierProject из eager-loaded BelongsTo отношений.
*
* Используется внутри aggregateSyncStatus(), aggregateLastSyncedAt(),
* getSupplierLinks() устраняет N+1 (каждый из трёх методов вызывал
* SupplierProject::find() независимо; теперь читает из уже загруженных
* $this->supplierB1 / supplierB2 / supplierB3).
*
* Требует eager-load: Project::with(['supplierB1', 'supplierB2', 'supplierB3']).
*
* @return Collection<int, SupplierProject>
*/
private function resolvedSupplierProjects(): Collection
{
return collect([$this->supplierB1, $this->supplierB2, $this->supplierB3])->filter()->values();
}
/**
* Агрегированный статус синхронизации по всем связанным SupplierProject.
*
* Логика: если нет ни одного pending; если есть failed failed;
* если есть pending pending; иначе ok.
*
* Читает из eager-loaded отношений (см. resolvedSupplierProjects()).
*/
public function aggregateSyncStatus(): string
{
$statuses = $this->resolvedSupplierProjects()->pluck('sync_status');
if ($statuses->isEmpty()) {
return 'pending';
}
if ($statuses->contains('failed')) {
return 'failed';
}
if ($statuses->contains('pending')) {
return 'pending';
}
return 'ok';
}
/**
* Минимальная дата последней синхронизации по всем связанным SupplierProject.
*
* Использует sortBy по timestamp вместо Collection::min() на Carbon-объектах
* (min() сравнивает строковое представление, что ненадёжно для Carbon).
*
* Читает из eager-loaded отношений (см. resolvedSupplierProjects()).
*/
public function aggregateLastSyncedAt(): ?string
{
$ts = $this->resolvedSupplierProjects()
->pluck('last_synced_at')
->filter()
->sortBy(fn (CarbonInterface $c) => $c->timestamp)
->first();
return $ts?->toIso8601String();
}
/**
* Массив ссылок на связанные SupplierProject (для show endpoint).
*
* Читает из eager-loaded отношений (см. resolvedSupplierProjects()).
*
* @return array<int, array{platform: string, supplier_project_id: int, sync_status: string|null, last_synced_at: string|null}>
*/
public function getSupplierLinks(): array
{
return collect(['b1' => $this->supplierB1, 'b2' => $this->supplierB2, 'b3' => $this->supplierB3])
->filter()
->map(fn (SupplierProject $sp, string $platform) => [
'platform' => $platform,
'supplier_project_id' => $sp->id,
'sync_status' => $sp->sync_status,
'last_synced_at' => $sp->last_synced_at?->toIso8601String(),
])
->values()
->all();
}
}
-3
View File
@@ -44,7 +44,6 @@ class Tenant extends Model
'desired_daily_numbers',
'delivered_in_month',
'api_key_limit',
'limits',
];
protected function casts(): array
@@ -58,8 +57,6 @@ class Tenant extends Model
'desired_daily_numbers' => 'integer',
'delivered_in_month' => 'integer',
'api_key_limit' => 'integer',
// JSONB: {"max_users":5,"max_projects":10,"api_rps":60}
'limits' => 'array',
'webhook_token_rotated_at' => 'datetime',
'last_activity_at' => 'datetime',
'last_webhook_at' => 'datetime',
-205
View File
@@ -1,205 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Project;
use App\Jobs\SyncSupplierProjectJob;
use App\Models\Project;
use App\Models\Tenant;
use Illuminate\Http\Exceptions\HttpResponseException;
class ProjectService
{
public function update(Project $project, array $data): Project
{
// Immutable fields — silently drop (don't 422)
unset(
$data['tenant_id'], $data['signal_type'], $data['signal_identifier'],
$data['delivered_today'], $data['delivered_in_month'],
$data['supplier_b1_project_id'], $data['supplier_b2_project_id'], $data['supplier_b3_project_id'],
$data['archived_at'],
);
if (isset($data['daily_limit_target']) && $data['daily_limit_target'] < $project->delivered_today) {
throw new HttpResponseException(response()->json([
'errors' => [
'daily_limit_target' => [
"Лимит не может быть меньше уже доставленных лидов сегодня ({$project->delivered_today}).",
],
],
], 422));
}
$needsResync = array_key_exists('sms_senders', $data) || array_key_exists('sms_keyword', $data);
$project->update($data);
if ($needsResync) {
SyncSupplierProjectJob::dispatch($project->id);
}
return $project->fresh();
}
public function archive(Project $project): void
{
if ($project->archived_at !== null) {
throw new HttpResponseException(response()->json([
'message' => 'Project уже архивирован.',
], 409));
}
$project->update([
'is_active' => false,
'archived_at' => now(),
]);
}
public function triggerSync(Project $project): void
{
SyncSupplierProjectJob::dispatch($project->id);
}
public const BULK_MAX = 500;
public function resolveBulkScope(int $tenantId, ?array $ids, ?array $filter): array
{
if (! empty($ids)) {
return array_values(array_unique($ids));
}
$query = Project::where('tenant_id', $tenantId);
if (! empty($filter['signal_type'])) {
$query->where('signal_type', $filter['signal_type']);
}
if (! empty($filter['status'])) {
match ($filter['status']) {
'active' => $query->where('is_active', true)->whereNull('archived_at'),
'paused' => $query->where('is_active', false)->whereNull('archived_at'),
'archived' => $query->whereNotNull('archived_at'),
default => null,
};
}
if (! empty($filter['search'])) {
$query->where('name', 'ilike', '%'.$filter['search'].'%');
}
return $query->pluck('id')->all();
}
public function bulkAction(int $tenantId, string $action, array $payload): array
{
$ids = $payload['ids'] ?? [];
if (empty($ids)) {
return ['updated' => 0, 'skipped' => [], 'warnings' => []];
}
$query = Project::where('tenant_id', $tenantId)->whereIn('id', $ids);
return match ($action) {
'pause' => $this->bulkSimpleUpdate($query, ['is_active' => false]),
'resume' => $this->bulkSimpleUpdate($query, ['is_active' => true]),
'archive' => $this->bulkSimpleUpdate($query, ['is_active' => false, 'archived_at' => now()]),
'update_regions' => $this->bulkUpdateRegions($query, $payload),
'update_days' => $this->bulkUpdateDays($query, $payload),
'update_limit' => $this->bulkUpdateLimit($query, $payload),
};
}
private function bulkSimpleUpdate($query, array $update): array
{
$updated = $query->update($update);
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
}
private function bulkUpdateRegions($query, array $payload): array
{
$add = (int) ($payload['add'] ?? 0);
$remove = (int) ($payload['remove'] ?? 0);
// region_mask = (region_mask | add) & ~remove, clamped to 8 bits (0255)
$updated = $query->update([
'region_mask' => \DB::raw("(region_mask | {$add}) & ~{$remove} & 255"),
]);
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
}
private function bulkUpdateDays($query, array $payload): array
{
$add = (int) ($payload['add'] ?? 0);
$remove = (int) ($payload['remove'] ?? 0);
$updated = $query->update([
'delivery_days_mask' => \DB::raw("(delivery_days_mask | {$add}) & ~{$remove} & 127"),
]);
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
}
private function bulkUpdateLimit($query, array $payload): array
{
$delta = $payload['delta'] ?? null;
$replace = $payload['replace'] ?? null;
$projects = (clone $query)->select(['id', 'daily_limit_target', 'delivered_today'])->get();
$updatableIds = [];
$skipped = [];
foreach ($projects as $p) {
$newValue = $replace !== null
? (int) $replace
: (int) $p->daily_limit_target + (int) $delta;
if ($newValue < (int) $p->delivered_today) {
$skipped[] = ['id' => $p->id, 'reason' => 'below_delivered_today'];
} else {
$updatableIds[$p->id] = $newValue;
}
}
$updated = 0;
if (! empty($updatableIds)) {
if ($replace !== null) {
$updated = Project::whereIn('id', array_keys($updatableIds))
->update(['daily_limit_target' => (int) $replace]);
} else {
// delta — обновляем по одному (count bounded by MAX 500).
foreach ($updatableIds as $id => $newValue) {
Project::where('id', $id)->update(['daily_limit_target' => $newValue]);
$updated++;
}
}
}
return ['updated' => $updated, 'skipped' => $skipped, 'warnings' => []];
}
public function create(Tenant $tenant, array $data): Project
{
$limit = (int) ($tenant->limits['max_projects'] ?? 10);
$current = Project::where('tenant_id', $tenant->id)->active()->count();
if ($current >= $limit) {
throw new HttpResponseException(response()->json([
'message' => "Достигнут лимит проектов ({$limit}). Смените тариф.",
], 403));
}
$data['tenant_id'] = $tenant->id;
$data['is_active'] = true;
$data['regions'] = $data['regions'] ?? [];
// Plan 6 dual-write: regions[] источник истины; region_mask/mode — legacy для
// PhonePrefixService / LeadRouter, удаляются в Plan 6.5 после переключения читателей.
$data['region_mask'] = 255;
$data['region_mode'] = 'include';
$project = Project::create($data);
SyncSupplierProjectJob::dispatch($project->id);
return $project->fresh();
}
}
@@ -8,7 +8,6 @@ use App\Exceptions\Supplier\SupplierAuthException;
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Models\SupplierProject;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Carbon\CarbonInterface;
use Illuminate\Http\Client\ConnectionException;
@@ -30,66 +29,12 @@ use Illuminate\Support\Facades\Cache;
* Авторизация: PHPSESSID cookie + X-CSRF-Token header (Redis cache 'supplier:session').
* На 401/403 single retry через dispatch_sync(RefreshSupplierSessionJob).
*/
class SupplierPortalClient
final class SupplierPortalClient
{
public function __construct(
private readonly HttpFactory $http,
) {}
/**
* Идемпотентно обеспечивает наличие supplier_project-записи для переданной
* тройки (platform, signalType, uniqueKey). Если запись уже существует
* возвращает её id. Иначе создаёт проект на стороне поставщика через
* saveProject() и сохраняет новую запись supplier_projects.
*
* Используется SyncSupplierProjectJob (Plan 5 Task 4).
*
* В тестах метод мокируется через $this->mock(SupplierPortalClient::class)
* реальное тело не вызывается.
*
* @param string $platform B1 / B2 / B3
* @param string $signalType site / call / sms
* @param string $uniqueKey domain / phone / sender+keyword / sender
*/
public function ensureSupplierProject(string $platform, string $signalType, string $uniqueKey): int
{
$existing = SupplierProject::query()
->where('platform', $platform)
->where('signal_type', $signalType)
->where('unique_key', $uniqueKey)
->first();
if ($existing !== null) {
return $existing->id;
}
$dto = new SupplierProjectDto(
platform: $platform,
signalType: $signalType,
uniqueKey: $uniqueKey,
limit: 0,
workdays: [1, 2, 3, 4, 5, 6, 7],
regions: [],
regionsReverse: false,
status: 'active',
);
$externalId = $this->saveProject($dto);
$sp = SupplierProject::query()->create([
'platform' => $platform,
'signal_type' => $signalType,
'unique_key' => $uniqueKey,
'supplier_external_id' => (string) $externalId,
'current_limit' => 0,
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
'current_regions' => null,
'sync_status' => 'ok',
]);
return $sp->id;
}
/**
* @return array<int, mixed>
*/
-1
View File
@@ -61,7 +61,6 @@
],
"pint": "@php vendor/bin/pint",
"pint:test": "@php vendor/bin/pint --test",
"test:parallel": "@php vendor/bin/pest --parallel --recreate-databases",
"stan": "@php vendor/bin/phpstan analyse --memory-limit=512M",
"mutation": "@php vendor/bin/infection --threads=2 --min-msi=50",
"audit-offline": "@composer audit --locked",
+1 -1
View File
@@ -20,7 +20,7 @@ class ProjectFactory extends Factory
{
return [
'tenant_id' => Tenant::factory(),
'name' => fake()->unique()->words(3, true),
'name' => fake()->words(3, true),
'type' => 'webhook',
'is_active' => true,
'daily_limit_target' => 10,
@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Guard: schema.sql v8.20+ already contains this column; skip if present
// (prevents "duplicate column" error after `migrate:fresh` which loads schema.sql first).
if (Schema::hasColumn('projects', 'archived_at')) {
return;
}
Schema::table('projects', function (Blueprint $table) {
$table->timestampTz('archived_at')->nullable();
});
}
public function down(): void
{
// Внимание: down() не симметричен up()'у. Если schema.sql v8.20 уже добавил
// archived_at (через migrate:fresh → load_initial_schema), rollback этой
// миграции удалит колонку, что создаст drift с schema.sql. На проекте rollback
// применяется только после migrate:fresh, поэтому это приемлемо — но не
// используйте миграцию как способ отката v8.19 (нужна отдельная schema-bump).
Schema::table('projects', function (Blueprint $table) {
$table->dropColumn('archived_at');
});
}
};
@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Plan 5 Task 3: добавить limits JSONB в tenants.
*
* Используется ProjectService::create() для проверки лимита max_projects.
* Default '{}' (int)($tenant->limits['max_projects'] ?? 10) = 10 из сервиса.
*/
return new class extends Migration
{
public function up(): void
{
if (Schema::hasColumn('tenants', 'limits')) {
return;
}
Schema::table('tenants', function (Blueprint $table) {
// limits JSONB: {"max_users":5,"max_projects":10,"api_rps":60}
// Аналог limits в tariff_plans — per-tenant override лимитов тарифа.
$table->jsonb('limits')->default('{}')->after('api_key_limit');
});
}
public function down(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->dropColumn('limits');
});
}
};
+4 -7
View File
@@ -12,16 +12,13 @@ class DatabaseSeeder extends Seeder
/**
* Seed the application's database.
*
* PricingTierSeeder runs in all environments (prod нуждается в 7-tier
* config bootstrap'е). DemoSeeder только local+testing: создаёт demo
* tenant + admin@demo.local + 3 проекта + ~14 demo сделок для UI smoke.
* Note: the Laravel scaffold default User::factory() seed was removed
* наша схема использует first_name/last_name (а не "name"), и заранее
* не было сценария, где этот seed реально вызывался. PricingTierSeeder
* (Plan 4) единственный текущий seed для dev/testing.
*/
public function run(): void
{
$this->call(PricingTierSeeder::class);
if (app()->environment('local', 'testing')) {
$this->call(DemoSeeder::class);
}
}
}
-197
View File
@@ -1,197 +0,0 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
class DemoSeeder extends Seeder
{
public function run(): void
{
$tenant = Tenant::query()->where('subdomain', 'demo')->first()
?? Tenant::factory()->create([
'subdomain' => 'demo',
'organization_name' => 'Demo Tenant',
'contact_email' => 'admin@demo.local',
'status' => 'active',
'balance_rub' => '1000.00',
'balance_leads' => 100,
'is_trial' => false,
]);
$admin = User::query()->updateOrCreate(
['email' => 'admin@demo.local'],
[
'tenant_id' => $tenant->id,
'password_hash' => Hash::make('password'),
'first_name' => 'Demo',
'last_name' => 'Admin',
'timezone' => 'Europe/Moscow',
'is_active' => true,
'totp_enabled' => false,
'sound_enabled' => true,
'email_verified_at' => now(),
'notification_preferences' => [
'new_lead' => ['inapp' => true, 'push' => true, 'email' => false],
'reminder' => ['inapp' => true, 'push' => true, 'email' => true],
'low_balance' => ['email' => true],
'zero_balance' => ['email' => true],
'topup_success' => ['email' => true],
'invoice_paid' => ['email' => true],
'new_device_login' => ['email' => true],
'marketing' => ['email' => false],
],
]
);
$this->seedProjects($tenant->id);
$this->seedDeals($tenant->id, $admin->id);
$this->command->info("Demo tenant id={$tenant->id} subdomain=demo");
$this->command->info('Login: admin@demo.local / password');
}
private function seedProjects(int $tenantId): void
{
$now = now();
$projects = [
[
'tag' => 'site',
'name' => 'Окна СПб (сайт)',
'type' => 'webhook',
'signal_type' => 'site',
'signal_identifier' => 'okna-konkurent.ru',
'sms_senders' => null,
'sms_keyword' => null,
'daily_limit_target' => 50,
],
[
'tag' => 'call',
'name' => 'Натяжные потолки (звонок)',
'type' => 'webhook',
'signal_type' => 'call',
'signal_identifier' => '79161112233',
'sms_senders' => null,
'sms_keyword' => null,
'daily_limit_target' => 30,
],
[
'tag' => 'sms',
'name' => 'Доставка еды (СМС)',
'type' => 'webhook',
'signal_type' => 'sms',
'signal_identifier' => null,
'sms_senders' => json_encode(['EDA-PROMO', 'YAEDA']),
'sms_keyword' => 'скидка',
'daily_limit_target' => 20,
],
];
foreach ($projects as $p) {
DB::table('projects')->updateOrInsert(
['tenant_id' => $tenantId, 'name' => $p['name']],
[
'tenant_id' => $tenantId,
'name' => $p['name'],
'tag' => $p['tag'],
'type' => $p['type'],
'signal_type' => $p['signal_type'],
'signal_identifier' => $p['signal_identifier'],
'sms_senders' => $p['sms_senders'],
'sms_keyword' => $p['sms_keyword'],
'is_active' => true,
'daily_limit_target' => $p['daily_limit_target'],
'delivered_today' => 0,
'delivered_in_month' => 0,
'region_mask' => 0,
'region_mode' => 'include',
'delivery_days_mask' => 127,
'assignment_strategy' => 'manual',
'ttfr_target_minutes' => 60,
'created_at' => $now,
'updated_at' => $now,
]
);
}
}
private function seedDeals(int $tenantId, int $managerId): void
{
$statuses = DB::table('lead_statuses')->orderBy('sort_order')->get();
$projects = DB::table('projects')
->where('tenant_id', $tenantId)
->orderBy('id')
->get()
->keyBy('signal_type');
$samplePool = [
'site' => [
['name' => 'Иван Петров', 'phone' => '+79161234501', 'utm' => ['source' => 'yandex', 'medium' => 'cpc', 'campaign' => 'okna-spb']],
['name' => 'Анна Смирнова', 'phone' => '+79161234502', 'utm' => ['source' => 'google', 'medium' => 'organic', 'campaign' => null]],
],
'call' => [
['name' => 'Сергей Иванов', 'phone' => '+79161234503', 'utm' => ['source' => 'call', 'medium' => 'direct', 'campaign' => null]],
['name' => 'Мария Кузнецова', 'phone' => '+79161234504', 'utm' => ['source' => 'call', 'medium' => 'direct', 'campaign' => null]],
],
'sms' => [
['name' => 'Дмитрий Соколов', 'phone' => '+79161234505', 'utm' => ['source' => 'sms', 'medium' => 'promo', 'campaign' => 'eda-skidka']],
['name' => 'Елена Морозова', 'phone' => '+79161234506', 'utm' => ['source' => 'sms', 'medium' => 'promo', 'campaign' => 'eda-skidka']],
],
];
$now = now();
$signalCycle = ['site', 'call', 'sms'];
$i = 0;
foreach ($statuses as $status) {
$signal = $signalCycle[$i % 3];
$sample = $samplePool[$signal][$i % 2];
$project = $projects[$signal];
$existing = DB::table('deals')
->where('tenant_id', $tenantId)
->where('phone', $sample['phone'])
->where('status', $status->slug)
->first();
if ($existing) {
$i++;
continue;
}
DB::table('deals')->insert([
'tenant_id' => $tenantId,
'project_id' => $project->id,
'phone' => $sample['phone'],
'phones' => json_encode([$sample['phone']]),
'status' => $status->slug,
'contact_name' => $sample['name'],
'comment' => "Демо-сделка статуса «{$status->name_ru}» ({$signal})",
'manager_id' => $managerId,
'assigned_at' => $now,
'escalated_count' => 0,
'utm_source' => $sample['utm']['source'],
'utm_medium' => $sample['utm']['medium'],
'utm_campaign' => $sample['utm']['campaign'],
'region_code' => $i % 2 === 0 ? '77' : '78',
'city' => $i % 2 === 0 ? 'Москва' : 'Санкт-Петербург',
'time_in_form_seconds' => 30 + $i * 5,
'lead_score' => number_format(50.0 + $i * 3, 2, '.', ''),
'is_test' => false,
'received_at' => $now->copy()->subMinutes($i * 7),
'created_at' => $now,
'updated_at' => $now,
]);
$i++;
}
}
}
-25271
View File
File diff suppressed because it is too large Load Diff
-50
View File
@@ -1,50 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Dev Element Indices Manifest",
"type": "object",
"required": ["version", "lastId", "entries", "deleted"],
"properties": {
"$schema": { "type": "string" },
"version": { "const": 1 },
"lastId": { "type": "integer", "minimum": 0 },
"entries": {
"type": "object",
"patternProperties": {
"^[0-9]+$": {
"type": "object",
"required": ["file", "line", "tag", "parentChain", "signature", "createdAt"],
"properties": {
"file": { "type": "string" },
"line": { "type": "integer", "minimum": 1 },
"tag": { "type": "string" },
"parentChain": { "type": "array", "items": { "type": "string" } },
"signature": { "type": "string" },
"text": { "type": ["string", "null"] },
"key": { "type": ["string", "null"] },
"ref": { "type": ["string", "null"] },
"createdAt": { "type": "string", "format": "date-time" }
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"deleted": {
"type": "object",
"patternProperties": {
"^[0-9]+$": {
"type": "object",
"required": ["lastSignature", "lastFile", "deletedAt"],
"properties": {
"lastSignature": { "type": "string" },
"lastFile": { "type": "string" },
"deletedAt": { "type": "string", "format": "date-time" }
},
"additionalProperties": false
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
+3 -7
View File
@@ -1,14 +1,10 @@
import type { KnipConfig } from 'knip';
const config: KnipConfig = {
entry: [
'resources/js/app.ts',
'resources/js/router/index.ts',
'histoire.config.ts',
'resources/js/histoire.setup.ts',
],
entry: ['resources/js/app.ts', 'resources/js/router/index.ts'],
project: ['resources/js/**/*.{ts,vue}'],
ignore: ['**/*.story.vue'],
ignore: ['**/*.story.vue', 'tests/**'],
ignoreDependencies: ['@vue/test-utils', 'jsdom', 'vitest'],
};
export default config;
+138 -12
View File
@@ -4,9 +4,6 @@
"requires": true,
"packages": {
"": {
"dependencies": {
"lucide-vue-next": "^1.0.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@histoire/plugin-vue": "^1.0.0-beta.1",
@@ -15,6 +12,7 @@
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/test-utils": "^2.4.10",
"axios": "^1.16.0",
"concurrently": "^9.0.1",
"cross-env": "^10.1.0",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
@@ -4318,6 +4316,36 @@
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/change-case": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
@@ -4363,6 +4391,21 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -4424,6 +4467,31 @@
"node": ">=14"
}
},
"node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "4.1.2",
"rxjs": "7.8.2",
"shell-quote": "1.8.3",
"supports-color": "8.1.1",
"tree-kill": "1.2.2",
"yargs": "17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/config-chain": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
@@ -6899,15 +6967,6 @@
"node": "20 || >=22"
}
},
"node_modules/lucide-vue-next": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-1.0.0.tgz",
"integrity": "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==",
"license": "ISC",
"peerDependencies": {
"vue": ">=3.0.1"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -7904,6 +7963,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -9133,6 +9202,16 @@
"node": ">=20"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@@ -10012,6 +10091,24 @@
"node": ">=0.10.0"
}
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs": {
"name": "wrap-ansi",
"version": "7.0.0",
@@ -10113,6 +10210,35 @@
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+1 -4
View File
@@ -11,7 +11,6 @@
"format:check": "prettier --check \"resources/js/**/*.{ts,vue,css}\" \"tests/Frontend/**/*.ts\"",
"type-check": "vue-tsc --noEmit",
"test:vue": "vitest run",
"dx": "node scripts/dev-indices-lookup.mjs",
"story": "histoire dev",
"story:build": "histoire build",
"story:preview": "histoire preview"
@@ -24,6 +23,7 @@
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/test-utils": "^2.4.10",
"axios": "^1.16.0",
"concurrently": "^9.0.1",
"cross-env": "^10.1.0",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
@@ -45,8 +45,5 @@
"vue-tsc": "^3.2.8",
"vuedraggable": "^4.1.0",
"vuetify": "^3.12.5"
},
"dependencies": {
"lucide-vue-next": "^1.0.0"
}
}
+2 -82
View File
@@ -1,25 +1,5 @@
parameters:
ignoreErrors:
# Plan 6 (v8.20): Project::$regions INT[] cast via PostgresIntArray; ide-helper
# regen pending (will resolve after next `php artisan ide-helper:models -W`).
-
message: '#^Access to an undefined property App\\Models\\Project\:\:\$regions\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/Plan5/Projects/ProjectsStoreTest.php
-
message: '#^Access to an undefined property App\\Models\\Project\:\:\$regions\.$#'
identifier: property.notFound
count: 2
path: tests/Feature/Plan5/Projects/ProjectsUpdateTest.php
-
message: '#^Access to an undefined property App\\Models\\Project\:\:\$regions\.$#'
identifier: property.notFound
count: 1
path: app/Jobs/Supplier/SyncSupplierProjectsJob.php
-
message: '#^Expression on left side of \?\? is not nullable\.$#'
identifier: nullCoalesce.expr
@@ -98,12 +78,6 @@ parameters:
count: 1
path: app/Http/Middleware/SetTenantContext.php
-
message: '#^Access to an undefined property App\\Models\\Project\:\:\$archived_at\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/ProjectResource.php
-
message: '#^Using nullsafe property access "\?\-\>name" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
@@ -122,18 +96,6 @@ parameters:
count: 1
path: app/Services/NotificationService.php
-
message: '#^Access to an undefined property App\\Models\\Project\:\:\$archived_at\.$#'
identifier: property.notFound
count: 1
path: app/Services/Project/ProjectService.php
-
message: '#^Match expression does not handle remaining value\: string$#'
identifier: match.unhandled
count: 1
path: app/Services/Project/ProjectService.php
-
message: '#^Return type \(array\<string, mixed\>\) of method Database\\Factories\\ProjectFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\Project, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\Project\>\:\:definition\(\)$#'
identifier: method.childReturnType
@@ -266,12 +228,6 @@ parameters:
count: 13
path: tests/Feature/AdminTenantsIndexTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 14
path: tests/Feature/Api/ProjectBulkActionsTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
@@ -833,13 +789,13 @@ parameters:
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 16
count: 20
path: tests/Feature/LookupsTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 4
count: 5
path: tests/Feature/LookupsTest.php
-
@@ -896,42 +852,6 @@ parameters:
count: 2
path: tests/Feature/PartitionsCreateMonthsTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:mock\(\)\.$#'
identifier: method.notFound
count: 6
path: tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php
-
message: '#^Access to an undefined property App\\Models\\Project\:\:\$archived_at\.$#'
identifier: property.notFound
count: 2
path: tests/Feature/Plan5/Projects/ProjectsActionsTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 9
path: tests/Feature/Plan5/Projects/ProjectsActionsTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 12
path: tests/Feature/Plan5/Projects/ProjectsListShowTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 12
path: tests/Feature/Plan5/Projects/ProjectsStoreTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 8
path: tests/Feature/Plan5/Projects/ProjectsUpdateTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
-51
View File
@@ -23,54 +23,3 @@ body {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum' 1;
}
/*
* A11y override: Vuetify .v-messages helper-text + .v-field-label opacity
* (~0.52 default) рендерится #7a7a7a/#767471 contrast 4.20-4.29 fails
* WCAG 2.1 AA 4.5:1. Q.DEFER.002 fix (12.05.2026 audit): локально bump до 0.7
* rendered #595959 7.9:1+.
*/
.v-messages,
.v-field-label {
--v-medium-emphasis-opacity: 0.7;
}
/*
* A11y rescan 2026-05-14: Vuetify tonal-variant default text color produces
* 2.0-4.4:1 contrast on ivory page background (#f6f3ec) below WCAG 2.1 AA
* 4.5:1 threshold. Pa11y rescan flagged across /billing /admin/billing
* /admin/incidents /admin/system. Fix: darken text color inside .v-alert and
* .v-chip tonal variants; also darken .text-warning utility used in count
* badges (text-h6 text-warning «5» on ivory was 2.03:1).
*/
.v-alert--variant-tonal .v-alert__content,
.v-alert--variant-tonal .v-alert__content strong,
.v-alert--variant-tonal .v-alert__content code {
color: #0a0700;
}
.v-chip--variant-tonal.bg-success .v-chip__content,
.v-chip--variant-tonal.text-success .v-chip__content {
/* deep forest green, ≥4.5:1 on tonal pale-success bg */
color: #1f5e3a;
}
.v-chip--variant-tonal.bg-warning .v-chip__content,
.v-chip--variant-tonal.text-warning .v-chip__content {
/* dark amber, ≥4.5:1 on tonal pale-warning bg + on ivory page bg */
color: #6a4504;
}
/*
* .text-warning is used both inside chips (covered above) and standalone
* (text-h6 count badges on ivory background). Vuetify defines the utility as
* `.v-theme--liderraForest .text-warning { color: rgb(var(--v-theme-warning)) !important }`
* which has specificity 0,2,0 + !important plain `.text-warning !important`
* (0,1,0) loses on specificity even with !important. Match Vuetify's selector
* exactly so our override wins on cascade-order (loaded after Vuetify CSS).
*/
.v-theme--liderraForest .text-warning,
.v-theme--liderraForest.text-warning,
.text-warning {
color: #6a4504 !important;
}
-132
View File
@@ -1,132 +0,0 @@
/* app/resources/css/motion.css
* Liderra motion-инфраструктура. 7 паттернов + reduced-motion wrapper.
* Spec: §9.
*/
/* === keyframes === */
@keyframes ld-fadeup {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: none; }
}
@keyframes ld-slideup {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
@keyframes ld-shimmer {
0% { background-position: -200px 0; }
100% { background-position: 200px 0; }
}
@keyframes ld-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.6); opacity: 0.4; }
}
@keyframes ld-dialog-in {
0% { opacity: 0; transform: scale(0.94) translateY(8px); }
100% { opacity: 1; transform: scale(1) translateY(0); }
}
/* === Utilities === */
/* motion #4 — Hover lift */
.ld-hover-lift {
transition:
transform 200ms cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
.ld-hover-lift:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-2);
}
/* motion #2 — Stagger list (применяется к строкам таблиц/списков; mount-only) */
.ld-stagger-row {
animation: ld-slideup 400ms cubic-bezier(0.16, 1, 0.3, 1) backwards;
}
.ld-stagger-row:nth-child(1) { animation-delay: 0ms; }
.ld-stagger-row:nth-child(2) { animation-delay: 50ms; }
.ld-stagger-row:nth-child(3) { animation-delay: 100ms; }
.ld-stagger-row:nth-child(4) { animation-delay: 150ms; }
.ld-stagger-row:nth-child(5) { animation-delay: 200ms; }
.ld-stagger-row:nth-child(6) { animation-delay: 250ms; }
.ld-stagger-row:nth-child(7) { animation-delay: 300ms; }
.ld-stagger-row:nth-child(8) { animation-delay: 350ms; }
.ld-stagger-row:nth-child(9) { animation-delay: 400ms; }
.ld-stagger-row:nth-child(10) { animation-delay: 450ms; }
/* motion #5 — Skeleton shimmer */
.ld-skeleton {
background: linear-gradient(
90deg,
rgba(1, 32, 25, 0.06) 0%,
rgba(1, 32, 25, 0.12) 50%,
rgba(1, 32, 25, 0.06) 100%
);
background-size: 400px 100%;
animation: ld-shimmer 1400ms infinite linear;
border-radius: var(--radius-6);
}
/* motion #10 (auxiliary) — Live pulse */
.ld-pulse {
position: relative;
display: inline-block;
width: 8px;
height: 8px;
border-radius: var(--radius-full);
background: var(--liderra-teal);
}
.ld-pulse::after {
content: '';
position: absolute;
inset: 0;
border-radius: var(--radius-full);
background: var(--liderra-teal);
animation: ld-pulse 1800ms infinite cubic-bezier(0.4, 0, 0.6, 1);
}
/* motion #6 — Page transition (View Transitions API + CSS fallback) */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 280ms;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}
::view-transition-old(root) {
animation-name: ld-fadeout-up;
}
::view-transition-new(root) {
animation-name: ld-fadeup;
}
@keyframes ld-fadeout-up {
from { opacity: 1; transform: none; }
to { opacity: 0; transform: translateY(-4px); }
}
/* CSS fallback для router transition */
.ld-route-fadeup-enter-active,
.ld-route-fadeup-leave-active {
transition: opacity 280ms cubic-bezier(0.16, 1, 0.3, 1),
transform 280ms cubic-bezier(0.16, 1, 0.3, 1);
}
.ld-route-fadeup-enter-from { opacity: 0; transform: translateY(4px); }
.ld-route-fadeup-leave-to { opacity: 0; transform: translateY(-4px); }
/* === Reduced motion — отключаем всё === */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
-47
View File
@@ -1,47 +0,0 @@
/* app/resources/css/tokens.css
* Liderra Forest design tokens (Iteration 1 Quiet Luxury).
* Spec: docs/superpowers/specs/2026-05-12-portal-redesign-quiet-luxury-design.md
*/
:root {
/* ===== Палитра (12 токенов) ===== */
--liderra-teal: #0F6E56;
--liderra-teal-deep: #0A5A47;
--liderra-noir: #012019;
--liderra-ivory: #F6F3EC;
--liderra-surface: #FFFFFF;
--liderra-muted: #6B6356;
--liderra-success: #2E8B57;
--liderra-saffron: #D9A441;
--liderra-error: #B83A3A;
--liderra-info: #3F7C95;
--liderra-plum: #7A5BA3;
--liderra-salmon: #CC6E50;
/* ===== Тонкие поверхности ===== */
--liderra-line: rgba(1, 32, 25, 0.08);
--liderra-line-strong: rgba(1, 32, 25, 0.14);
/* ===== Spacing (4pt grid) ===== */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--space-8: 32px;
--space-12: 48px;
--space-16: 64px;
/* ===== Радиусы ===== */
--radius-6: 6px;
--radius-8: 8px;
--radius-10: 10px;
--radius-12: 12px;
--radius-14: 14px;
--radius-full: 999px;
/* ===== Shadows (ambient + key, двухслойные) ===== */
--shadow-1: 0 1px 2px rgba(1, 32, 25, 0.04);
--shadow-2: 0 4px 12px rgba(1, 32, 25, 0.06), 0 1px 2px rgba(1, 32, 25, 0.04);
--shadow-3: 0 12px 28px rgba(1, 32, 25, 0.10);
--shadow-4: 0 24px 48px rgba(1, 32, 25, 0.16);
}
-84
View File
@@ -1,84 +0,0 @@
/* app/resources/css/typography.css
* Liderra typography Inter (UI) + JetBrains Mono (numerics) с tnum.
*/
@import url('https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,300..700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap');
html,
body {
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
font-feature-settings: 'tnum' 1, 'cv11' 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.ld-mono {
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
font-feature-settings: 'tnum' 1;
letter-spacing: -0.01em;
}
/* Шкала (см. spec §4) */
.ld-label {
font-size: 11px;
line-height: 14px;
font-weight: 500;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--liderra-muted);
}
.ld-body {
font-size: 13px;
line-height: 20px;
font-weight: 400;
}
.ld-body-strong {
font-size: 15px;
line-height: 22px;
font-weight: 500;
}
.ld-h3 {
font-size: 17px;
line-height: 24px;
font-weight: 600;
letter-spacing: -0.01em;
}
.ld-h2 {
font-size: 22px;
line-height: 28px;
font-weight: 600;
letter-spacing: -0.015em;
}
.ld-h1 {
font-size: 28px;
line-height: 36px;
font-weight: 600;
letter-spacing: -0.02em;
}
.ld-hero {
font-size: clamp(30px, 5vw, 48px);
font-weight: 600;
letter-spacing: -0.025em;
line-height: 1.1;
}
.ld-mono-xl {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 28px;
font-weight: 500;
letter-spacing: -0.02em;
font-feature-settings: 'tnum' 1;
}
.ld-mono-s {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 11px;
font-weight: 400;
font-feature-settings: 'tnum' 1;
}
+4 -4
View File
@@ -113,7 +113,7 @@ export interface AdminTenant {
created_at: string | null;
}
interface AdminTenantsStats {
export interface AdminTenantsStats {
total: number;
active: number;
trial: number;
@@ -182,7 +182,7 @@ export interface ApiTenantActivityEvent {
created_at: string;
}
interface ApiTenantMetrics {
export interface ApiTenantMetrics {
leads_today: number;
leads_this_week: number;
leads_this_month: number;
@@ -224,7 +224,7 @@ export interface ApiAdminBillingTenant {
chargeback_unrecovered_rub: string;
}
interface ApiAdminBillingSummary {
export interface ApiAdminBillingSummary {
total_mrr_rub: string;
monthly_revenue_rub: string;
overdue_count: number;
@@ -262,7 +262,7 @@ export interface ApiAdminIncident {
rkn_deadline_at: string | null;
}
interface ApiAdminIncidentsSummary {
export interface ApiAdminIncidentsSummary {
open: number;
investigating: number;
rkn_pending: number;
+1 -1
View File
@@ -7,7 +7,7 @@ import { apiClient, ensureCsrfCookie } from './client';
* Mutating-вызовы (mark-read/mark-all-read/destroy) делают ensureCsrfCookie().
*/
type NotificationEvent =
export type NotificationEvent =
| 'new_lead'
| 'reminder'
| 'low_balance'
+5 -5
View File
@@ -12,11 +12,11 @@ import { apiClient, ensureCsrfCookie } from './client';
export type ApiReportStatus = 'pending' | 'processing' | 'done' | 'failed';
type ApiReportType = 'deals_export' | 'managers_summary' | 'sources_summary' | 'billing_summary';
export type ApiReportType = 'deals_export' | 'managers_summary' | 'sources_summary' | 'billing_summary';
type ApiReportFormat = 'csv' | 'xlsx' | 'json' | 'pdf';
export type ApiReportFormat = 'csv' | 'xlsx' | 'json' | 'pdf';
interface ApiReportParameters {
export interface ApiReportParameters {
format: ApiReportFormat;
date_from: string;
date_to: string;
@@ -43,14 +43,14 @@ export interface ApiReportJob {
retry_max: number;
}
interface ReportCounts {
export interface ReportCounts {
pending: number;
processing: number;
done: number;
failed: number;
}
interface ReportQuota {
export interface ReportQuota {
active: number;
max_active: number;
}
-3
View File
@@ -2,9 +2,6 @@ import { createPinia } from 'pinia';
import { createApp } from 'vue';
import AppShell from './components/AppShell.vue';
import { vuetify } from './plugins/vuetify';
import '../css/tokens.css';
import '../css/typography.css';
import '../css/motion.css';
import { router } from './router';
// Точка входа Vue 3 + Vuetify 3 + Vue Router 4 + Pinia (фаза 2, CLAUDE.md §3.3).
+1 -7
View File
@@ -9,7 +9,7 @@
* Источник дизайна: liderra_v8_handoff/concepts/v8_login.html (auth),
* v8_dashboard.html (app), v8_errors.html (error).
*/
import { computed, defineAsyncComponent, type Component } from 'vue';
import { computed } from 'vue';
import { RouterView, useRoute } from 'vue-router';
import AdminLayout from '../layouts/AdminLayout.vue';
import AppLayout from '../layouts/AppLayout.vue';
@@ -17,11 +17,6 @@ import AuthLayout from '../layouts/AuthLayout.vue';
const route = useRoute();
const layoutName = computed(() => route.meta.layout ?? 'app');
// Dev-only overlay: tree-shaken from production bundle via import.meta.env.DEV guard.
const DevIndexOverlay: Component | null = import.meta.env.DEV
? defineAsyncComponent(() => import('./DevIndexOverlay.vue'))
: null;
</script>
<template>
@@ -29,5 +24,4 @@ const DevIndexOverlay: Component | null = import.meta.env.DEV
<RouterView v-else-if="layoutName === 'error'" />
<AdminLayout v-else-if="layoutName === 'admin'" />
<AppLayout v-else />
<component :is="DevIndexOverlay" v-if="DevIndexOverlay" />
</template>
@@ -1,61 +0,0 @@
<template>
<div v-if="index" class="dev-index-badge" :class="{ 'is-dialog': dialogMode }">
<span class="dev-index-num">{{ index }}</span>
<span class="dev-index-label">{{ label }}</span>
</div>
</template>
<script setup lang="ts">
/**
* Dev-only визуальный badge: показывает индекс и название текущего экрана/компонента
* для упрощения обратной связи на localhost («элемент 16: бага X»).
*
* Использование:
* - Layout-уровень: `<DevIndexBadge :index="route.meta.devIndex" :label="route.meta.devLabel" />`
* - Inline (диалоги/sub-компоненты): `<DevIndexBadge :index="18" label="NewProjectDialog" :dialog-mode="true" />`
*
* Не отображается если `index` falsy (null/undefined/0/'').
* `dialogMode` переключает position: fixed absolute для встраивания внутрь карточек.
*/
defineProps<{
index: number | string | null | undefined;
label?: string;
dialogMode?: boolean;
}>();
</script>
<style scoped>
.dev-index-badge {
position: fixed;
top: 64px;
right: 8px;
z-index: 9000;
display: inline-flex;
align-items: center;
gap: 6px;
background: #0f6e56;
color: #fff;
padding: 4px 10px;
border-radius: 4px;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 11px;
font-weight: 600;
pointer-events: none;
opacity: 0.92;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18);
}
.dev-index-badge.is-dialog {
position: absolute;
z-index: 10000;
}
.dev-index-num {
background: rgba(255, 255, 255, 0.22);
padding: 1px 6px;
border-radius: 3px;
min-width: 22px;
text-align: center;
}
.dev-index-label {
letter-spacing: 0.02em;
}
</style>
@@ -1,221 +0,0 @@
<template>
<Teleport to="body">
<div
v-if="currentId !== null && currentTarget"
class="dx-badge"
:class="{ 'dx-badge--copied': justCopied }"
:style="badgePosition"
@click.stop="copyToClipboard"
>
<span class="dx-badge__num">#{{ currentId }}</span>
<span class="dx-badge__meta">{{ tagLabel }} · "{{ textPreview }}"</span>
</div>
</Teleport>
<Teleport to="body">
<div v-if="overlayMode" class="dx-mini-layer">
<div v-for="el in overlayElements" :key="el.id" class="dx-mini" :style="miniStyleFor(el.rect)">
#{{ el.id }}
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue';
import { useDevIndices } from '../composables/useDevIndices';
const {
currentId,
currentTarget,
hoverEnabled,
overlayMode,
setTarget,
reset,
pauseHover,
walkToParent,
walkToChild,
toggleOverlay,
} = useDevIndices();
const cursorX = ref(0);
const cursorY = ref(0);
const justCopied = ref(false);
let mousemoveRAF: number | null = null;
const tagLabel = computed(() => {
const t = currentTarget.value;
if (!t) return '';
return t.tagName.toLowerCase();
});
const textPreview = computed(() => {
const t = currentTarget.value;
if (!t) return '';
const text = (t.textContent ?? '').trim().slice(0, 24);
return text || '—';
});
const badgePosition = computed(() => ({
left: `${cursorX.value + 12}px`,
top: `${cursorY.value + 12}px`,
}));
interface OverlayItem {
id: number;
rect: DOMRect;
}
const overlayElements = ref<OverlayItem[]>([]);
function refreshOverlayElements() {
const nodes = Array.from(document.querySelectorAll<HTMLElement>('[data-dx]'));
overlayElements.value = nodes
.map((el) => {
const idAttr = el.getAttribute('data-dx');
const id = Number(idAttr);
if (!Number.isFinite(id)) return null;
return { id, rect: el.getBoundingClientRect() };
})
.filter((x): x is OverlayItem => x !== null);
}
function miniStyleFor(rect: DOMRect) {
return {
left: `${rect.left}px`,
top: `${rect.top}px`,
};
}
watch(overlayMode, (on) => {
if (on) {
refreshOverlayElements();
window.addEventListener('resize', refreshOverlayElements);
window.addEventListener('scroll', refreshOverlayElements, true);
} else {
overlayElements.value = [];
window.removeEventListener('resize', refreshOverlayElements);
window.removeEventListener('scroll', refreshOverlayElements, true);
}
});
function onMousemove(e: MouseEvent) {
if (!hoverEnabled.value) return;
cursorX.value = e.clientX;
cursorY.value = e.clientY;
if (mousemoveRAF !== null) return;
mousemoveRAF = requestAnimationFrame(() => {
mousemoveRAF = null;
const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
if (!el) {
setTarget(null);
return;
}
const withDx = el.closest('[data-dx]') as HTMLElement | null;
setTarget(withDx);
});
}
function onKeydown(e: KeyboardEvent) {
if (e.altKey && e.shiftKey && (e.key === 'I' || e.key === 'i')) {
e.preventDefault();
toggleOverlay();
return;
}
if (e.altKey && e.key === 'ArrowUp') {
e.preventDefault();
walkToParent();
pauseHover(800);
return;
}
if (e.altKey && e.key === 'ArrowDown') {
e.preventDefault();
walkToChild();
pauseHover(800);
return;
}
if (e.key === 'Escape') {
reset();
pauseHover(2000);
}
}
async function copyToClipboard() {
if (currentId.value === null) return;
try {
await navigator.clipboard.writeText(`#${currentId.value}`);
justCopied.value = true;
setTimeout(() => (justCopied.value = false), 400);
} catch {
// clipboard may be unavailable in some contexts; silent fail OK in dev tool
}
}
onMounted(() => {
document.addEventListener('mousemove', onMousemove);
document.addEventListener('keydown', onKeydown);
});
onBeforeUnmount(() => {
document.removeEventListener('mousemove', onMousemove);
document.removeEventListener('keydown', onKeydown);
if (mousemoveRAF !== null) cancelAnimationFrame(mousemoveRAF);
if (overlayMode.value) {
window.removeEventListener('resize', refreshOverlayElements);
window.removeEventListener('scroll', refreshOverlayElements, true);
}
});
</script>
<style scoped>
.dx-badge {
position: fixed;
z-index: 999999;
pointer-events: auto;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 10px;
background: #0f6e56;
color: #fff;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 11px;
line-height: 1.4;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.24);
user-select: none;
transition: background 120ms ease;
}
.dx-badge--copied {
background: #21a16e;
}
.dx-badge__num {
background: rgba(255, 255, 255, 0.22);
padding: 1px 6px;
border-radius: 3px;
font-weight: 600;
}
.dx-badge__meta {
letter-spacing: 0.02em;
opacity: 0.92;
}
.dx-mini-layer {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 999998;
}
.dx-mini {
position: fixed;
background: #0f6e56;
color: #fff;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 9px;
line-height: 1;
padding: 1px 3px;
border-radius: 2px;
pointer-events: none;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
}
</style>
@@ -117,8 +117,7 @@ const emit = defineEmits<{
align-items: center;
}
.page-meta .sep {
/* WCAG2AA 4.5:1 fix (was #92907b → 2.92:1 on ivory; #6b6356 → 5.33:1). */
color: #6b6356;
color: #92907b;
}
.head-actions {
display: flex;
@@ -57,10 +57,7 @@ const emit = defineEmits<{
</td>
<td class="num text-caption text-medium-emphasis">{{ tx.id }}</td>
<td>{{ tx.description }}</td>
<td
class="text-end num"
:class="{ 'text-error': tx.amount < 0, 'text-success': tx.amount > 0 }"
>
<td class="text-end num" :class="{ 'text-error': tx.amount < 0, 'text-success': tx.amount > 0 }">
{{ formatRub(tx.amount) }}
</td>
</tr>
@@ -26,25 +26,16 @@ function formatRub(v: number): string {
<div>
<h1 class="text-h4 mb-2 page-title">Тенанты</h1>
<div class="page-stats text-body-2 text-medium-emphasis">
<span
><span class="num">{{ stats.total }}</span> всего</span
>
<span><span class="num">{{ stats.total }}</span> всего</span>
<span class="sep">·</span>
<span
><span class="num text-success">{{ stats.active }}</span> активны</span
>
<span><span class="num text-success">{{ stats.active }}</span> активны</span>
<span class="sep">·</span>
<span
><span class="num">{{ stats.trial }}</span> trial</span
>
<span><span class="num">{{ stats.trial }}</span> trial</span>
<span class="sep">·</span>
<span
><span class="num text-warning">{{ stats.overdue }}</span> просрочка</span
>
<span><span class="num text-warning">{{ stats.overdue }}</span> просрочка</span>
<span class="sep">·</span>
<span
>выручка месяц <span class="num text-primary">{{ formatRub(stats.monthlyRevenueRub) }}</span></span
>
<span>выручка месяц
<span class="num text-primary">{{ formatRub(stats.monthlyRevenueRub) }}</span></span>
</div>
</div>
<div class="d-flex ga-2">
@@ -81,8 +72,7 @@ function formatRub(v: number): string {
align-items: center;
}
.page-stats .sep {
/* WCAG2AA 4.5:1 fix (was #92907b → 2.92:1 on ivory; #6b6356 → 5.33:1). */
color: #6b6356;
color: #92907b;
}
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
@@ -40,7 +40,7 @@ function statusColor(s: TenantStatus): string {
{ title: 'Желаем×факт сегодня', key: 'today', align: 'end', sortable: false },
{ title: 'MRR', key: 'mrrRub', align: 'end', sortable: false },
{ title: 'Активность', key: 'activitySince', sortable: false },
{ title: 'Действия', key: 'actions', align: 'end', sortable: false, width: 56 },
{ title: '', key: 'actions', align: 'end', sortable: false, width: 56 },
]"
items-per-page="-1"
hide-default-footer
@@ -78,11 +78,7 @@ function statusColor(s: TenantStatus): string {
<span class="num text-medium-emphasis">{{ item.activitySince }}</span>
</template>
<template #[`item.actions`]="{ item }: { item: AdminTenant }">
<v-tooltip
text="Войти как клиент (impersonation)"
location="top"
aria-label="Войти как клиент (impersonation)"
>
<v-tooltip text="Войти как клиент (impersonation)" location="top">
<template #activator="{ props: tipProps }">
<v-btn
v-bind="tipProps"
@@ -90,7 +86,6 @@ function statusColor(s: TenantStatus): string {
variant="text"
size="small"
density="comfortable"
:aria-label="`Войти как клиент (impersonation) для ${item.name}`"
:disabled="item.status === 'suspended'"
:data-testid="`impersonate-btn-${item.id}`"
@click.stop="emit('impersonate', item)"
@@ -4,8 +4,17 @@
* (Все / Пополнения / Списания / Возвраты). Sprint 4 Phase B/2 split BillingView.
*/
import { computed, ref } from 'vue';
import { BILLING_TABS, MOCK_TRANSACTIONS, type BillingTransaction } from '../../composables/mockBilling';
import { formatCost, statusChipColor, statusLabel, txAmountClass } from '../../composables/billingFormatters';
import {
BILLING_TABS,
MOCK_TRANSACTIONS,
type BillingTransaction,
} from '../../composables/mockBilling';
import {
formatCost,
statusChipColor,
statusLabel,
txAmountClass,
} from '../../composables/billingFormatters';
const activeTab = ref<(typeof BILLING_TABS)[number]['id']>('all');
@@ -93,7 +102,7 @@ const filteredTransactions = computed<BillingTransaction[]>(() => {
color: #66635c;
}
.tx-amount-up {
color: #1b6e3b;
color: #2e8b57;
}
.tx-amount-down {
color: #b83a3a;
@@ -31,7 +31,6 @@ defineProps<{
<div class="runway mt-3">
<div
class="runway-bar"
role="img"
:aria-label="`Хватит на ${balance.runwayDays} дня из ${balance.runwayMax}`"
>
<span
@@ -1,15 +1,10 @@
<script setup lang="ts">
/**
* DashboardKpiRow 3 KPI-карты (получено лидов / конверсия / активные проекты).
* Numerics через JetBrains Mono с tabular-nums + count-up анимация (motion #1).
* Numerics через JetBrains Mono с tabular-nums.
*
* Sprint 4 Phase B/3 split DashboardView (audit O-refactor-04 закрытие).
* Task 14 (Quiet Luxury) добавлены ld-kpi__value/ld-kpi__label классы и
* count-up через useCountUp композабл. Respects prefers-reduced-motion.
*/
import { onMounted, ref, watch, type Ref } from 'vue';
import { useCountUp } from '../../composables/useCountUp';
export interface Kpi {
label: string;
value: string;
@@ -18,85 +13,17 @@ export interface Kpi {
sub: string;
}
const props = defineProps<{
defineProps<{
kpis: Kpi[];
}>();
/**
* Парсит KPI value-строку в число. Поддерживает:
* - целые ('247', '8')
* - дробные ('18.4')
* - с пробелами как тысячными ('14 250')
*/
function parseNumeric(raw: string): { value: number; precision: number } {
const cleaned = raw.replace(/\s+/g, '').replace(',', '.');
const value = parseFloat(cleaned);
if (Number.isNaN(value)) return { value: 0, precision: 0 };
const dotIdx = cleaned.indexOf('.');
const precision = dotIdx === -1 ? 0 : cleaned.length - dotIdx - 1;
return { value, precision };
}
/**
* Форматирует число обратно с пробелами как тысячными
* (чтобы '14 250' выводилось так же, а не '14250').
*/
function formatNumber(value: number, precision: number): string {
const fixed = precision === 0 ? Math.round(value).toString() : value.toFixed(precision);
const [intPart, decPart] = fixed.split('.');
const withSpaces = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
return decPart === undefined ? withSpaces : `${withSpaces}.${decPart}`;
}
interface AnimationSlot {
target: Ref<number>;
display: Ref<number>;
start: () => void;
precision: number;
}
const slots: AnimationSlot[] = [];
function rebuildSlots(): void {
slots.length = 0;
for (const kpi of props.kpis) {
const { value, precision } = parseNumeric(kpi.value);
const target = ref(value);
const { display, start } = useCountUp(target, { duration: 600, precision });
slots.push({ target, display, start, precision });
}
}
rebuildSlots();
// Если props.kpis сменился (новый range / refetch) пересобираем слоты
// и перезапускаем анимацию.
watch(
() => props.kpis,
() => {
rebuildSlots();
slots.forEach((s) => s.start());
},
{ deep: true },
);
onMounted(() => {
slots.forEach((s) => s.start());
});
function displayFor(idx: number): string {
const slot = slots[idx];
if (!slot) return '';
return formatNumber(slot.display.value, slot.precision);
}
</script>
<template>
<v-col v-for="(kpi, idx) in kpis" :key="kpi.label" cols="12" sm="6" md="3">
<v-col v-for="kpi in kpis" :key="kpi.label" cols="12" sm="6" md="3">
<v-card variant="outlined" class="kpi-card pa-4">
<div class="kpi-label ld-kpi__label ld-label text-body-2 text-medium-emphasis">{{ kpi.label }}</div>
<div class="kpi-value ld-kpi__value ld-mono">
{{ displayFor(idx) }}
<div class="kpi-label text-body-2 text-medium-emphasis">{{ kpi.label }}</div>
<div class="kpi-value">
{{ kpi.value }}
<span v-if="kpi.unit" class="kpi-unit">{{ kpi.unit }}</span>
</div>
<div class="kpi-foot text-caption text-medium-emphasis mt-2">
@@ -162,7 +89,7 @@ function displayFor(idx: number): string {
font-weight: 500;
}
.delta-up {
color: #1b6e3b;
color: #2e8b57;
}
.delta-down {
color: #b83a3a;
@@ -55,8 +55,7 @@ const range = defineModel<'today' | '7d' | '30d' | 'custom'>({ required: true })
align-items: center;
}
.page-meta .sep {
/* WCAG2AA 4.5:1 fix (was #92907b → 2.92:1 on ivory; #6b6356 → 5.33:1). */
color: #6b6356;
color: #92907b;
}
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
@@ -29,7 +29,13 @@ function formatRelative(minutes: number): string {
<div class="hero-eyebrow text-caption text-medium-emphasis">Сделка #{{ deal.id }}</div>
<div class="hero-row mt-1">
<h2 class="hero-name text-h5">{{ deal.name }}</h2>
<v-btn icon="mdi-close" variant="text" size="small" aria-label="Закрыть панель" @click="$emit('close')" />
<v-btn
icon="mdi-close"
variant="text"
size="small"
aria-label="Закрыть панель"
@click="$emit('close')"
/>
</div>
<div class="hero-meta mt-2">
<a :href="`tel:${deal.phone.replace(/[^+\d]/g, '')}`" class="phone-link">{{ deal.phone }}</a>
@@ -41,7 +47,11 @@ function formatRelative(minutes: number): string {
</div>
<div v-if="status" class="status-row mt-3">
<v-chip size="small" variant="tonal" :style="{ color: status.colorHex, borderColor: status.colorHex }">
<v-chip
size="small"
variant="tonal"
:style="{ color: status.colorHex, borderColor: status.colorHex }"
>
<span class="status-dot" :style="{ background: status.colorHex }" />
{{ status.nameRu }}
</v-chip>
@@ -87,8 +97,7 @@ function formatRelative(minutes: number): string {
text-decoration: underline;
}
.hero-meta .sep {
/* WCAG2AA 4.5:1 fix (was #92907b → 2.92:1 on ivory; #6b6356 → 5.33:1). */
color: #6b6356;
color: #92907b;
}
.status-row {
@@ -16,18 +16,12 @@
*/
import type { MockDeal } from '../../composables/mockDeals';
import type { LeadStatus } from '../../composables/leadStatuses';
import StatusPill from '../ui/StatusPill.vue';
withDefaults(
defineProps<{
deals: MockDeal[];
selectedIds: number[];
statusBySlug: Map<string, LeadStatus>;
// Task 15: row height from density toggle (44 comfortable / 36 compact).
rowHeight?: number;
}>(),
{ rowHeight: 44 },
);
defineProps<{
deals: MockDeal[];
selectedIds: number[];
statusBySlug: Map<string, LeadStatus>;
}>();
const emit = defineEmits<{
'update:selectedIds': [value: number[]];
@@ -67,8 +61,7 @@ function formatCost(cost: number): string {
items-per-page="-1"
hide-default-footer
hover
:density="rowHeight && rowHeight < 40 ? 'compact' : 'comfortable'"
:row-props="() => ({ class: 'ld-hover-lift ld-stagger-row', style: { height: rowHeight + 'px' } })"
density="comfortable"
@update:model-value="onSelectedUpdate"
@click:row="(_e: Event, { item }: { item: MockDeal }) => emit('row-click', item)"
>
@@ -92,18 +85,23 @@ function formatCost(cost: number): string {
</v-avatar>
<div>
<div class="deal-name">{{ item.name }}</div>
<div class="deal-phone text-caption text-medium-emphasis ld-mono-s">{{ item.phone }}</div>
<div class="deal-phone text-caption text-medium-emphasis">{{ item.phone }}</div>
</div>
</div>
</template>
<template #[`item.statusSlug`]="{ item }: { item: MockDeal }">
<!-- Task 15: StatusPill заменяет v-chip + ручной dot. Label fallback на slug
если nameRu отсутствует (leadStatuses store ещё не загружен). -->
<StatusPill
:slug="item.statusSlug"
:label="statusBySlug.get(item.statusSlug)?.nameRu ?? item.statusSlug"
/>
<v-chip
size="small"
variant="tonal"
:style="{
color: statusBySlug.get(item.statusSlug)?.colorHex,
borderColor: statusBySlug.get(item.statusSlug)?.colorHex,
}"
>
<span class="status-dot" :style="{ background: statusBySlug.get(item.statusSlug)?.colorHex }" />
{{ statusBySlug.get(item.statusSlug)?.nameRu }}
</v-chip>
</template>
<template #[`item.manager`]="{ item }: { item: MockDeal }">
@@ -116,28 +114,11 @@ function formatCost(cost: number): string {
</template>
<template #[`item.cost`]="{ item }: { item: MockDeal }">
<span class="num ld-mono">{{ formatCost(item.cost) }}</span>
<span class="num">{{ formatCost(item.cost) }}</span>
</template>
<template #[`item.receivedMinutesAgo`]="{ item }: { item: MockDeal }">
<span class="num ld-mono-s text-medium-emphasis">{{ formatRelative(item.receivedMinutesAgo) }}</span>
</template>
<template #[`header.data-table-select`]="{ allSelected, selectAll, someSelected }">
<v-checkbox-btn
:model-value="allSelected"
:indeterminate="someSelected && !allSelected"
aria-label="Выбрать все сделки"
@update:model-value="selectAll"
/>
</template>
<template #[`item.data-table-select`]="{ isSelected, toggleSelect, internalItem, item }">
<v-checkbox-btn
:model-value="isSelected(internalItem)"
:aria-label="`Выбрать сделку «${(item as MockDeal).name}»`"
@update:model-value="(v: boolean | null) => toggleSelect(internalItem)"
/>
<span class="num text-medium-emphasis">{{ formatRelative(item.receivedMinutesAgo) }}</span>
</template>
</v-data-table>
@@ -11,8 +11,7 @@ defineProps<{
<template>
<h1 class="err-code">
{{ code[0] }}<span class="accent">{{ code[1] }}</span
>{{ code[2] }}
{{ code[0] }}<span class="accent">{{ code[1] }}</span>{{ code[2] }}
</h1>
</template>
@@ -53,7 +53,7 @@ function statusColor(s: string): string {
<p v-if="code === '404'" class="err-help text-caption">
Что-то не так? Напишите в
<a href="mailto:support@liderra.app" class="err-help__link">support@liderra.app</a>
<a href="mailto:support@liderra.app" class="text-primary">support@liderra.app</a>
</p>
</template>
@@ -99,11 +99,4 @@ function statusColor(s: string): string {
color: #7a8c87;
margin-top: 16px;
}
.err-help__link {
color: #d3dad8;
text-decoration: underline;
}
.err-help__link:hover {
color: #ffffff;
}
</style>
@@ -18,12 +18,7 @@ function formatCost(cost: number): string {
</script>
<template>
<v-card
variant="outlined"
class="kanban-card ld-hover-lift pa-3 mb-2"
density="compact"
@click="emit('open', deal.id)"
>
<v-card variant="outlined" class="kanban-card pa-3 mb-2" density="compact" @click="emit('open', deal.id)">
<div class="card-name">{{ deal.name }}</div>
<div class="card-phone text-caption text-medium-emphasis">{{ deal.phone }}</div>
<div class="card-meta mt-2">
@@ -54,7 +54,7 @@ function onDraggableChange(event: DraggableChangeEvent) {
<div class="kanban-column">
<header class="column-head" :style="{ '--accent': status.colorHex }">
<div class="column-head-row">
<span class="column-name ld-label">{{ status.nameRu }}</span>
<span class="column-name">{{ status.nameRu }}</span>
<span class="column-count">{{ deals.length }}</span>
</div>
<div class="column-total">{{ formatTotal(total) }}</div>
+87 -151
View File
@@ -1,22 +1,19 @@
<script setup lang="ts">
/**
* Sprint 4 Phase B/1 (audit O-refactor-04 хвост): sidebar выделен из AppLayout.
* Task 12 (Portal Redesign Quiet Luxury): двухтоновый shell + K stub + group-eyebrows
* + active-marker pseudo-element + JetBrains Mono badges.
*
* Brand mark + nav-tree (3 группы: Работа, Финансы, Команда).
* Counts для «Сделки» mock.
* Counts для «Напоминания» живой из remindersStore; «Сделки»/«Менеджеры» mock.
*/
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import Kbd from '../ui/Kbd.vue';
import { useRemindersStore } from '../../stores/reminders';
interface NavItem {
title: string;
icon: string;
to: string;
countKey?: 'deals' | 'reminders' | 'managers';
count?: number;
countKey?: string;
}
interface NavGroup {
eyebrow: string;
@@ -26,15 +23,22 @@ interface NavGroup {
const drawerOpen = defineModel<boolean>('drawerOpen', { default: true });
const route = useRoute();
const reminders = useRemindersStore();
const navGroups = computed<NavGroup[]>(() => [
{
eyebrow: 'Работа',
items: [
{ title: 'Проекты', icon: 'mdi-folder-multiple-outline', to: '/projects' },
{ title: 'Дашборд', icon: 'mdi-view-dashboard-outline', to: '/dashboard' },
{ title: 'Сделки', icon: 'mdi-format-list-bulleted', to: '/deals', count: 247 },
{ title: 'Канбан', icon: 'mdi-view-column-outline', to: '/kanban' },
{ title: 'Дашборд', icon: 'mdi-view-dashboard-outline', to: '/dashboard' },
{
title: 'Напоминания',
icon: 'mdi-clock-outline',
to: '/reminders',
countKey: 'reminders',
count: reminders.counts.active,
},
],
},
{
@@ -46,174 +50,106 @@ const navGroups = computed<NavGroup[]>(() => [
},
{
eyebrow: 'Команда',
items: [{ title: 'Настройки', icon: 'mdi-cog-outline', to: '/settings' }],
items: [
{ title: 'Менеджеры', icon: 'mdi-account-group-outline', to: '/managers', count: 4 },
{ title: 'Настройки', icon: 'mdi-cog-outline', to: '/settings' },
],
},
]);
function resolveCount(item: NavItem): number {
return item.count ?? 0;
}
defineExpose({ navGroups });
</script>
<template>
<aside class="ld-sidebar" :data-open="drawerOpen">
<div class="ld-sidebar__brand">
<span class="ld-sidebar__brand-name">Лидерра<span class="ld-sidebar__brand-dot">.</span></span>
<v-navigation-drawer v-model="drawerOpen" color="secondary" theme="dark" :width="240" :rail="false" class="app-drawer">
<div class="brand-block">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 48 48" width="22" height="22">
<path
d="M16 14 L16 34 L32 34"
stroke="#012019"
stroke-width="4.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
<circle cx="32" cy="34" r="3.5" fill="#0F6E56" />
</svg>
</span>
<span class="brand-text">Лидерра<span class="brand-dot">.</span></span>
</div>
<div class="ld-cmdk-stub" role="button" tabindex="0">
<span class="ld-cmdk-stub__placeholder">Поиск, команды</span>
<Kbd dark>K</Kbd>
</div>
<nav class="ld-sidebar__nav">
<div v-for="(group, gi) in navGroups" :key="gi" class="ld-nav-group">
<div class="ld-nav-group__eyebrow">{{ group.eyebrow }}</div>
<RouterLink
<v-list nav density="comfortable" class="app-nav">
<template v-for="group in navGroups" :key="group.eyebrow">
<v-list-subheader class="nav-eyebrow">{{ group.eyebrow }}</v-list-subheader>
<v-list-item
v-for="item in group.items"
:key="item.to"
:to="item.to"
class="ld-nav-item"
:class="{ 'ld-nav-item--active': route.path === item.to }"
:prepend-icon="item.icon"
:active="route.path === item.to"
rounded="lg"
class="nav-item"
>
<span class="ld-nav-item__title">{{ item.title }}</span>
<span
v-if="resolveCount(item) > 0"
class="ld-nav-item__badge ld-mono"
:data-testid="item.countKey ? `nav-count-${item.countKey}` : undefined"
>{{ resolveCount(item) }}</span
>
</RouterLink>
</div>
</nav>
</aside>
<v-list-item-title>{{ item.title }}</v-list-item-title>
<template v-if="item.count !== undefined && item.count > 0" #append>
<span
class="nav-count"
:data-testid="item.countKey ? `nav-count-${item.countKey}` : undefined"
>{{ item.count }}</span>
</template>
</v-list-item>
</template>
</v-list>
</v-navigation-drawer>
</template>
<style scoped>
.ld-sidebar {
background: linear-gradient(180deg, var(--liderra-noir) 0%, #04261e 100%);
color: #e8e2d4;
padding: 20px 14px;
width: 232px;
height: 100vh;
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
z-index: 1006;
overflow-y: auto;
.app-drawer {
border-right: 1px solid rgba(255, 255, 255, 0.06);
}
.ld-sidebar__brand {
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
padding: 0 8px;
margin-bottom: 22px;
}
.ld-sidebar__brand-name {
color: var(--liderra-ivory);
}
.ld-sidebar__brand-dot {
color: var(--liderra-teal);
}
.ld-cmdk-stub {
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
padding: 8px 11px;
border-radius: var(--radius-8);
font-size: 12px;
color: #9b9484;
margin-bottom: 18px;
cursor: pointer;
transition: background 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
.ld-cmdk-stub:hover {
background: rgba(255, 255, 255, 0.1);
}
.ld-sidebar__nav {
flex: 1;
}
.ld-nav-group {
margin-bottom: 6px;
}
.ld-nav-group__eyebrow {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.14em;
color: #6b7470;
margin: 14px 8px 4px;
font-weight: 500;
}
.ld-nav-item {
.brand-block {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px;
border-radius: var(--radius-6);
font-size: 13px;
color: #b8b0a0;
text-decoration: none;
position: relative;
transition:
color 200ms cubic-bezier(0.16, 1, 0.3, 1),
background 200ms cubic-bezier(0.16, 1, 0.3, 1);
margin-bottom: 1px;
padding: 18px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.ld-nav-item:hover {
color: #e8e2d4;
background: rgba(255, 255, 255, 0.04);
.brand-mark {
width: 24px;
height: 24px;
border-radius: 5px;
background: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ld-nav-item--active {
color: var(--liderra-ivory);
background: rgba(15, 110, 86, 0.22);
.brand-text {
font-weight: 600;
font-size: 16px;
letter-spacing: -0.01em;
color: #fff;
}
.ld-nav-item--active::before {
content: '';
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
width: 2px;
background: var(--liderra-teal);
border-radius: 2px;
transform-origin: center;
animation: ld-marker-grow 250ms cubic-bezier(0.16, 1, 0.3, 1);
.brand-dot {
color: #32c8a9;
}
@keyframes ld-marker-grow {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
.ld-nav-item__title {
flex: 1;
}
.ld-nav-item__badge {
font-size: 10px;
background: rgba(255, 255, 255, 0.08);
padding: 1px 6px;
border-radius: 4px;
color: #b8b0a0;
.nav-eyebrow {
font-size: 11px !important;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #7a8c87 !important;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum' 1;
padding-top: 16px !important;
}
.ld-nav-item--active .ld-nav-item__badge {
background: rgba(255, 255, 255, 0.1);
color: var(--liderra-ivory);
.nav-count {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
font-size: 11px;
color: #7a8c87;
background: rgba(255, 255, 255, 0.05);
padding: 2px 7px;
border-radius: 10px;
}
</style>
@@ -87,13 +87,11 @@ async function handleLogout(): Promise<void> {
<template>
<v-app-bar :elevation="0" color="surface" class="app-topbar" :height="56">
<v-app-bar-nav-icon
class="d-md-none"
aria-label="Открыть меню навигации"
@click="emit('toggle-drawer')"
/>
<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>
@@ -170,7 +168,13 @@ async function handleLogout(): Promise<void> {
<v-menu offset="8">
<template #activator="{ props }">
<v-btn v-bind="props" variant="text" size="small" class="user-chip ml-2" aria-label="Меню пользователя">
<v-btn
v-bind="props"
variant="text"
size="small"
class="user-chip ml-2"
aria-label="Меню пользователя"
>
<v-avatar size="28" color="primary" class="mr-2">
<span class="text-caption">{{ userInitials }}</span>
</v-avatar>
@@ -189,16 +193,7 @@ async function handleLogout(): Promise<void> {
<style scoped>
.app-topbar {
background: linear-gradient(180deg, var(--liderra-noir) 0%, #04261e 100%) !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
color: #e8e2d4 !important;
}
.app-topbar :deep(.v-toolbar__content) {
padding-left: 240px;
color: #e8e2d4;
}
.app-topbar :deep(.v-icon) {
color: #b8b0a0;
border-bottom: 1px solid #d9d5cd !important;
}
.crumb {
display: flex;
@@ -206,30 +201,20 @@ async function handleLogout(): Promise<void> {
gap: 4px;
font-size: 14px;
margin-left: 8px;
color: #e8e2d4;
}
.crumb strong {
color: var(--liderra-ivory);
font-weight: 600;
}
.searchbar {
text-transform: none;
color: #b8b0a0 !important;
border-color: rgba(255, 255, 255, 0.12) !important;
}
.search-kbd {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 10px;
padding: 1px 5px;
border: 1px solid rgba(255, 255, 255, 0.16);
border: 1px solid #d9d5cd;
border-radius: 3px;
background: rgba(255, 255, 255, 0.06);
color: #9b9484;
background: #f0ede4;
color: #66635c;
margin-left: 6px;
}
.user-chip :deep(.v-btn__content) {
color: #e8e2d4;
}
.notification-pip {
position: absolute;
top: 4px;
@@ -1,23 +0,0 @@
<template>
<Story title="BulkActionsBar">
<Variant title="1 selected">
<BulkActionsBar />
</Variant>
<Variant title="Many selected">
<BulkActionsBar />
</Variant>
</Story>
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
import BulkActionsBar from './BulkActionsBar.vue';
import { useProjectsStore } from '../../stores/projectsStore';
const store = useProjectsStore();
onMounted(() => {
store.selectedIds.add(1);
store.selectedIds.add(2);
store.selectedIds.add(3);
});
</script>
@@ -1,108 +0,0 @@
<template>
<v-card class="bulk-actions-bar" elevation="6">
<DevIndexBadge :index="20" label="BulkActionsBar" :dialog-mode="true" style="top: 4px; right: 4px" />
<v-card-text class="d-flex align-center gap-3 flex-wrap">
<strong>Выбрано: {{ store.selectedIds.size }}</strong>
<v-divider vertical />
<v-btn color="primary" variant="outlined" data-testid="bulk-regions" @click="regionsOpen = true">
🌍 Регионы
</v-btn>
<v-btn color="primary" variant="outlined" data-testid="bulk-days" @click="daysOpen = true">
📅 Дни сбора
</v-btn>
<v-btn color="primary" variant="outlined" data-testid="bulk-limit" @click="limitOpen = true">
🎯 Лимит лидов
</v-btn>
<v-divider vertical />
<v-btn color="warning" prepend-icon="mdi-pause" data-testid="bulk-pause" @click="confirmAndRun('pause')">
Приостановить
</v-btn>
<v-btn color="success" prepend-icon="mdi-play" data-testid="bulk-resume" @click="confirmAndRun('resume')">
Возобновить
</v-btn>
<v-divider vertical />
<v-btn
color="error"
prepend-icon="mdi-archive"
data-testid="bulk-archive"
@click="confirmAndRun('archive')"
>
Архивировать
</v-btn>
<v-spacer />
<v-btn variant="text" data-testid="bulk-clear" @click="store.clearSelection">Снять выбор</v-btn>
</v-card-text>
<RegionsBulkDialog
v-model="regionsOpen"
:count="store.selectedIds.size"
@apply="(p) => runBulk({ action: 'update_regions', ...p })"
/>
<DaysBulkDialog
v-model="daysOpen"
:count="store.selectedIds.size"
@apply="(p) => runBulk({ action: 'update_days', ...p })"
/>
<LimitBulkDialog
v-model="limitOpen"
:count="store.selectedIds.size"
@apply="(p) => runBulk({ action: 'update_limit', ...p })"
/>
</v-card>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useProjectsStore } from '../../stores/projectsStore';
import DevIndexBadge from '../DevIndexBadge.vue';
import RegionsBulkDialog from './RegionsBulkDialog.vue';
import DaysBulkDialog from './DaysBulkDialog.vue';
import LimitBulkDialog from './LimitBulkDialog.vue';
const store = useProjectsStore();
const regionsOpen = ref(false);
const daysOpen = ref(false);
const limitOpen = ref(false);
const messages: Record<string, string> = {
pause: 'Приостановить выбранные проекты?',
resume: 'Возобновить выбранные проекты?',
archive:
'Архивировать выбранные проекты?\nДействие необратимо в Plan 5 (восстановление потребует ручного запроса).',
};
async function confirmAndRun(action: 'pause' | 'resume' | 'archive') {
if (!window.confirm(messages[action])) return;
await runBulk({ action });
}
async function runBulk(payload: Parameters<typeof store.bulkUpdate>[0]) {
const result = await store.bulkUpdate(payload);
if (result.skipped.length > 0) {
window.alert(
`Применено: ${result.updated}. Пропущено: ${result.skipped.length} (конфликт с уже доставленными лидами).`,
);
}
}
defineExpose({ regionsOpen, daysOpen, limitOpen });
</script>
<style scoped>
.bulk-actions-bar {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
max-width: calc(100vw - 48px);
}
</style>
@@ -1,13 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
import DaysBulkDialog from './DaysBulkDialog.vue';
const open = ref(true);
</script>
<template>
<Story title="projects/DaysBulkDialog">
<Variant title="open (5 projects)">
<DaysBulkDialog v-model="open" :count="5" />
</Variant>
</Story>
</template>
@@ -1,93 +0,0 @@
<template>
<v-dialog v-model="open" max-width="560">
<v-card>
<v-card-title>Дни сбора лидов для {{ count }} проектов</v-card-title>
<v-card-text>
<div class="mb-4">
<div class="text-caption text-success font-weight-medium mb-2"> Добавить дни</div>
<div class="d-flex gap-2">
<v-btn
v-for="d in WEEKDAYS"
:key="`add-${d.bit}`"
:data-testid="`day-add-${d.bit}`"
:color="addMask & d.bit ? 'success' : undefined"
:variant="addMask & d.bit ? 'flat' : 'outlined'"
size="small"
@click="toggleAdd(d.bit)"
>{{ d.short }}</v-btn
>
</div>
</div>
<div>
<div class="text-caption text-error font-weight-medium mb-2"> Убрать дни</div>
<div class="d-flex gap-2">
<v-btn
v-for="d in WEEKDAYS"
:key="`remove-${d.bit}`"
:data-testid="`day-remove-${d.bit}`"
:color="removeMask & d.bit ? 'error' : undefined"
:variant="removeMask & d.bit ? 'flat' : 'outlined'"
size="small"
@click="toggleRemove(d.bit)"
>{{ d.short }}</v-btn
>
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn data-testid="cancel" @click="open = false">Отмена</v-btn>
<v-btn color="primary" data-testid="apply" :disabled="addMask === 0 && removeMask === 0" @click="apply"
>Применить к {{ count }}</v-btn
>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { WEEKDAYS } from '../../constants/weekdays';
const props = defineProps<{ modelValue: boolean; count: number }>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
apply: [payload: { add: number; remove: number }];
}>();
const open = ref(props.modelValue);
const addMask = ref(0);
const removeMask = ref(0);
watch(
() => props.modelValue,
(val) => {
open.value = val;
if (val) {
addMask.value = 0;
removeMask.value = 0;
}
},
);
watch(open, (val) => {
emit('update:modelValue', val);
});
function toggleAdd(bit: number) {
addMask.value ^= bit;
if (addMask.value & bit) removeMask.value &= ~bit;
}
function toggleRemove(bit: number) {
removeMask.value ^= bit;
if (removeMask.value & bit) addMask.value &= ~bit;
}
function apply() {
emit('apply', { add: addMask.value, remove: removeMask.value });
addMask.value = 0;
removeMask.value = 0;
open.value = false;
}
</script>
@@ -1,13 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
import LimitBulkDialog from './LimitBulkDialog.vue';
const open = ref(true);
</script>
<template>
<Story title="projects/LimitBulkDialog">
<Variant title="open (5 projects)">
<LimitBulkDialog v-model="open" :count="5" />
</Variant>
</Story>
</template>
@@ -1,107 +0,0 @@
<template>
<v-dialog v-model="open" max-width="480">
<v-card>
<v-card-title>Лимит лидов для {{ count }} проектов</v-card-title>
<v-card-text>
<template v-if="!useReplace">
<v-text-field
v-model.number="addValue"
type="number"
min="0"
label="➕ Прибавить к лимиту"
suffix="лидов/день"
data-testid="add-input"
density="compact"
/>
<v-text-field
v-model.number="removeValue"
type="number"
min="0"
label="➖ Убавить лимит"
suffix="лидов/день"
data-testid="remove-input"
density="compact"
class="mt-2"
/>
</template>
<template v-else>
<v-text-field
v-model.number="replaceValue"
type="number"
min="0"
label="Установить лимит"
suffix="лидов/день"
data-testid="replace-input"
density="compact"
/>
</template>
<v-checkbox
v-model="useReplace"
label="Заменить на абсолютное значение"
data-testid="replace-toggle"
density="compact"
hide-details
class="mt-3"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn data-testid="cancel" @click="open = false">Отмена</v-btn>
<v-btn color="primary" data-testid="apply" :disabled="!canApply" @click="apply"
>Применить к {{ count }}</v-btn
>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
const props = defineProps<{ modelValue: boolean; count: number }>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
apply: [payload: { delta?: number; replace?: number }];
}>();
const open = ref(props.modelValue);
const useReplace = ref(false);
const addValue = ref<number | null>(null);
const removeValue = ref<number | null>(null);
const replaceValue = ref<number | null>(null);
watch(
() => props.modelValue,
(val) => {
open.value = val;
if (val) {
useReplace.value = false;
addValue.value = null;
removeValue.value = null;
replaceValue.value = null;
}
},
);
watch(open, (val) => {
emit('update:modelValue', val);
});
const canApply = computed(() => {
if (useReplace.value) return replaceValue.value !== null && replaceValue.value >= 0;
return (addValue.value ?? 0) > 0 || (removeValue.value ?? 0) > 0;
});
function apply() {
if (useReplace.value && replaceValue.value !== null) {
emit('apply', { replace: replaceValue.value });
} else {
const delta = (addValue.value ?? 0) - (removeValue.value ?? 0);
emit('apply', { delta });
}
addValue.value = null;
removeValue.value = null;
replaceValue.value = null;
open.value = false;
}
</script>
@@ -1,72 +0,0 @@
<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>
@@ -1,180 +0,0 @@
<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>
@@ -1,324 +0,0 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue';
import axios from 'axios';
import type { Project } from '../../stores/projectsStore';
import { useProjectsStore } from '../../stores/projectsStore';
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
const props = defineProps<{ project: Project | null }>();
const emit = defineEmits<{ close: []; saved: [] }>();
interface FormState {
name: string;
daily_limit_target: number;
regions: number[];
delivery_days_mask: number;
sms_senders: string[];
sms_keyword: string;
}
const form = reactive<FormState>({
name: '',
daily_limit_target: 50,
regions: [],
delivery_days_mask: 127,
sms_senders: [],
sms_keyword: '',
});
const selectableRegions = REGIONS.filter((r) => r.code !== 0);
function reseedFromProject(p: Project | null): void {
if (!p) return;
form.name = p.name;
form.daily_limit_target = p.daily_limit_target;
form.regions = Array.isArray(p.regions) ? [...p.regions] : [];
form.delivery_days_mask = p.delivery_days_mask ?? 127;
form.sms_senders = p.sms_senders ?? [];
form.sms_keyword = p.sms_keyword ?? '';
}
reseedFromProject(props.project);
watch(
() => props.project?.id,
() => {
reseedFromProject(props.project);
},
);
const saving = ref(false);
const errors = reactive<Record<string, string[]>>({});
const store = useProjectsStore();
async function onPause(): Promise<void> {
if (!props.project) return;
await store.toggleActive(props.project);
}
async function onDelete(): Promise<void> {
if (!props.project) return;
const ok = window.confirm(
'Архивировать проект? Действие необратимо в Plan 5 (восстановление потребует ручного запроса).',
);
if (!ok) return;
await store.archive(props.project.id);
emit('close');
}
async function onSave(): Promise<void> {
if (!props.project) return;
saving.value = true;
Object.keys(errors).forEach((k) => delete errors[k]);
try {
const payload: Record<string, unknown> = {
name: form.name,
daily_limit_target: form.daily_limit_target,
regions: form.regions,
delivery_days_mask: form.delivery_days_mask,
};
if (props.project.signal_type === 'sms') {
payload.sms_senders = form.sms_senders;
payload.sms_keyword = form.sms_keyword;
}
await axios.patch(`/api/projects/${props.project.id}`, payload);
emit('saved');
} 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 onKey(e: KeyboardEvent): void {
if (e.key === 'Escape' && props.project) emit('close');
}
onMounted(() => document.addEventListener('keydown', onKey));
onBeforeUnmount(() => document.removeEventListener('keydown', onKey));
const activeDays = computed<boolean[]>(() => {
const mask = form.delivery_days_mask;
return Array.from({ length: 7 }, (_, i) => (mask & (1 << i)) !== 0);
});
function toggleDay(i: number): void {
form.delivery_days_mask ^= 1 << i;
}
const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
</script>
<template>
<aside class="project-details-drawer" :class="{ open: project !== null }">
<div v-if="project" class="pdd-content">
<header class="pdd-head">
<div class="pdd-title">{{ project.name }}</div>
<button class="pdd-close" data-testid="pdd-close" @click="$emit('close')"></button>
</header>
<div class="pdd-body">
<label class="pdd-field">
<span class="pdd-label">Название</span>
<input v-model="form.name" data-testid="pdd-name" class="pdd-input" />
<div v-if="errors.name" class="pdd-error" data-testid="pdd-error-name">{{ errors.name[0] }}</div>
</label>
<label class="pdd-field">
<span class="pdd-label">Лимит лидов в день</span>
<input
v-model.number="form.daily_limit_target"
type="number"
min="1"
max="10000"
data-testid="pdd-limit"
class="pdd-input"
/>
<div v-if="errors.daily_limit_target" class="pdd-error">{{ errors.daily_limit_target[0] }}</div>
</label>
<div class="pdd-field">
<span class="pdd-label">Регионы (пусто = вся РФ)</span>
<v-autocomplete
v-model="form.regions"
:items="selectableRegions"
item-title="name"
item-value="code"
multiple
chips
clearable
density="comfortable"
hide-details
data-testid="pdd-regions"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps">
<template #subtitle>
{{ FEDERAL_DISTRICT_NAMES[item.raw.federalDistrict] || '' }}
</template>
</v-list-item>
</template>
</v-autocomplete>
</div>
<div class="pdd-field">
<span class="pdd-label">Дни недели приёма</span>
<div class="pdd-days">
<button
v-for="(label, i) in dayLabels"
:key="i"
type="button"
:data-testid="`pdd-day-${i}`"
:class="['pdd-day', { active: activeDays[i] }]"
@click="toggleDay(i)"
>
{{ label }}
</button>
</div>
</div>
</div>
<footer class="pdd-foot">
<div class="pdd-foot-left">
<button class="pdd-btn pdd-btn-warning" data-testid="pdd-pause" @click="onPause">
{{ project.is_active ? '⏸ Приостановить' : '▶ Возобновить' }}
</button>
<button class="pdd-btn pdd-btn-error" data-testid="pdd-delete" @click="onDelete">🗄 Удалить</button>
</div>
<div class="pdd-foot-right">
<button class="pdd-btn pdd-btn-text" data-testid="pdd-cancel" @click="$emit('close')">
Отмена
</button>
<button class="pdd-btn pdd-btn-primary" data-testid="pdd-save" :disabled="saving" @click="onSave">
Сохранить
</button>
</div>
</footer>
</div>
</aside>
</template>
<style scoped>
.project-details-drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 480px;
background: var(--liderra-surface, #ffffff);
border-left: 1px solid var(--liderra-line, #e6e2d6);
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.06);
transform: translateX(100%);
transition: transform 240ms cubic-bezier(0.16, 1, 0.3, 1);
display: flex;
flex-direction: column;
z-index: 5;
}
.project-details-drawer.open {
transform: translateX(0);
}
.pdd-content {
display: flex;
flex-direction: column;
height: 100%;
}
.pdd-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--liderra-line, #e6e2d6);
}
.pdd-title {
font-weight: 600;
font-size: 16px;
}
.pdd-close {
background: none;
border: 0;
cursor: pointer;
font-size: 18px;
padding: 4px;
}
.pdd-body {
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 14px;
flex: 1;
overflow-y: auto;
}
.pdd-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.pdd-label {
font-size: 12px;
color: #6b6f72;
}
.pdd-input {
padding: 8px 10px;
border: 1px solid var(--liderra-line, #e6e2d6);
border-radius: 6px;
font: inherit;
}
.pdd-days {
display: flex;
gap: 4px;
}
.pdd-day {
padding: 6px 10px;
border: 1px solid var(--liderra-line, #e6e2d6);
background: #ffffff;
border-radius: 4px;
cursor: pointer;
font: inherit;
}
.pdd-day.active {
background: #0f6e56;
color: #ffffff;
border-color: #0f6e56;
}
.pdd-foot {
display: flex;
justify-content: space-between;
padding: 12px 20px;
border-top: 1px solid var(--liderra-line, #e6e2d6);
}
.pdd-foot-left,
.pdd-foot-right {
display: flex;
gap: 8px;
}
.pdd-btn {
padding: 6px 14px;
border: 0;
border-radius: 6px;
cursor: pointer;
font: inherit;
}
.pdd-btn-text {
background: transparent;
color: #081319;
}
.pdd-btn-primary {
background: #0f6e56;
color: #ffffff;
}
.pdd-btn-warning {
background: #f59e0b;
color: #ffffff;
}
.pdd-btn-error {
background: #dc2626;
color: #ffffff;
}
.pdd-error {
color: #dc2626;
font-size: 12px;
margin-top: 4px;
}
</style>
@@ -1,13 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
import RegionsBulkDialog from './RegionsBulkDialog.vue';
const open = ref(true);
</script>
<template>
<Story title="projects/RegionsBulkDialog">
<Variant title="open (5 projects)">
<RegionsBulkDialog v-model="open" :count="5" />
</Variant>
</Story>
</template>
@@ -1,93 +0,0 @@
<template>
<v-dialog v-model="open" max-width="560">
<v-card>
<v-card-title>Регионы для {{ count }} проектов</v-card-title>
<v-card-text>
<div class="mb-4">
<div class="text-caption text-success font-weight-medium mb-2"> Добавить</div>
<div class="d-flex flex-wrap gap-2">
<v-chip
v-for="r in FEDERAL_DISTRICTS"
:key="`add-${r.bit}`"
:data-testid="`region-add-${r.bit}`"
:color="addMask & r.bit ? 'success' : undefined"
:variant="addMask & r.bit ? 'flat' : 'outlined'"
size="small"
@click="toggleAdd(r.bit)"
>{{ r.label }}</v-chip
>
</div>
</div>
<div>
<div class="text-caption text-error font-weight-medium mb-2"> Убрать</div>
<div class="d-flex flex-wrap gap-2">
<v-chip
v-for="r in FEDERAL_DISTRICTS"
:key="`remove-${r.bit}`"
:data-testid="`region-remove-${r.bit}`"
:color="removeMask & r.bit ? 'error' : undefined"
:variant="removeMask & r.bit ? 'flat' : 'outlined'"
size="small"
@click="toggleRemove(r.bit)"
>{{ r.label }}</v-chip
>
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn data-testid="cancel" @click="open = false">Отмена</v-btn>
<v-btn color="primary" data-testid="apply" :disabled="addMask === 0 && removeMask === 0" @click="apply"
>Применить к {{ count }}</v-btn
>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { FEDERAL_DISTRICTS } from '../../constants/federal-districts';
const props = defineProps<{ modelValue: boolean; count: number }>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
apply: [payload: { add: number; remove: number }];
}>();
const open = ref(props.modelValue);
const addMask = ref(0);
const removeMask = ref(0);
watch(
() => props.modelValue,
(val) => {
open.value = val;
if (val) {
addMask.value = 0;
removeMask.value = 0;
}
},
);
watch(open, (val) => {
emit('update:modelValue', val);
});
function toggleAdd(bit: number) {
addMask.value ^= bit;
if (addMask.value & bit) removeMask.value &= ~bit;
}
function toggleRemove(bit: number) {
removeMask.value ^= bit;
if (removeMask.value & bit) addMask.value &= ~bit;
}
function apply() {
emit('apply', { add: addMask.value, remove: removeMask.value });
addMask.value = 0;
removeMask.value = 0;
open.value = false;
}
</script>
@@ -134,7 +134,7 @@ function formatAbsolute(iso: string | null): string {
.empty-hint {
font-size: 12px;
margin-top: 6px;
color: #6b6356;
color: #9a9690;
}
.reminder-row {
@@ -33,7 +33,9 @@ const sessions: Session[] = [
эта сессия
</v-chip>
</div>
<div class="session-meta text-caption text-medium-emphasis">{{ s.location }} · {{ s.when }}</div>
<div class="session-meta text-caption text-medium-emphasis">
{{ s.location }} · {{ s.when }}
</div>
</div>
<v-btn v-if="!s.current" variant="text" size="small" color="error"> Завершить </v-btn>
</li>
@@ -99,8 +99,8 @@ async function confirmDisable(): Promise<void> {
безопасном месте.
</template>
<template v-else>
Защитите аккаунт двухфакторной авторизацией. Поддерживаются Google Authenticator, Yandex Key, 1Password
и другие TOTP-приложения.
Защитите аккаунт двухфакторной авторизацией. Поддерживаются Google Authenticator, Yandex Key,
1Password и другие TOTP-приложения.
</template>
</p>
<div class="d-flex ga-2 flex-wrap">
@@ -1,9 +0,0 @@
<script setup lang="ts">
import DensityToggle from './DensityToggle.vue';
</script>
<template>
<Story title="UI/DensityToggle">
<Variant title="Default"><DensityToggle /></Variant>
</Story>
</template>
@@ -1,60 +0,0 @@
<script setup lang="ts">
import { useDensity, type Density } from '../../composables/useDensity';
const { density, setDensity } = useDensity();
const emit = defineEmits<{ change: [Density] }>();
function pick(d: Density): void {
setDensity(d);
emit('change', d);
}
</script>
<template>
<div class="ld-density-toggle" role="group" aria-label="Плотность таблицы">
<button
type="button"
class="ld-density-toggle__btn"
:class="{ 'ld-density-toggle__btn--active': density === 'compact' }"
@click="pick('compact')"
>
Компакт
</button>
<button
type="button"
class="ld-density-toggle__btn"
:class="{ 'ld-density-toggle__btn--active': density === 'comfortable' }"
@click="pick('comfortable')"
>
Комфорт
</button>
</div>
</template>
<style scoped>
.ld-density-toggle {
display: inline-flex;
background: var(--liderra-surface);
border: 1px solid var(--liderra-line);
border-radius: var(--radius-8);
padding: 2px;
}
.ld-density-toggle__btn {
background: transparent;
border: none;
padding: 5px 10px;
border-radius: var(--radius-6);
font-size: 11px;
color: var(--liderra-muted);
cursor: pointer;
font-family: inherit;
transition:
background 200ms cubic-bezier(0.16, 1, 0.3, 1),
color 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
.ld-density-toggle__btn--active {
background: rgba(1, 32, 25, 0.08);
color: var(--liderra-noir);
}
</style>
@@ -1,13 +0,0 @@
<script setup lang="ts">
import FilterChip from './FilterChip.vue';
</script>
<template>
<Story title="UI/FilterChip">
<Variant title="Default">
<FilterChip label="Статус" />
<FilterChip label="Проект" :count="2" />
<FilterChip label="Менеджер" :active="true" :count="3" />
</Variant>
</Story>
</template>
@@ -1,54 +0,0 @@
<script setup lang="ts">
defineProps<{
label: string;
count?: number;
active?: boolean;
}>();
defineEmits<{ click: [] }>();
</script>
<template>
<button type="button" class="ld-filter-chip" :class="{ 'ld-filter-chip--active': active }" @click="$emit('click')">
<span>{{ label }}</span>
<span v-if="count && count > 0" class="ld-filter-chip__count">{{ count }}</span>
<span class="ld-filter-chip__caret"></span>
</button>
</template>
<style scoped>
.ld-filter-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border-radius: var(--radius-8);
background: var(--liderra-surface);
border: 1px solid var(--liderra-line);
font-size: 12px;
color: var(--liderra-noir);
cursor: pointer;
font-family: inherit;
transition: border-color 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
.ld-filter-chip:hover {
border-color: var(--liderra-line-strong);
}
.ld-filter-chip--active {
border-color: var(--liderra-teal);
background: rgba(15, 110, 86, 0.06);
color: var(--liderra-teal);
}
.ld-filter-chip__count {
font-family: 'JetBrains Mono', ui-monospace, monospace;
background: rgba(15, 110, 86, 0.12);
color: var(--liderra-teal);
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
}
.ld-filter-chip__caret {
font-size: 9px;
opacity: 0.6;
}
</style>
@@ -1,12 +0,0 @@
<script setup lang="ts">
import Kbd from './Kbd.vue';
</script>
<template>
<Story title="UI/Kbd">
<Variant title="Light"><Kbd>K</Kbd> <Kbd>Esc</Kbd> <Kbd>/</Kbd></Variant>
<Variant title="Dark (sidebar)">
<div style="background: #012019; padding: 14px; border-radius: 8px"><Kbd dark>K</Kbd></div>
</Variant>
</Story>
</template>
-28
View File
@@ -1,28 +0,0 @@
<script setup lang="ts">
defineProps<{ dark?: boolean }>();
</script>
<template>
<kbd class="ld-kbd" :class="{ 'ld-kbd--dark': dark }">
<slot />
</kbd>
</template>
<style scoped>
.ld-kbd {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
background: rgba(1, 32, 25, 0.06);
color: var(--liderra-muted);
border: 1px solid var(--liderra-line);
line-height: 1.4;
}
.ld-kbd--dark {
background: rgba(255, 255, 255, 0.08);
color: rgba(232, 226, 212, 0.85);
border-color: rgba(255, 255, 255, 0.1);
}
</style>
@@ -1,29 +0,0 @@
<script setup lang="ts">
import StatusPill from './StatusPill.vue';
import { STATUS_PILL_SLUGS } from '../../composables/useStatusPill';
const labelMap: Record<string, string> = {
new: 'Новый',
in_progress: 'В работе',
callback: 'Перезвонить',
quality: 'Качественный',
meeting_set: 'Встреча',
won: 'Продано',
refund: 'Возврат',
duplicate: 'Дубль',
junk: 'Спам',
no_answer: 'Нет ответа',
cancelled: 'Отменено',
closed: 'Закрыто',
postponed: 'Отложено',
archived: 'Архив',
};
</script>
<template>
<Story title="UI/StatusPill" :layout="{ type: 'grid', width: 200 }">
<Variant v-for="slug in STATUS_PILL_SLUGS" :key="slug" :title="slug">
<StatusPill :slug="slug" :label="labelMap[slug]" />
</Variant>
</Story>
</template>
@@ -1,38 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useStatusPill } from '../../composables/useStatusPill';
const props = defineProps<{
slug: string;
label?: string;
}>();
const style = computed<Record<string, string>>(() => {
const s = useStatusPill(props.slug);
const css: Record<string, string> = {
background: s.bg,
color: s.color,
};
if (s.fontWeight) css['font-weight'] = String(s.fontWeight);
if (s.textDecoration) css['text-decoration'] = s.textDecoration;
return css;
});
</script>
<template>
<span class="ld-status-pill" :style="style">{{ label ?? slug }}</span>
</template>
<style scoped>
.ld-status-pill {
display: inline-flex;
align-items: center;
padding: 3px 9px;
border-radius: var(--radius-full);
font-size: 11px;
font-weight: 500;
transition:
background 300ms cubic-bezier(0.16, 1, 0.3, 1),
color 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
</style>
+1 -1
View File
@@ -9,7 +9,7 @@
* - invoices (§4.5): type {invoice, upd}, format {pdf, xml_1c83}.
*/
type TxType = 'topup' | 'lead_charge' | 'refund' | 'tariff_charge';
export type TxType = 'topup' | 'lead_charge' | 'refund' | 'tariff_charge';
export type TxStatus = 'pending' | 'completed' | 'rejected';
export interface BillingTransaction {
@@ -56,3 +56,4 @@ export interface AdminTenantDetail extends AdminTenant {
avgLeadCost: number;
runwayDays: number; // balance / avgDailySpend
}
@@ -1,67 +0,0 @@
/**
* useCountUp RAF-tween анимация числа (Quiet Luxury KPI cards).
*
* - easeOutQuint easing
* - respects prefers-reduced-motion (instant value)
* - re-animates when target ref changes
*
* Spec: docs/superpowers/plans/2026-05-12-portal-redesign-quiet-luxury-plan.md (Task 6).
*/
import { ref, watch, type Ref } from 'vue';
export interface CountUpOptions {
duration?: number; // ms
precision?: number; // знаков после запятой
}
export interface CountUpHandle {
display: Ref<number>;
start: () => void;
}
const easeOutQuint = (t: number): number => 1 - Math.pow(1 - t, 5);
function prefersReducedMotion(): boolean {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
export function useCountUp(target: Ref<number>, opts: CountUpOptions = {}): CountUpHandle {
const duration = opts.duration ?? 600;
const precision = opts.precision ?? 0;
const display = ref(0);
let raf: number | null = null;
let startTime = 0;
let fromValue = 0;
function tick(now: number): void {
const elapsed = now - startTime;
const t = Math.min(elapsed / duration, 1);
const eased = easeOutQuint(t);
const value = fromValue + (target.value - fromValue) * eased;
display.value = precision === 0 ? Math.round(value) : parseFloat(value.toFixed(precision));
if (t < 1) {
raf = requestAnimationFrame(tick);
} else {
display.value = target.value;
raf = null;
}
}
function start(): void {
if (prefersReducedMotion()) {
display.value = target.value;
return;
}
if (raf !== null) cancelAnimationFrame(raf);
fromValue = display.value;
startTime = performance.now();
raf = requestAnimationFrame(tick);
}
watch(target, () => {
if (display.value !== target.value) start();
});
return { display, start };
}
@@ -46,7 +46,10 @@ export function triggerCsvDownload(csv: string, filename: string): void {
* BOM нужен чтобы Excel корректно распознавал UTF-8.
*/
export function buildCsvString(headers: string[], rows: (string | number)[][]): string {
const lines = [headers.join(';'), ...rows.map((row) => row.map((v) => csvEscape(String(v))).join(';'))];
const lines = [
headers.join(';'),
...rows.map((row) => row.map((v) => csvEscape(String(v))).join(';')),
];
// String.fromCharCode(0xfeff) вместо литерального BOM — иначе ESLint
// no-irregular-whitespace.
return String.fromCharCode(0xfeff) + lines.join('\r\n');
@@ -1,44 +0,0 @@
import { computed, ref, watch, type ComputedRef, type Ref } from 'vue';
export type Density = 'comfortable' | 'compact';
export const DENSITY_KEY = 'liderra:density';
export interface DensityHandle {
density: Ref<Density>;
rowHeight: ComputedRef<number>;
setDensity: (d: Density) => void;
toggle: () => void;
}
function loadInitial(): Density {
if (typeof localStorage === 'undefined') return 'comfortable';
const raw = localStorage.getItem(DENSITY_KEY);
return raw === 'compact' ? 'compact' : 'comfortable';
}
export function useDensity(): DensityHandle {
const density = ref<Density>(loadInitial());
const rowHeight = computed<number>(() => (density.value === 'compact' ? 36 : 44));
function setDensity(d: Density): void {
density.value = d;
}
function toggle(): void {
density.value = density.value === 'comfortable' ? 'compact' : 'comfortable';
}
watch(
density,
(v) => {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(DENSITY_KEY, v);
}
},
{ flush: 'sync' },
);
return { density, rowHeight, setDensity, toggle };
}
@@ -1,111 +0,0 @@
import { ref, type Ref } from 'vue';
export interface DevIndicesApi {
currentTarget: Ref<HTMLElement | null>;
currentId: Ref<number | null>;
overlayMode: Ref<boolean>;
hoverEnabled: Ref<boolean>;
setTarget(el: HTMLElement | null): void;
toggleOverlay(): void;
walkToParent(): void;
walkToChild(): void;
pauseHover(ms: number): void;
reset(): void;
}
// Module-level singleton state — shared across all consumers
const currentTarget = ref<HTMLElement | null>(null);
const currentId = ref<number | null>(null);
const overlayMode = ref(false);
const hoverEnabled = ref(true);
let pauseTimer: ReturnType<typeof setTimeout> | null = null;
function parseId(el: HTMLElement | null): number | null {
if (!el) return null;
const raw = el.getAttribute('data-dx');
if (raw == null) return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
}
function setTarget(el: HTMLElement | null): void {
if (el == null) {
currentTarget.value = null;
currentId.value = null;
return;
}
const id = parseId(el);
if (id == null) return;
currentTarget.value = el;
currentId.value = id;
}
function toggleOverlay(): void {
overlayMode.value = !overlayMode.value;
}
function findAncestorWithDx(el: HTMLElement | null): HTMLElement | null {
let cur: HTMLElement | null = el?.parentElement ?? null;
while (cur) {
if (cur.hasAttribute('data-dx')) return cur;
cur = cur.parentElement;
}
return null;
}
function findFirstDescendantWithDx(el: HTMLElement | null): HTMLElement | null {
if (!el) return null;
// BFS: find first descendant with data-dx
const queue: HTMLElement[] = Array.from(el.children) as HTMLElement[];
while (queue.length) {
const cur = queue.shift()!;
if (cur.hasAttribute('data-dx')) return cur;
queue.push(...(Array.from(cur.children) as HTMLElement[]));
}
return null;
}
function walkToParent(): void {
const parent = findAncestorWithDx(currentTarget.value);
if (parent) setTarget(parent);
}
function walkToChild(): void {
const child = findFirstDescendantWithDx(currentTarget.value);
if (child) setTarget(child);
}
function pauseHover(ms: number): void {
hoverEnabled.value = false;
if (pauseTimer) clearTimeout(pauseTimer);
pauseTimer = setTimeout(() => {
hoverEnabled.value = true;
pauseTimer = null;
}, ms);
}
function reset(): void {
currentTarget.value = null;
currentId.value = null;
overlayMode.value = false;
hoverEnabled.value = true;
if (pauseTimer) {
clearTimeout(pauseTimer);
pauseTimer = null;
}
}
export function useDevIndices(): DevIndicesApi {
return {
currentTarget,
currentId,
overlayMode,
hoverEnabled,
setTarget,
toggleOverlay,
walkToParent,
walkToChild,
pauseHover,
reset,
};
}
@@ -1,54 +0,0 @@
/**
* Маппинг slug'ов lead_statuses стилевые токены пилюли.
* Slugs синхронизированы с db/schema.sql:2076 (источник истины).
*
* Spec §8. Используется компонентом StatusPill.vue.
*/
export interface PillStyle {
bg: string;
color: string;
fontWeight?: number;
textDecoration?: 'line-through' | 'none';
}
export const STATUS_PILL_SLUGS = [
'new',
'in_progress',
'callback',
'quality',
'meeting_set',
'won',
'refund',
'duplicate',
'junk',
'no_answer',
'cancelled',
'closed',
'postponed',
'archived',
] as const;
type StatusPillSlug = (typeof STATUS_PILL_SLUGS)[number];
const STYLES: Record<StatusPillSlug, PillStyle> = {
new: { bg: 'rgba(15,110,86,0.12)', color: '#0F6E56' },
in_progress: { bg: 'rgba(63,124,149,0.12)', color: '#2A5A6E' },
callback: { bg: 'rgba(217,164,65,0.18)', color: '#A07820' },
quality: { bg: 'rgba(46,139,87,0.15)', color: '#2E8B57' },
meeting_set: { bg: 'rgba(122,91,163,0.15)', color: '#7A5BA3' },
won: { bg: 'rgba(46,139,87,0.22)', color: '#1F6940', fontWeight: 600 },
refund: { bg: 'rgba(204,110,80,0.15)', color: '#B0563D' },
duplicate: { bg: 'rgba(1,32,25,0.08)', color: '#3A3A3A' },
junk: { bg: 'rgba(184,58,58,0.10)', color: '#B83A3A' },
no_answer: { bg: 'rgba(107,99,86,0.15)', color: '#6B6356' },
cancelled: { bg: 'rgba(107,99,86,0.18)', color: '#6B6356', textDecoration: 'line-through' },
closed: { bg: 'rgba(1,32,25,0.10)', color: '#3A3A3A' },
postponed: { bg: 'rgba(15,110,86,0.06)', color: '#6B6356' },
archived: { bg: '#012019', color: '#E8E2D4' },
};
const FALLBACK: PillStyle = { bg: 'rgba(1,32,25,0.08)', color: '#3A3A3A' };
export function useStatusPill(slug: string): PillStyle {
return STYLES[slug as StatusPillSlug] ?? FALLBACK;
}
@@ -1,18 +0,0 @@
export interface FederalDistrict {
bit: number; // 1, 2, 4, ..., 128
label: string;
}
// 8 ФО РФ — соответствует schema `projects.region_mask BETWEEN 0 AND 255`.
// Используется в bulk-операциях по проектам (грубое выделение).
// Для тонкого pick'а subject-level см. constants/regions.ts.
export const FEDERAL_DISTRICTS: FederalDistrict[] = [
{ bit: 1, label: 'Центральный' },
{ bit: 2, label: 'Северо-Западный' },
{ bit: 4, label: 'Южный' },
{ bit: 8, label: 'Северо-Кавказский' },
{ bit: 16, label: 'Приволжский' },
{ bit: 32, label: 'Уральский' },
{ bit: 64, label: 'Сибирский' },
{ bit: 128, label: 'Дальневосточный' },
];
-119
View File
@@ -1,119 +0,0 @@
export interface Region {
code: number; // 1..89, sequential по конституционному порядку (Art. 65)
name: string; // официальное название субъекта
federalDistrict: number; // 1..8 (см. FEDERAL_DISTRICT_NAMES)
}
// Конституционный порядок (ст. 65 Конституции РФ, ред. 2022):
// 24 республики (1..24) → 9 краёв (25..33) → 48 областей (34..81) →
// 3 города фед.знач. (82..84) → 1 АО Еврейская (85) → 4 АО (86..89).
// Sentinel code:0 = "Вся РФ" (UI hint, в БД хранится как regions=[]).
export const REGIONS: Region[] = [
{ code: 0, name: 'Вся РФ', federalDistrict: 0 },
// 24 республики
{ code: 1, name: 'Республика Адыгея', federalDistrict: 3 },
{ code: 2, name: 'Республика Алтай', federalDistrict: 7 },
{ code: 3, name: 'Республика Башкортостан', federalDistrict: 5 },
{ code: 4, name: 'Республика Бурятия', federalDistrict: 8 },
{ code: 5, name: 'Республика Дагестан', federalDistrict: 4 },
{ code: 6, name: 'Донецкая Народная Республика', federalDistrict: 3 },
{ code: 7, name: 'Республика Ингушетия', federalDistrict: 4 },
{ code: 8, name: 'Кабардино-Балкарская Республика', federalDistrict: 4 },
{ code: 9, name: 'Республика Калмыкия', federalDistrict: 3 },
{ code: 10, name: 'Карачаево-Черкесская Республика', federalDistrict: 4 },
{ code: 11, name: 'Республика Карелия', federalDistrict: 2 },
{ code: 12, name: 'Республика Коми', federalDistrict: 2 },
{ code: 13, name: 'Республика Крым', federalDistrict: 3 },
{ code: 14, name: 'Луганская Народная Республика', federalDistrict: 3 },
{ code: 15, name: 'Республика Марий Эл', federalDistrict: 5 },
{ code: 16, name: 'Республика Мордовия', federalDistrict: 5 },
{ code: 17, name: 'Республика Саха (Якутия)', federalDistrict: 8 },
{ code: 18, name: 'Республика Северная Осетия — Алания', federalDistrict: 4 },
{ code: 19, name: 'Республика Татарстан', federalDistrict: 5 },
{ code: 20, name: 'Республика Тыва', federalDistrict: 7 },
{ code: 21, name: 'Удмуртская Республика', federalDistrict: 5 },
{ code: 22, name: 'Республика Хакасия', federalDistrict: 7 },
{ code: 23, name: 'Чеченская Республика', federalDistrict: 4 },
{ code: 24, name: 'Чувашская Республика', federalDistrict: 5 },
// 9 краёв
{ code: 25, name: 'Алтайский край', federalDistrict: 7 },
{ code: 26, name: 'Забайкальский край', federalDistrict: 8 },
{ code: 27, name: 'Камчатский край', federalDistrict: 8 },
{ code: 28, name: 'Краснодарский край', federalDistrict: 3 },
{ code: 29, name: 'Красноярский край', federalDistrict: 7 },
{ code: 30, name: 'Пермский край', federalDistrict: 5 },
{ code: 31, name: 'Приморский край', federalDistrict: 8 },
{ code: 32, name: 'Ставропольский край', federalDistrict: 4 },
{ code: 33, name: 'Хабаровский край', federalDistrict: 8 },
// 48 областей
{ code: 34, name: 'Амурская область', federalDistrict: 8 },
{ code: 35, name: 'Архангельская область', federalDistrict: 2 },
{ code: 36, name: 'Астраханская область', federalDistrict: 3 },
{ code: 37, name: 'Белгородская область', federalDistrict: 1 },
{ code: 38, name: 'Брянская область', federalDistrict: 1 },
{ code: 39, name: 'Владимирская область', federalDistrict: 1 },
{ code: 40, name: 'Волгоградская область', federalDistrict: 3 },
{ code: 41, name: 'Вологодская область', federalDistrict: 2 },
{ code: 42, name: 'Воронежская область', federalDistrict: 1 },
{ code: 43, name: 'Запорожская область', federalDistrict: 3 },
{ code: 44, name: 'Ивановская область', federalDistrict: 1 },
{ code: 45, name: 'Иркутская область', federalDistrict: 7 },
{ code: 46, name: 'Калининградская область', federalDistrict: 2 },
{ code: 47, name: 'Калужская область', federalDistrict: 1 },
{ code: 48, name: 'Кемеровская область', federalDistrict: 7 },
{ code: 49, name: 'Кировская область', federalDistrict: 5 },
{ code: 50, name: 'Костромская область', federalDistrict: 1 },
{ code: 51, name: 'Курганская область', federalDistrict: 6 },
{ code: 52, name: 'Курская область', federalDistrict: 1 },
{ code: 53, name: 'Ленинградская область', federalDistrict: 2 },
{ code: 54, name: 'Липецкая область', federalDistrict: 1 },
{ code: 55, name: 'Магаданская область', federalDistrict: 8 },
{ code: 56, name: 'Московская область', federalDistrict: 1 },
{ code: 57, name: 'Мурманская область', federalDistrict: 2 },
{ code: 58, name: 'Нижегородская область', federalDistrict: 5 },
{ code: 59, name: 'Новгородская область', federalDistrict: 2 },
{ code: 60, name: 'Новосибирская область', federalDistrict: 7 },
{ code: 61, name: 'Омская область', federalDistrict: 7 },
{ code: 62, name: 'Оренбургская область', federalDistrict: 5 },
{ code: 63, name: 'Орловская область', federalDistrict: 1 },
{ code: 64, name: 'Пензенская область', federalDistrict: 5 },
{ code: 65, name: 'Псковская область', federalDistrict: 2 },
{ code: 66, name: 'Ростовская область', federalDistrict: 3 },
{ code: 67, name: 'Рязанская область', federalDistrict: 1 },
{ code: 68, name: 'Самарская область', federalDistrict: 5 },
{ code: 69, name: 'Саратовская область', federalDistrict: 5 },
{ code: 70, name: 'Сахалинская область', federalDistrict: 8 },
{ code: 71, name: 'Свердловская область', federalDistrict: 6 },
{ code: 72, name: 'Смоленская область', federalDistrict: 1 },
{ code: 73, name: 'Тамбовская область', federalDistrict: 1 },
{ code: 74, name: 'Тверская область', federalDistrict: 1 },
{ code: 75, name: 'Томская область', federalDistrict: 7 },
{ code: 76, name: 'Тульская область', federalDistrict: 1 },
{ code: 77, name: 'Тюменская область', federalDistrict: 6 },
{ code: 78, name: 'Ульяновская область', federalDistrict: 5 },
{ code: 79, name: 'Херсонская область', federalDistrict: 3 },
{ code: 80, name: 'Челябинская область', federalDistrict: 6 },
{ code: 81, name: 'Ярославская область', federalDistrict: 1 },
// 3 города федерального значения
{ code: 82, name: 'Москва', federalDistrict: 1 },
{ code: 83, name: 'Санкт-Петербург', federalDistrict: 2 },
{ code: 84, name: 'Севастополь', federalDistrict: 3 },
// 1 автономная область
{ code: 85, name: 'Еврейская автономная область', federalDistrict: 8 },
// 4 автономных округа
{ code: 86, name: 'Ненецкий автономный округ', federalDistrict: 2 },
{ code: 87, name: 'Ханты-Мансийский автономный округ — Югра', federalDistrict: 6 },
{ code: 88, name: 'Чукотский автономный округ', federalDistrict: 8 },
{ code: 89, name: 'Ямало-Ненецкий автономный округ', federalDistrict: 6 },
];
export const FEDERAL_DISTRICT_NAMES: Record<number, string> = {
1: 'Центральный',
2: 'Северо-Западный',
3: 'Южный',
4: 'Северо-Кавказский',
5: 'Приволжский',
6: 'Уральский',
7: 'Сибирский',
8: 'Дальневосточный',
};
-16
View File
@@ -1,16 +0,0 @@
export interface Weekday {
bit: number; // 1, 2, 4, ..., 64
label: string;
short: string;
}
// Соответствует schema `projects.delivery_days_mask BETWEEN 0 AND 127` (7 бит).
export const WEEKDAYS: Weekday[] = [
{ bit: 1, label: 'Понедельник', short: 'Пн' },
{ bit: 2, label: 'Вторник', short: 'Вт' },
{ bit: 4, label: 'Среда', short: 'Ср' },
{ bit: 8, label: 'Четверг', short: 'Чт' },
{ bit: 16, label: 'Пятница', short: 'Пт' },
{ bit: 32, label: 'Суббота', short: 'Сб' },
{ bit: 64, label: 'Воскресенье', short: 'Вс' },
];
+11 -4
View File
@@ -1,8 +1,18 @@
import { defineSetupVue3 } from '@histoire/plugin-vue';
import { createPinia } from 'pinia';
import { createMemoryHistory, createRouter } from 'vue-router';
import { vuetify } from './plugins/vuetify';
/**
* Histoire setup регистрирует Vuetify + Vue Router (memory-history) для каждой story.
*
* - Vuetify: без него VApp/VBtn/VCard не рендерятся (требует createVuetify-инстанс).
* - vue-router: компоненты используют RouterLink/useRoute. В Histoire-iframe
* нет HTML5 history API используем memory-history с минимальным набором
* stub-маршрутов (story-context, не реальные пути).
*
* vuetify/styles импортируется внутри plugins/vuetify.ts повторно
* импортировать здесь не нужно (TS 6 strict не видит side-effect d.ts).
*/
export const setupVue3 = defineSetupVue3(({ app }) => {
const router = createRouter({
history: createMemoryHistory(),
@@ -13,11 +23,9 @@ export const setupVue3 = defineSetupVue3(({ app }) => {
{ path: '/forgot', component: { template: '<div />' } },
{ path: '/2fa', component: { template: '<div />' } },
{ path: '/recovery', component: { template: '<div />' } },
{ path: '/recovery-use', component: { template: '<div />' } },
{ path: '/dashboard', component: { template: '<div />' } },
{ path: '/deals', component: { template: '<div />' } },
{ path: '/kanban', component: { template: '<div />' } },
{ path: '/projects', component: { template: '<div />' } },
{ path: '/reminders', component: { template: '<div />' } },
{ path: '/billing', component: { template: '<div />' } },
{ path: '/reports', component: { template: '<div />' } },
@@ -27,5 +35,4 @@ export const setupVue3 = defineSetupVue3(({ app }) => {
});
app.use(vuetify);
app.use(router);
app.use(createPinia());
});
+4 -6
View File
@@ -15,7 +15,6 @@
import { useAuthStore } from '../stores/auth';
import { computed } from 'vue';
import { RouterView, useRoute, useRouter } from 'vue-router';
import DevIndexBadge from '../components/DevIndexBadge.vue';
interface NavItem {
title: string;
@@ -66,7 +65,7 @@ const currentPageTitle = computed(() => {
<template>
<v-app>
<v-navigation-drawer color="#012019" theme="dark" :width="240" class="admin-drawer">
<v-navigation-drawer color="secondary" theme="dark" :width="240" class="admin-drawer">
<div class="brand-block">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 48 48" width="22" height="22">
@@ -85,7 +84,7 @@ const currentPageTitle = computed(() => {
</div>
<div class="brand-sub">ADMIN</div>
<v-list nav density="comfortable" class="app-nav" role="navigation" aria-label="Админ навигация">
<v-list nav density="comfortable" class="app-nav">
<v-list-item
v-for="item in navItems"
:key="item.to"
@@ -131,7 +130,6 @@ const currentPageTitle = computed(() => {
<v-main class="admin-main">
<RouterView />
</v-main>
<DevIndexBadge :index="route.meta.devIndex" :label="route.meta.devLabel" />
</v-app>
</template>
@@ -169,7 +167,7 @@ const currentPageTitle = computed(() => {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 10px;
letter-spacing: 0.16em;
color: #e06155;
color: #b94837;
padding: 0 20px 14px;
text-transform: uppercase;
font-weight: 600;
@@ -180,7 +178,7 @@ const currentPageTitle = computed(() => {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
font-size: 11px;
color: #8a9c95;
color: #7a8c87;
background: rgba(255, 255, 255, 0.05);
padding: 2px 7px;
border-radius: 10px;
+4 -10
View File
@@ -17,7 +17,6 @@ import { useRemindersStore } from '../stores/reminders';
import { usePolling } from '../composables/usePolling';
import AppSidebar from '../components/layout/AppSidebar.vue';
import AppTopbar from '../components/layout/AppTopbar.vue';
import DevIndexBadge from '../components/DevIndexBadge.vue';
const auth = useAuthStore();
const notifications = useNotificationsStore();
@@ -29,12 +28,13 @@ const drawerOpen = ref(true);
// Тот же навигационный pool что в AppSidebar для crumb-resolution в topbar
// (sidebar и topbar независимые, но navGroups совпадают по контракту).
const navItems = computed(() => [
{ title: 'Проекты', to: '/projects' },
{ title: 'Дашборд', to: '/dashboard' },
{ title: 'Сделки', to: '/deals' },
{ title: 'Канбан', to: '/kanban' },
{ title: 'Дашборд', to: '/dashboard' },
{ title: 'Напоминания', to: '/reminders' },
{ title: 'Биллинг', to: '/billing' },
{ title: 'Отчёты', to: '/reports' },
{ title: 'Менеджеры', to: '/managers' },
{ title: 'Настройки', to: '/settings' },
]);
@@ -66,19 +66,13 @@ usePolling(loadReminderCounts, { intervalMs: 60_000, enabled: true });
<AppTopbar :page-title="currentPageTitle" @toggle-drawer="drawerOpen = !drawerOpen" />
<v-main class="app-main">
<RouterView v-slot="{ Component, route: r }">
<Transition :name="(r.meta.transition as string) ?? 'ld-route-fadeup'" mode="out-in">
<component :is="Component" :key="r.fullPath" />
</Transition>
</RouterView>
<RouterView />
</v-main>
<DevIndexBadge :index="route.meta.devIndex" :label="route.meta.devLabel" />
</v-app>
</template>
<style scoped>
.app-main {
background: #f6f3ec;
padding-left: 232px;
}
</style>
+1 -5
View File
@@ -10,10 +10,7 @@
* - Слева: brand mark "Лидерра.", цитата с акцентом, footer с ссылками на оферту/политику.
* - Справа: <RouterView /> рендерит конкретный auth-экран (LoginView и т.п.).
*/
import { RouterView, useRoute } from 'vue-router';
import DevIndexBadge from '../components/DevIndexBadge.vue';
const route = useRoute();
import { RouterView } from 'vue-router';
</script>
<template>
@@ -54,7 +51,6 @@ const route = useRoute();
</v-col>
</v-row>
</v-main>
<DevIndexBadge :index="route.meta.devIndex" :label="route.meta.devLabel" />
</v-app>
</template>

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