Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 515acb654c | |||
| 7bc9ded118 | |||
| 30d1a3c756 | |||
| 7e167cf943 | |||
| cb5bb7dbaf | |||
| 942f5364e8 | |||
| fcba06172a | |||
| 947290f1dc | |||
| 14f405a84a | |||
| 781a59cbf6 | |||
| b1765e98f7 | |||
| c2c9210317 | |||
| 07eacdbceb | |||
| ef5da8def8 | |||
| 78bae4addf | |||
| 049eaf0dfc | |||
| 1ab84d8038 | |||
| 83a8d58096 | |||
| 8dbdd5aac0 | |||
| 235b1d4e8c | |||
| b40f2c8ffb | |||
| 63337b418d | |||
| 2ebc776cc9 | |||
| a0691e8857 | |||
| 50fc188f01 | |||
| 14f92d5147 | |||
| 802cda1b34 | |||
| 33d9c43450 | |||
| afcff10892 | |||
| 1a49d7b127 | |||
| a816c2413b | |||
| b22b76f96e | |||
| ea5e475f32 | |||
| 626baa65ec | |||
| bcba3a153c | |||
| 3e389365d5 | |||
| e29f38280e | |||
| 0f4f7161c8 | |||
| b4138bbc82 | |||
| 80c1cfd9e4 | |||
| 37518e6aa2 | |||
| a2b6293566 | |||
| 77cc535ab2 | |||
| 5e73e0cf0f | |||
| 90be402106 | |||
| e9ae43a81b | |||
| 78333da3d5 | |||
| fc7d34a131 | |||
| efc6dbeb0a | |||
| d78a72c286 | |||
| ba12fecc5c | |||
| 74cc4408c7 | |||
| ccf194ed8a | |||
| a2bfeafcea | |||
| f98a3bf109 | |||
| 3981fdcbf3 | |||
| 5234e46d92 | |||
| a3167d5783 | |||
| 7bcfbf6bd4 | |||
| ad2c8f1704 | |||
| 55a34af986 | |||
| 54451d2ea6 |
@@ -64,6 +64,15 @@
|
||||
"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'); }\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Task",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"C:/моя/проекты/портал crm/Документация/tools/subagent-prompt-prefix.mjs\""
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
name: discovery-interview
|
||||
description: Структурированное интервью-discovery ПЕРЕД проектированием. Два режима. FEATURE — заказчик описывает проблему, боль или цель без готового решения («менеджеры жалуются на…», «сделки теряются», «хочу чтобы…»): JTBD-интервью вскрывает проблему до решения и отдаёт discovery-brief в brainstorming. SYSTEM — запрос ориентации по проекту («сориентируй», «где мы сейчас», «что в тулчейне / на карте», «catch-up по…»): синтез по мета-слою (карта, CLAUDE.md, MEMORY, Открытые_вопросы, Tooling, git log). SKIP — чёткий директив на реализацию («интегрируй X», «закрой находку Y», «поправь Z»): это не discovery. SKIP — анализ бизнес-процесса из кода или диагностика просадки измеримой метрики/конверсии («как устроен процесс X», «process discovery», «где узкое место», «почему просела конверсия»): это skill process-analysis. Используй при «discovery interview», «проведи discovery», «сориентируй по проекту» и при расплывчатом проблемном запросе, даже если слово «discovery» не названо.
|
||||
---
|
||||
|
||||
# Discovery Interview
|
||||
|
||||
Структурированное интервью, которое вскрывает **проблему** прежде, чем кто-либо
|
||||
начнёт проектировать решение. Два режима — FEATURE (интервью заказчика перед
|
||||
фичей) и SYSTEM (интервью-ориентация по состоянию проекта).
|
||||
|
||||
Зачем скил существует: запрос вида «менеджеры жалуются на X» или «хочу, чтобы Y» —
|
||||
это симптом, не задача. Уйдёшь сразу в дизайн — спроектируешь решение не той
|
||||
проблемы. Discovery interview удерживает разговор в проблемном поле ровно столько,
|
||||
сколько нужно, чтобы понять *настоящую* потребность, и только потом передаёт
|
||||
эстафету проектированию.
|
||||
|
||||
## Когда какой режим
|
||||
|
||||
| Запрос | Действие |
|
||||
|---|---|
|
||||
| Заказчик описал проблему / боль / цель без решения | режим **FEATURE** |
|
||||
| Заказчик просит сориентировать по проекту | режим **SYSTEM** |
|
||||
| Заказчик дал чёткий директив («сделай X», «интегрируй Y») | скил не нужен — работай напрямую |
|
||||
| Вопрос про устройство бизнес-процесса из кода | скил `process-analysis`, не этот |
|
||||
|
||||
## Несущий принцип — три слоя-источника
|
||||
|
||||
Этот скил соседствует со скилом `process-analysis` (раздел C10 карты). Чтобы не
|
||||
дублировать его, способности разведены по **слою данных**, с которым работают:
|
||||
|
||||
| Способность | Слой-источник | Метод |
|
||||
|---|---|---|
|
||||
| `process-analysis` | app-код — `routes/`, `app/Jobs`, `audit_*` | реконструкция бизнес-процесса из кода |
|
||||
| discovery-interview **FEATURE** | голова заказчика | интервью человека |
|
||||
| discovery-interview **SYSTEM** | мета-слой — карта, CLAUDE.md, MEMORY, Открытые_вопросы, Tooling, git log | интервью + синтез |
|
||||
|
||||
Правило разведения: если ответ добывается **чтением кода** — это `process-analysis`.
|
||||
Если ответ лежит в голове заказчика или в управляющих документах — это
|
||||
discovery-interview.
|
||||
|
||||
## Режим FEATURE
|
||||
|
||||
### Триггер
|
||||
|
||||
Заказчик описывает проблему, боль, раздражение или цель — но НЕ готовое решение.
|
||||
Признаки: «менеджеры жалуются…», «X теряется», «неудобно делать Y», «хочу, чтобы…»,
|
||||
«было бы хорошо, если…».
|
||||
|
||||
### SKIP
|
||||
|
||||
Не запускай FEATURE, если запрос — чёткий директив на реализацию: «интегрируй X»,
|
||||
«закрой находку Y», «поправь Z», «добавь endpoint». Проблема уже понята заказчиком,
|
||||
discovery только затормозит. Работай напрямую — или через `brainstorming`, если
|
||||
дизайн решения нетривиален.
|
||||
|
||||
Не запускай FEATURE и если запрос — **диагностика просадки измеримой метрики или
|
||||
конверсии** («почему падает конверсия B2», «где теряем в воронке», «почему лиды не
|
||||
доходят до оплаты»). Ответ там добывается анализом кода и audit-данных — это скил
|
||||
`process-analysis`. FEATURE — про UX-боль и желаемые возможности, не про диагностику
|
||||
чисел.
|
||||
|
||||
### Процесс
|
||||
|
||||
1. **Один вопрос за раз.** Не вываливай список — это интервью, не анкета. Ответ на
|
||||
первый вопрос определяет второй.
|
||||
2. **Спрашивай про прошлое поведение, не про гипотетику.** «Расскажи, как ты делал
|
||||
это в последний раз» сильнее, чем «как бы ты хотел». Люди плохо предсказывают
|
||||
своё поведение и точно помнят прошлое.
|
||||
3. **Копай до корня — «5 почему».** Первая названная проблема обычно симптом.
|
||||
4. **Не задавай наводящих вопросов.** «Тебе мешает отсутствие фильтра?» подсказывает
|
||||
ответ. Спроси открыто: «что именно замедляет тебя на этом экране?».
|
||||
5. **Поняв проблему — собери discovery-brief и остановись.** Не проектируй решение —
|
||||
это работа `brainstorming`.
|
||||
|
||||
Банк вопросов по шагам JTBD — `references/jtbd-questions.md`.
|
||||
|
||||
### Артефакт — discovery-brief
|
||||
|
||||
Проблема · JTBD (какую работу заказчик «нанимает» решение сделать) · Текущий обходной
|
||||
путь · Цена боли (время / деньги / частота) · Сигнал успеха (как поймём, что закрыто)
|
||||
· Ограничения. Шаблон — `docs/discovery/templates/discovery-brief.md`.
|
||||
|
||||
### Хэндофф
|
||||
|
||||
discovery-brief — это вход для `brainstorming`. Передай brief как готовую проблемную
|
||||
секцию: `brainstorming` берёт её и переходит к решению — он **не перезадаёт** уже
|
||||
выясненные вопросы. discovery-interview отвечает за «что за проблема», brainstorming —
|
||||
за «что построим». Отдельным файлом FEATURE-brief не сохраняется — он вливается в
|
||||
спеку brainstorming.
|
||||
|
||||
## Режим SYSTEM
|
||||
|
||||
### Триггер
|
||||
|
||||
Заказчик просит сориентировать его по состоянию проекта: «сориентируй», «где мы
|
||||
сейчас», «что у нас по X», «что в тулчейне / на карте», «catch-up».
|
||||
|
||||
### SKIP
|
||||
|
||||
Не запускай SYSTEM, если вопрос про устройство **бизнес-процесса** («как устроен
|
||||
процесс сделок», «process discovery», «где узкое место в воронке») — это скил
|
||||
`process-analysis`, он читает код. SYSTEM отвечает на «где мы в проекте», не «как
|
||||
работает процесс X».
|
||||
|
||||
### Процесс
|
||||
|
||||
1. **Короткое уточнение scope** — что именно ориентировать? Весь проект, конкретный
|
||||
раздел, тулчейн, открытые вопросы? Без scope ответ будет рыхлым.
|
||||
2. **Синтез по мета-слою:** карта `docs/automation-graph.html`, `CLAUDE.md`, MEMORY,
|
||||
`docs/Открытые_вопросы_*.md`, `docs/Tooling_*.md`, `git log`.
|
||||
3. **Запрет:** не читай `app/`-код для реконструкции процессов — это исключительный
|
||||
метод `process-analysis`. SYSTEM работает только с мета-слоем.
|
||||
4. **Выдай синтез**, а не пересказ документа целиком — ответ на запрос ориентации с
|
||||
пинами на источники.
|
||||
|
||||
### Артефакт — system-snapshot
|
||||
|
||||
Если ориентация существенная — сохрани `docs/discovery/YYYY-MM-DD-<тема>.md` по
|
||||
шаблону `docs/discovery/templates/system-snapshot.md`. Мелкий устный ответ файла не
|
||||
требует.
|
||||
|
||||
## JTBD-дисциплина (общая для обоих режимов)
|
||||
|
||||
- **Один вопрос за раз** — интервью, не анкета.
|
||||
- **Прошлое, не гипотетика** — «когда это случилось в последний раз?».
|
||||
- **«5 почему»** — корень, не симптом.
|
||||
- **Не наводи** — открытые вопросы, без подсказанного ответа.
|
||||
- **Слушай, не защищай** — если заказчик критикует существующее, не оправдывай его,
|
||||
копай дальше.
|
||||
|
||||
## Границы
|
||||
|
||||
- **`brainstorming`** — проектирование решения. discovery-interview вскрывает проблему
|
||||
и передаёт brief; brainstorming проектирует. Не дублируй его вопросы.
|
||||
- **`process-analysis`** (раздел C10) — анализ as-is бизнес-процесса из кода и
|
||||
диагностика метрик/конверсии. Если ответ требует чтения `routes/` / `app/Jobs` /
|
||||
`audit_*` или расчёта метрик процесса — это `process-analysis`, не этот скил.
|
||||
- **`audit-portal`** — качественный вердикт о здоровье портала. SYSTEM даёт
|
||||
ориентацию («где мы»), не вердикт («здорово ли»).
|
||||
- **Интервью конечных пользователей Лидерры** — вне этого скила (defer post-Б-1; для
|
||||
методологии user research — `design:user-research`).
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"skill_name": "discovery-interview",
|
||||
"note": "Триггер-eval: should_trigger=true → должен вызваться discovery-interview; false → должен сработать другой инструмент (expected_skill). Особое внимание — near-miss к process-analysis (C10).",
|
||||
"evals": [
|
||||
{ "id": 1, "should_trigger": true, "expected_skill": "discovery-interview/FEATURE", "prompt": "менеджеры жалуются что не видят, какие сделки сегодня надо обзвонить — каждое утро роются в фильтрах вручную" },
|
||||
{ "id": 2, "should_trigger": false, "expected_skill": "process-analysis", "prompt": "у меня ощущение что лиды из B2 проседают по конверсии, но не пойму почему — хочу разобраться" },
|
||||
{ "id": 3, "should_trigger": true, "expected_skill": "discovery-interview/FEATURE", "prompt": "хочу чтобы поставщики сами видели свой баланс, а то постоянно пишут в поддержку спрашивают" },
|
||||
{ "id": 4, "should_trigger": true, "expected_skill": "discovery-interview/FEATURE", "prompt": "проведи discovery interview по идее напоминаний — я пока сам не уверен что именно нужно" },
|
||||
{ "id": 5, "should_trigger": true, "expected_skill": "discovery-interview/FEATURE", "prompt": "не нравится как сейчас сделана выгрузка отчётов, неудобно, давай покопаем что не так" },
|
||||
{ "id": 6, "should_trigger": true, "expected_skill": "discovery-interview/FEATURE", "prompt": "клиенты часто отваливаются на этапе оплаты, надо понять что там за проблема" },
|
||||
{ "id": 7, "should_trigger": true, "expected_skill": "discovery-interview/SYSTEM", "prompt": "сориентируй меня — где мы сейчас по проекту, что закрыто что нет" },
|
||||
{ "id": 8, "should_trigger": true, "expected_skill": "discovery-interview/SYSTEM", "prompt": "что у нас вообще в тулчейне по безопасности, я запутался" },
|
||||
{ "id": 9, "should_trigger": true, "expected_skill": "discovery-interview/SYSTEM", "prompt": "вернулся после недели отсутствия, сделай catch-up что произошло по проекту" },
|
||||
{ "id": 10, "should_trigger": true, "expected_skill": "discovery-interview/SYSTEM", "prompt": "что там на карте в разделе биллинга, какие узлы" },
|
||||
{ "id": 11, "should_trigger": false, "expected_skill": "process-analysis", "prompt": "как устроен процесс обработки сделки от создания до закрытия — пройди по коду" },
|
||||
{ "id": 12, "should_trigger": false, "expected_skill": "process-analysis", "prompt": "где узкое место в воронке лидов, какой шаг тормозит" },
|
||||
{ "id": 13, "should_trigger": false, "expected_skill": "process-analysis", "prompt": "сделай process discovery по джобам импорта лидов" },
|
||||
{ "id": 14, "should_trigger": false, "expected_skill": "process-analysis", "prompt": "посчитай метрики процесса: cycle time по статусам сделок" },
|
||||
{ "id": 15, "should_trigger": false, "expected_skill": "directive (no skill)", "prompt": "интегрируй openapi-mcp-server в .mcp.json" },
|
||||
{ "id": 16, "should_trigger": false, "expected_skill": "directive (no skill)", "prompt": "закрой находку аудита G7 по AdminBillingController" },
|
||||
{ "id": 17, "should_trigger": false, "expected_skill": "systematic-debugging", "prompt": "поправь падающий тест RlsSmokeTest, он валится на teardown" },
|
||||
{ "id": 18, "should_trigger": false, "expected_skill": "directive (no skill)", "prompt": "добавь endpoint POST /api/deals/{id}/archive" },
|
||||
{ "id": 19, "should_trigger": false, "expected_skill": "write-spec / brainstorming", "prompt": "напиши спеку для фичи мультивалютного биллинга" },
|
||||
{ "id": 20, "should_trigger": false, "expected_skill": "audit-portal", "prompt": "проведи полный аудит портала перед релизом" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# Банк вопросов JTBD — режим FEATURE
|
||||
|
||||
Вопросы для discovery-интервью. Задавать **по одному**, адаптируя формулировку под
|
||||
контекст. Все вопросы — про прошлое поведение, без подсказанного ответа.
|
||||
|
||||
## 1. Вскрыть проблему
|
||||
|
||||
- Расскажи, что произошло в последний раз, когда [ситуация]?
|
||||
- Что именно тебя в этом раздражало или замедляло?
|
||||
- Как часто это случается?
|
||||
|
||||
## 2. Текущий обходной путь
|
||||
|
||||
- Как ты решаешь это сейчас?
|
||||
- Что делаешь, когда [проблема] происходит?
|
||||
- Кто ещё это делает и как?
|
||||
|
||||
## 3. Цена боли
|
||||
|
||||
- Сколько времени это съедает за неделю?
|
||||
- Что случается, если не сделать это вовремя?
|
||||
- Были случаи, когда из-за этого что-то сорвалось?
|
||||
|
||||
## 4. JTBD — какую работу «нанимают» решение сделать
|
||||
|
||||
- Если бы это работало идеально — что бы ты перестал делать руками?
|
||||
- Какого результата ты на самом деле добиваешься?
|
||||
|
||||
## 5. Сигнал успеха
|
||||
|
||||
- Как ты поймёшь, что проблема закрыта?
|
||||
- Что должно стать видимо иначе?
|
||||
|
||||
## 6. Ограничения
|
||||
|
||||
- Что нельзя ломать или менять?
|
||||
- Есть ли срок?
|
||||
|
||||
## Антипаттерны
|
||||
|
||||
- **Наводящий вопрос** («тебе мешает отсутствие X?») — подсказывает ответ; заказчик
|
||||
согласится из вежливости.
|
||||
- **Гипотетика** («как бы ты хотел?») — люди плохо предсказывают своё поведение.
|
||||
- **Список вопросов разом** — это анкета, не интервью; теряется ветвление по ответам.
|
||||
- **Принять первый ответ за корень** — копай «5 почему» до настоящей причины.
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: process-analysis
|
||||
description: Анализ и оптимизация существующего бизнес-процесса — process discovery (реконструкция as-is процесса из кода Laravel и audit-логов), поиск узких мест, трассировка требование→процесс, метрики и KPI процесса. Триггеры — «проанализируй процесс», «где узкое место», «process discovery», «как устроен процесс X», «метрики процесса», «оптимизируй процесс». Раздел C10 карты «Бизнес-процессы (общее)».
|
||||
---
|
||||
|
||||
# Process Analysis
|
||||
|
||||
Разбирает **существующий** бизнес-процесс: восстанавливает фактическую модель,
|
||||
находит узкие места, считает метрики. Парный скил к `process-modeling` — тот
|
||||
проектирует to-be, этот вскрывает as-is.
|
||||
|
||||
## Четыре режима
|
||||
|
||||
### 1. Process discovery — реконструкция as-is
|
||||
|
||||
Восстановить фактический процесс из артефактов кода (карта источников —
|
||||
`references/discovery.md`): маршруты + контроллеры (точки входа), джобы/события
|
||||
(асинхронные шаги), enum статусов + переходы (state-машина), audit-таблицы
|
||||
(фактические следы), cron/scheduler (периодические шаги). Итог — модель,
|
||||
которую можно передать `process-modeling` для отрисовки.
|
||||
|
||||
### 2. Bottleneck — поиск узких мест
|
||||
|
||||
Паттерны: ручной шаг между авто-шагами; шаг с ожиданием внешней системы; точка
|
||||
сериализации (advisory-lock, `lockForUpdate`); N+1 внутри шага; ретраи/таймауты;
|
||||
шаг с наибольшей долей исключений.
|
||||
Граница: это **процессные** узкие места. Runtime/код-производительность —
|
||||
`perf-analyzer` / скил `analysis:bottleneck-detect` (PA1).
|
||||
|
||||
### 3. Трассировка требование→процесс
|
||||
|
||||
Связать пункт ТЗ / `Открытые_вопросы` → шаги процесса → код (file:line) →
|
||||
тесты. Выявить шаги без требования (скрытая логика) и требования без
|
||||
реализации.
|
||||
|
||||
### 4. Метрики процесса
|
||||
|
||||
Определить KPI: throughput, cycle time, конверсия между статусами, доля
|
||||
исключений, объём ручного труда. Числа берутся из БД через `Boost`, не
|
||||
выдумываются.
|
||||
Граница: продуктовые метрики — плагин `product-management` (`/metrics-review`).
|
||||
|
||||
## Рабочий процесс
|
||||
|
||||
1. Определить режим (1-4) по запросу.
|
||||
2. Собрать факты из кода / БД / логов — никаких допущений без пинов (file:line).
|
||||
3. Выдать находки: модель / список узких мест / матрицу трассировки / таблицу
|
||||
метрик.
|
||||
4. Рекомендации направить в `process-modeling` (to-be) или в задачи. Этот скил
|
||||
код не правит.
|
||||
|
||||
## Границы
|
||||
|
||||
- **Проектирование to-be модели** — скил `process-modeling`.
|
||||
- **Runtime / код-производительность** — `perf-analyzer`,
|
||||
`analysis:bottleneck-detect` (PA1).
|
||||
- **Продуктовые метрики** — плагин `product-management`.
|
||||
- **Документ / change-request процесса** — плагин `operations`.
|
||||
- **Интервью заказчика про будущую фичу / ориентация по проекту** — скил
|
||||
`discovery-interview`. Тот вскрывает проблему до решения через интервью человека
|
||||
(режим FEATURE) и синтезирует мета-слой проекта (режим SYSTEM); этот скил — про
|
||||
вскрытие as-is процесса из app-кода. «process discovery», «как устроен процесс X»,
|
||||
«где узкое место» — сюда; «проведи discovery interview», «сориентируй по проекту» —
|
||||
в `discovery-interview`.
|
||||
- **Генерик-методология оптимизации процесса** — скил `process-optimization`
|
||||
плагина `operations`. Этот скил — про code-grounded discovery конкретного
|
||||
процесса Лидерры (вскрытие as-is), не про общую методологию и не про
|
||||
проектирование to-be.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Process discovery — карта источников as-is процесса в Лидерре
|
||||
|
||||
Где в коде Лидерры лежат факты о фактическом бизнес-процессе.
|
||||
|
||||
## Источники
|
||||
|
||||
| Артефакт процесса | Где искать |
|
||||
|---|---|
|
||||
| Точки входа процесса | `app/routes/*.php` + `app/app/Http/Controllers/**` |
|
||||
| Синхронные шаги | методы контроллеров + `app/app/Services/**` |
|
||||
| Асинхронные шаги | `app/app/Jobs/**`, `app/app/Events/**` + listeners |
|
||||
| State-машина | enum/константы статусов + `db/schema.sql` (воронка — 14 статусов) |
|
||||
| Фактические следы выполнения | `audit_*` таблицы, `audit_chain_hash` (событийный лог) |
|
||||
| Периодические шаги | `app/app/Console/**` + scheduler (`partitions:create-months` и пр.) |
|
||||
| Бизнес-правила в шагах | `calc_lead_score` (SQL), `PricingTierResolver`, `LedgerService` |
|
||||
|
||||
## Метод
|
||||
|
||||
1. От **точки входа** (route → controller) пройти по вызовам до терминального
|
||||
состояния.
|
||||
2. Каждый `dispatch()` / событие — асинхронная ветка; проследить listener/job.
|
||||
3. Переход статуса = ребро state-машины; собрать все переходы в автомат.
|
||||
4. Свериться с **audit-логом**: фактический порядок событий в `audit_*` может
|
||||
расходиться с «проектным» — расхождение само по себе находка.
|
||||
5. Зафиксировать каждый шаг пином `file:line`; без пина — это допущение, не факт.
|
||||
|
||||
## Антипаттерны при discovery
|
||||
|
||||
- Принять «happy path» за весь процесс — исключения (catch, failed jobs,
|
||||
таймауты) тоже шаги.
|
||||
- Пропустить cron-шаги — они не видны из route-графа.
|
||||
- Доверять имени метода вместо его тела.
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: process-modeling
|
||||
description: Моделирование бизнес-процесса — BPMN 2.0 (пулы, дорожки, задачи, гейтвеи, события), карты процессов, customer-journey / value-stream, RACI-матрицы, state-машины. Триггеры — «смоделируй процесс», «нарисуй BPMN», «карта процесса», «swimlane / дорожки», «customer journey», «RACI», проектирование state-машины (воронка сделок, цепочка джобов). Раздел C10 карты «Бизнес-процессы (общее)».
|
||||
---
|
||||
|
||||
# Process Modeling
|
||||
|
||||
Превращает словесное описание бизнес-процесса в формальную модель. Скил даёт
|
||||
**нотацию и методологию** — рендер диаграмм делегируется скилу `mermaid`
|
||||
(process-modeling не рендерит сам — конфликт-граница OPS1/BPMN1: mermaid
|
||||
остаётся рендер-SoT).
|
||||
|
||||
## Когда какой артефакт
|
||||
|
||||
| Нужно | Артефакт |
|
||||
|---|---|
|
||||
| Кто-что-в-каком-порядке делает, с ветвлениями | BPMN 2.0 / swimlane |
|
||||
| Сквозной поток end-to-end крупными блоками | Карта процесса (flowchart) |
|
||||
| Опыт клиента/лида по этапам + точки боли | Customer-journey map |
|
||||
| Поток создания ценности + потери и ожидания | Value-stream map |
|
||||
| Распределение ответственности по шагам | RACI-матрица |
|
||||
| Конечный автомат (статусы + переходы) | State-диаграмма |
|
||||
|
||||
## Рабочий процесс
|
||||
|
||||
1. **Собрать процесс** — уточнить: триггер (что запускает), участники (роли),
|
||||
шаги по порядку, ветвления и условия, итог, исключения. Неясное — один
|
||||
вопрос за раз.
|
||||
2. **Выбрать артефакт** по таблице выше.
|
||||
3. **Построить модель** в нотации (BPMN — см. `references/bpmn.md`).
|
||||
4. **Отрендерить** — передать исходник скилу `mermaid`.
|
||||
5. **Свериться** — модель не должна противоречить ТЗ / `db/schema.sql` /
|
||||
`Открытые_вопросы`. Процесс вне ТЗ И не в реестре открытых вопросов —
|
||||
hard-стоп (Pravila §7): не моделировать молча, поднять вопрос.
|
||||
|
||||
## BPMN 2.0 — ядро
|
||||
|
||||
Полная нотация и маппинг на mermaid — `references/bpmn.md`. Кратко:
|
||||
|
||||
- **Pool** — организация/система; **Lane** — роль внутри pool.
|
||||
- **Task** — атомарное действие; **Sub-process** — свёрнутый под-поток.
|
||||
- **Gateway** — ветвление: exclusive (XOR — один путь), parallel (AND — все
|
||||
пути), inclusive (OR — один и более).
|
||||
- **Event** — start / intermediate / end; типы: timer, message, error.
|
||||
- **Sequence flow** — порядок внутри pool; **Message flow** — между pool'ами.
|
||||
|
||||
## Границы
|
||||
|
||||
- **Рендер диаграмм** — скил `mermaid` (C10 OPS1/BPMN1). Этот скил исходник не
|
||||
рисует — отдаёт его mermaid.
|
||||
- **DDD-границы доменных процессов** — скил `architecture-patterns` (bounded
|
||||
context = граница бизнес-процесса).
|
||||
- **Документ процесса, change-request, оптимизация** — плагин `operations`
|
||||
(скилы `process-doc`, `change-request`, `process-optimization`).
|
||||
- **Анализ as-is процесса** (discovery, узкие места) — скил `process-analysis`.
|
||||
- Этот скил — про проектирование **to-be модели**, не про вскрытие as-is.
|
||||
@@ -0,0 +1,56 @@
|
||||
# BPMN 2.0 — справочник нотации и рендер в mermaid
|
||||
|
||||
mermaid не имеет нативного BPMN-рендера. BPMN-модель выражается через mermaid
|
||||
`flowchart` (swimlane через `subgraph` = дорожки) или `stateDiagram-v2`.
|
||||
|
||||
## Элементы BPMN → mermaid
|
||||
|
||||
| BPMN | Смысл | mermaid-выражение |
|
||||
|---|---|---|
|
||||
| Pool / Lane | организация / роль | `subgraph Роль ... end` |
|
||||
| Task | действие | прямоугольник `id[Текст]` |
|
||||
| Sub-process | свёрнутый поток | `id[[Текст]]` |
|
||||
| Start event | старт | `id((Старт))` |
|
||||
| End event | конец | `id((Конец))` |
|
||||
| Exclusive gateway (XOR) | один путь | ромб `id{Условие?}` + подписи на рёбрах |
|
||||
| Parallel gateway (AND) | все пути | ромб `id{И}` с несколькими исходящими |
|
||||
| Sequence flow | порядок | `-->` |
|
||||
| Message flow | между pool | `-.->` |
|
||||
|
||||
## Шаблон swimlane
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Менеджер
|
||||
A((Старт)) --> B[Принять лид]
|
||||
B --> C{Лид валиден?}
|
||||
end
|
||||
subgraph Система
|
||||
C -->|да| D[Создать сделку]
|
||||
C -->|нет| E((Отклонён))
|
||||
D --> F((Сделка создана))
|
||||
end
|
||||
```
|
||||
|
||||
## State-машина
|
||||
|
||||
Для конечных автоматов (воронка сделок — 14 статусов из `db/schema.sql`)
|
||||
использовать `stateDiagram-v2`:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> new
|
||||
new --> in_progress
|
||||
in_progress --> won
|
||||
in_progress --> lost
|
||||
won --> [*]
|
||||
lost --> [*]
|
||||
```
|
||||
|
||||
Статус-слаги — из `db/schema.sql` (источник истины воронки), не выдумывать.
|
||||
|
||||
## Правила
|
||||
|
||||
- Один gateway — один вопрос; каждое исходящее ребро подписано условием.
|
||||
- Каждый путь оканчивается end-событием (нет «висящих» задач).
|
||||
- Исключения (timer/error) моделировать явно, не прятать в «happy path».
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: subagent-driven-development
|
||||
description: Project-local wrapper для superpowers:subagent-driven-development — добавляет обязательный git-safety verify-протокол per Pravila §15.1. Использовать вместо marketplace-варианта при работе с git-коммит-задачами в субагентах.
|
||||
---
|
||||
|
||||
# Subagent-Driven Development (project wrapper)
|
||||
|
||||
Этот скил — проектная обёртка над marketplace-скилом `superpowers:subagent-driven-development`. Дополняет его обязательным git-safety verify-протоколом per Pravila §15.1.
|
||||
|
||||
## Когда использовать
|
||||
|
||||
Когда нужно делегировать задачу субагенту через Task tool — особенно git-коммит-задачи (Sprint 6 прецедент: Haiku-субагенты угнали ветку параллельной сессии).
|
||||
|
||||
## Что делать
|
||||
|
||||
1. **Откройте marketplace-скил** `superpowers:subagent-driven-development` для общего workflow (fresh subagent per task + two-stage review).
|
||||
2. **Перед каждой Task-инвокацией** прочитайте и выполните pre-spawn-чеклист — [references/git-safety-checklist.md](references/git-safety-checklist.md) §A.
|
||||
3. **После каждой Task-инвокации** прочитайте и выполните post-subagent-чеклист — там же §B.
|
||||
4. **Hard-rule §15.1** — git-коммит-задача = модель Sonnet/Opus, никогда Haiku. Read-only git-операции (`log`, `status`, `diff`, `rev-parse`, `branch --show-current`, `worktree list`) разрешены любой модели.
|
||||
|
||||
Хук `tools/subagent-prompt-prefix.mjs` (зарегистрирован в `.claude/settings.json`) автоматически инжектит git-safety заголовок в каждый Task-prompt — это **первая** линия защиты. Чеклист из этого скила — **вторая** линия (защита со стороны контроллера).
|
||||
|
||||
## Cross-refs
|
||||
|
||||
- Pravila §15.1 — hard-rule субагенты + git.
|
||||
- Spec: `docs/superpowers/specs/2026-05-18-parallel-sessions-coordination-design.md` §5.
|
||||
- Memory: `memory/feedback_subagent_git_reliability.md`.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Git-safety Checklist для контроллера субагентов
|
||||
|
||||
Per Pravila §15.1 — выполнять каждый раз при делегировании задачи через Task tool.
|
||||
|
||||
## §A. Pre-spawn чеклист (до Task-инвокации)
|
||||
|
||||
1. **Резолвите 4 значения** (запишите у себя для post-check):
|
||||
|
||||
```bash
|
||||
git branch --show-current # → ожидаемая ветка
|
||||
git rev-parse HEAD # → pre-spawn parent SHA
|
||||
git rev-parse --show-toplevel # → worktree root
|
||||
pwd # → cwd
|
||||
```
|
||||
|
||||
2. **Выберите модель** субагенту:
|
||||
- Задача требует `git commit`/`push`/`stage`/`checkout`/`switch`/`merge`/`rebase`? → **Sonnet или Opus**, никогда Haiku (§15.1).
|
||||
- Только read-только `git log`/`status`/`diff`/`rev-parse` ИЛИ только Edit/Read/Grep? → любая модель.
|
||||
3. **Если задача правит нормативку из списка §15.2** (Pravila / CLAUDE.md / Tooling / PSR_v1 / MEMORY.md / Открытые_вопросы / docs/adr/* / db/schema.sql):
|
||||
|
||||
```bash
|
||||
git fetch origin && git log HEAD..origin/main --oneline
|
||||
```
|
||||
|
||||
Не пусто → **ребейз/merge до инвокации**, не после. Pre-flight также проверить `docs/sessions/CURRENT.md` на конфликт scope-files / version-claims.
|
||||
|
||||
## §B. Post-subagent чеклист (сразу после возврата субагента)
|
||||
|
||||
1. **`git rev-parse HEAD`** — сравнить с pre-spawn parent SHA.
|
||||
- Равно → субагент не коммитил (OK для Edit-задач без commit).
|
||||
- Отличается ровно одним коммитом, чей parent = pre-spawn HEAD → OK для commit-задач.
|
||||
- **Иначе → STOP, разбор инцидента.**
|
||||
2. **`git branch --show-current`** — сравнить с pre-spawn branch.
|
||||
- Не равно → **STOP, разбор инцидента** (Sprint 6 паттерн).
|
||||
3. **`git log -1 --format='%s%n%P'`** — проверить subject + parent последнего коммита.
|
||||
- Subject соответствует задаче?
|
||||
- Parent = pre-spawn HEAD?
|
||||
4. Если несколько коммитов — ручная проверка subject'ов каждого.
|
||||
|
||||
## §C. Red-flag-список — любой = hard-stop разбор
|
||||
|
||||
- `branch ≠ ожидаемая`;
|
||||
- `parent коммита ≠ pre-spawn HEAD` (висячий коммит / попадание на чужую ветку);
|
||||
- HEAD двинулся, но субагент в отчёте об этом не упомянул;
|
||||
- в diff'е есть файлы вне scope задачи.
|
||||
|
||||
## §D. Обязательный формат отчёта субагента
|
||||
|
||||
Субагент в конце ответа выписывает блок:
|
||||
|
||||
```
|
||||
=== GIT REPORT ===
|
||||
cwd: <pwd>
|
||||
branch: <git branch --show-current>
|
||||
HEAD: <git rev-parse HEAD>
|
||||
HEAD^: <git rev-parse HEAD^>
|
||||
status: <git status --short>
|
||||
=== END GIT REPORT ===
|
||||
```
|
||||
|
||||
Отсутствие блока = контроллер считает результат недостоверным и запускает §B-чеклист сам через Bash.
|
||||
|
||||
## §E. Соотношение с code-review
|
||||
|
||||
Двухстадийное review (Pravila §4.5 / PSR_v1 R10) сохраняется. Git-safety-чеклист **не заменяет** code-review — он стоит **до** него (нет смысла ревьюить diff, если он не в той ветке).
|
||||
@@ -63,10 +63,10 @@ class DashboardController extends Controller
|
||||
$curLeads = (clone $base())->whereBetween('received_at', [$windowStart, $now])->count();
|
||||
$prevLeads = (clone $base())->whereBetween('received_at', [$prevStart, $windowStart])->count();
|
||||
|
||||
// --- conversion: % статуса 'paid' в окне ---
|
||||
$curPaid = (clone $base())->where('status', 'paid')
|
||||
// --- conversion: % статуса 'won' в окне ---
|
||||
$curPaid = (clone $base())->where('status', 'won')
|
||||
->whereBetween('received_at', [$windowStart, $now])->count();
|
||||
$prevPaid = (clone $base())->where('status', 'paid')
|
||||
$prevPaid = (clone $base())->where('status', 'won')
|
||||
->whereBetween('received_at', [$prevStart, $windowStart])->count();
|
||||
$curConv = $curLeads > 0 ? round($curPaid / $curLeads * 100, 1) : 0.0;
|
||||
$prevConv = $prevLeads > 0 ? round($prevPaid / $prevLeads * 100, 1) : 0.0;
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Models\User;
|
||||
use App\Services\SupplierResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
@@ -55,6 +56,11 @@ class DealController extends Controller
|
||||
{
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
$request->validate([
|
||||
'received_from' => 'nullable|date',
|
||||
'received_to' => 'nullable|date',
|
||||
]);
|
||||
|
||||
$statuses = (array) $request->query('status_in', []);
|
||||
$projectId = $request->query('project_id') !== null ? (int) $request->query('project_id') : null;
|
||||
$managerId = $request->query('manager_id') !== null ? (int) $request->query('manager_id') : null;
|
||||
@@ -64,6 +70,8 @@ class DealController extends Controller
|
||||
$onlyDeleted = $request->boolean('only_deleted');
|
||||
$countOnly = $request->boolean('count_only');
|
||||
$cursorRaw = (string) $request->query('cursor', '');
|
||||
$receivedFrom = trim((string) $request->query('received_from', ''));
|
||||
$receivedTo = trim((string) $request->query('received_to', ''));
|
||||
|
||||
// Sprint 4 Phase A (audit O-perf-04): keyset pagination через cursor.
|
||||
// При передаче cursor — keyset через PG row constructor (received_at, id) < (?, ?),
|
||||
@@ -81,7 +89,7 @@ class DealController extends Controller
|
||||
$cursor = ['r' => (string) $parsed['r'], 'i' => (int) $parsed['i']];
|
||||
}
|
||||
|
||||
[$deals, $total, $nextCursor] = DB::transaction(function () use ($tenantId, $statuses, $projectId, $managerId, $search, $limit, $offset, $onlyDeleted, $cursor, $countOnly) {
|
||||
[$deals, $total, $nextCursor] = DB::transaction(function () use ($tenantId, $statuses, $projectId, $managerId, $search, $limit, $offset, $onlyDeleted, $cursor, $countOnly, $receivedFrom, $receivedTo) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
// Defense-in-depth: явный where(tenant_id) поверх RLS — на тестах
|
||||
@@ -92,8 +100,16 @@ class DealController extends Controller
|
||||
// withTrashed() обходит global scope SoftDeletes; явный
|
||||
// whereNotNull('deleted_at') фильтрует только удалённые.
|
||||
$query = Deal::query()
|
||||
->select('deals.*')
|
||||
->addSelect(['next_reminder_at' => DB::table('reminders')
|
||||
->select('remind_at')
|
||||
->whereColumn('reminders.deal_id', 'deals.id')
|
||||
->whereNull('reminders.completed_at')
|
||||
->orderBy('remind_at')
|
||||
->limit(1),
|
||||
])
|
||||
->where('tenant_id', $tenantId)
|
||||
->with(['project:id,name', 'manager:id,email,first_name,last_name']);
|
||||
->with(['project:id,name,signal_type', 'manager:id,email,first_name,last_name']);
|
||||
|
||||
if ($onlyDeleted) {
|
||||
$query->withTrashed()->whereNotNull('deleted_at');
|
||||
@@ -115,6 +131,13 @@ class DealController extends Controller
|
||||
->orWhere('contact_name', 'ilike', $like);
|
||||
});
|
||||
}
|
||||
if ($receivedFrom !== '') {
|
||||
$query->where('received_at', '>=', Carbon::parse($receivedFrom)->startOfDay());
|
||||
}
|
||||
if ($receivedTo !== '') {
|
||||
// received_to включительно — до конца дня (+1 день, строгое <).
|
||||
$query->where('received_at', '<', Carbon::parse($receivedTo)->addDay()->startOfDay());
|
||||
}
|
||||
|
||||
// Audit B2: count_only — отдаём только COUNT(*), пропуская SELECT строк
|
||||
// и cursor/offset-логику (лёгкий запрос для бейджа в сайдбаре).
|
||||
@@ -187,6 +210,12 @@ class DealController extends Controller
|
||||
? ManagerController::formatInitials($d->manager->first_name, $d->manager->last_name, $d->manager->email)
|
||||
: null,
|
||||
'received_at' => $d->received_at?->toIso8601String(),
|
||||
'comment' => $d->comment,
|
||||
'city' => $d->city,
|
||||
'project_signal_type' => $d->project?->signal_type,
|
||||
'next_reminder_at' => $d->next_reminder_at
|
||||
? Carbon::parse($d->next_reminder_at)->toIso8601String()
|
||||
: null,
|
||||
]),
|
||||
'limit' => $limit,
|
||||
'next_cursor' => $nextCursor,
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Deal;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use OpenSpout\Common\Entity\Row;
|
||||
use OpenSpout\Common\Entity\Style\Style;
|
||||
@@ -16,44 +17,45 @@ use OpenSpout\Writer\XLSX\Writer as XlsxWriter;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* Export сделок в CSV / XLSX через OpenSpout streaming.
|
||||
* Экспорт сделок в CSV / XLSX через OpenSpout streaming.
|
||||
*
|
||||
* Извлечено из DealController (Sprint 3 Phase A, audit O-refactor-01).
|
||||
* Редизайн «Сделки» (2026-05-17, Task A5): экспорт по ДИАПАЗОНУ ДАТ поставки
|
||||
* (received_at), не по списку id. Окно задаётся received_from/received_to;
|
||||
* оба опциональны (пусто = весь период). Колонки соответствуют таблице
|
||||
* страницы (без чекбокса и без «Напоминание» — экспорт = дамп лидов).
|
||||
*
|
||||
* RLS-обёртка SET LOCAL внутри транзакции (PgBouncer-safe).
|
||||
*
|
||||
* J1 (Sprint 3F): auth:sanctum+tenant, tenant_id из auth()->user().
|
||||
*
|
||||
* O-perf-05: streaming устраняет memory pressure. PhpSpreadsheet строил
|
||||
* полный объект .xlsx в памяти (для 10K сделок ≈ 100+ MB). OpenSpout пишет
|
||||
* O-perf-05: streaming устраняет memory pressure. OpenSpout пишет
|
||||
* в php://output постранично через Writer + Row::fromValues и chunkById(500)
|
||||
* по сделкам — пик памяти O(1) от размера экспорта.
|
||||
*
|
||||
* API контракт сохранён:
|
||||
* POST /api/deals/export {ids[], format?: csv|xlsx}
|
||||
* Headers Content-Type / Content-Disposition без изменений.
|
||||
* CSV: UTF-8 + BOM + ;-разделитель (Excel-friendly RU-локаль).
|
||||
* XLSX: bold-header + auto-size columns.
|
||||
*
|
||||
* RLS-обёртка SET LOCAL внутри транзакции (PgBouncer-safe). Чужие id
|
||||
* отфильтрует where(tenant_id) defense-in-depth.
|
||||
*/
|
||||
class DealExportController extends Controller
|
||||
{
|
||||
/** Заголовки таблицы — общие для CSV и XLSX. */
|
||||
private const HEADERS = ['ID', 'Имя', 'Телефон', 'Статус', 'Проект ID', 'Менеджер ID', 'Получено'];
|
||||
/** Заголовки — общие для CSV и XLSX. */
|
||||
private const HEADERS = ['Телефон', 'Источник', 'Город', 'Статус', 'Комментарий', 'Поставлен'];
|
||||
|
||||
/** signal_type → русская метка для колонки «Источник». */
|
||||
private const SIGNAL_LABELS = ['call' => 'Звонки', 'site' => 'Сайт', 'sms' => 'СМС'];
|
||||
|
||||
public function export(Request $request): StreamedResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'ids' => 'required|array|min:1|max:10000',
|
||||
'ids.*' => 'integer|min:1',
|
||||
'received_from' => 'nullable|date',
|
||||
'received_to' => 'nullable|date',
|
||||
'format' => 'nullable|string|in:csv,xlsx',
|
||||
]);
|
||||
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
$format = $validated['format'] ?? 'csv';
|
||||
$filename = 'deals_export_'.now()->format('Y-m-d').'.'.$format;
|
||||
$from = isset($validated['received_from']) && $validated['received_from'] !== ''
|
||||
? Carbon::parse($validated['received_from'])->startOfDay() : null;
|
||||
$to = isset($validated['received_to']) && $validated['received_to'] !== ''
|
||||
? Carbon::parse($validated['received_to'])->addDay()->startOfDay() : null;
|
||||
|
||||
$filename = 'deals_export_'.now()->format('Y-m-d').'.'.$format;
|
||||
$headers = $format === 'xlsx'
|
||||
? [
|
||||
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
@@ -64,14 +66,16 @@ class DealExportController extends Controller
|
||||
'Content-Disposition' => 'attachment; filename="'.$filename.'"',
|
||||
];
|
||||
|
||||
return new StreamedResponse(function () use ($validated, $tenantId, $format) {
|
||||
return new StreamedResponse(function () use ($tenantId, $format, $from, $to) {
|
||||
// RLS-контекст должен быть установлен внутри транзакции на момент
|
||||
// фактического SELECT. StreamedResponse callback вызывается уже
|
||||
// после Laravel-response pipeline'а, поэтому открываем транзакцию
|
||||
// прямо здесь.
|
||||
DB::transaction(function () use ($validated, $tenantId, $format) {
|
||||
DB::transaction(function () use ($tenantId, $format, $from, $to) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$statusNames = DB::table('lead_statuses')->pluck('name_ru', 'slug');
|
||||
|
||||
$writer = $this->openWriter($format);
|
||||
$writer->openToFile('php://output');
|
||||
|
||||
@@ -81,32 +85,41 @@ class DealExportController extends Controller
|
||||
if ($format === 'xlsx') {
|
||||
/** @var XlsxWriter $writer */
|
||||
$writer->getCurrentSheet()->setName('Сделки');
|
||||
$headerStyle = (new Style)->withFontBold(true);
|
||||
$writer->addRow(Row::fromValuesWithStyle(self::HEADERS, $headerStyle));
|
||||
$writer->addRow(Row::fromValuesWithStyle(self::HEADERS, (new Style)->withFontBold(true)));
|
||||
} else {
|
||||
$writer->addRow(Row::fromValues(self::HEADERS));
|
||||
}
|
||||
|
||||
// chunkById(500) — keyset-friendly; в нашем DealsView это
|
||||
// редкий тяжёлый action, экспортировать могут до 10K id.
|
||||
Deal::query()
|
||||
$query = Deal::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('id', $validated['ids'])
|
||||
->orderBy('id')
|
||||
->chunkById(500, function ($deals) use ($writer) {
|
||||
foreach ($deals as $deal) {
|
||||
/** @var Deal $deal */
|
||||
$writer->addRow(Row::fromValues([
|
||||
$deal->id,
|
||||
(string) ($deal->contact_name ?? ''),
|
||||
(string) $deal->phone,
|
||||
(string) $deal->status,
|
||||
$deal->project_id,
|
||||
$deal->manager_id ?? '',
|
||||
$deal->received_at->toDateTimeString(),
|
||||
]));
|
||||
}
|
||||
});
|
||||
->with('project:id,name,signal_type')
|
||||
->orderByDesc('received_at');
|
||||
|
||||
if ($from !== null) {
|
||||
$query->where('received_at', '>=', $from);
|
||||
}
|
||||
if ($to !== null) {
|
||||
$query->where('received_at', '<', $to);
|
||||
}
|
||||
|
||||
// chunkById(500) — keyset-friendly; deals.id — BIGSERIAL (unique),
|
||||
// корректно для чанкинга даже при партиционированной PK (id, received_at).
|
||||
$query->chunkById(500, function ($deals) use ($writer, $statusNames) {
|
||||
foreach ($deals as $deal) {
|
||||
/** @var Deal $deal */
|
||||
$signal = $deal->project?->signal_type;
|
||||
$source = trim(($deal->project?->name ?? '—').' · '
|
||||
.(self::SIGNAL_LABELS[$signal] ?? '—'));
|
||||
$writer->addRow(Row::fromValues([
|
||||
(string) $deal->phone,
|
||||
$source,
|
||||
(string) ($deal->city ?? ''),
|
||||
(string) ($statusNames[$deal->status] ?? $deal->status),
|
||||
(string) ($deal->comment ?? ''),
|
||||
$deal->received_at?->toDateTimeString() ?? '',
|
||||
]));
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
$writer->close();
|
||||
});
|
||||
@@ -120,12 +133,10 @@ class DealExportController extends Controller
|
||||
}
|
||||
|
||||
// CSV: ;-разделитель + UTF-8 BOM (Excel-friendly RU-локаль).
|
||||
$options = new CsvOptions(
|
||||
return new CsvWriter(new CsvOptions(
|
||||
FIELD_DELIMITER: ';',
|
||||
FIELD_ENCLOSURE: '"',
|
||||
SHOULD_ADD_BOM: true,
|
||||
);
|
||||
|
||||
return new CsvWriter($options);
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,10 +32,17 @@ class BulkProjectActionRequest extends FormRequest
|
||||
'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_regions') {
|
||||
// Plan 6.5: субъект-уровневые коды 1..89 (см. resources/js/constants/regions.ts).
|
||||
$rules['add_regions'] = ['nullable', 'array'];
|
||||
$rules['add_regions.*'] = ['integer', 'between:1,89'];
|
||||
$rules['remove_regions'] = ['nullable', 'array'];
|
||||
$rules['remove_regions.*'] = ['integer', 'between:1,89'];
|
||||
}
|
||||
|
||||
if ($action === 'update_days') {
|
||||
$rules['add'] = ['nullable', 'integer', 'min:0', 'max:127'];
|
||||
$rules['remove'] = ['nullable', 'integer', 'min:0', 'max:127'];
|
||||
}
|
||||
|
||||
if ($action === 'update_limit') {
|
||||
|
||||
@@ -105,7 +105,7 @@ final class HistoricalImportService
|
||||
}
|
||||
|
||||
/**
|
||||
* Маппит статус: каноническая таблица §6.4 → tenant-override → fallback 'new'.
|
||||
* Маппит статус: StatusRuToSlugMapper → tenant-override → fallback 'new'.
|
||||
* Неизвестный статус инкрементит счётчик в $unknown по ссылке.
|
||||
*
|
||||
* @param array<string, string> $overrides
|
||||
|
||||
@@ -5,29 +5,36 @@ declare(strict_types=1);
|
||||
namespace App\Services\Import;
|
||||
|
||||
/**
|
||||
* Маппинг русских названий статусов воронки в slug (ТЗ §6.4).
|
||||
* Маппинг русских названий статусов (старые 14 названий поставщика + новые 5)
|
||||
* в slug 5-статусной воронки (редизайн 2026-05-17).
|
||||
*
|
||||
* Чистый сервис без зависимостей. Tenant-специфичные переопределения
|
||||
* неизвестных статусов накладываются вызывающим кодом (HistoricalImportService).
|
||||
*/
|
||||
class StatusRuToSlugMapper
|
||||
{
|
||||
/** @var array<string, string> Канонический маппинг ТЗ §6.4 (14 статусов воронки). */
|
||||
/** @var array<string, string> Русские названия → 5 slug'ов воронки (редизайн 2026-05-17). */
|
||||
private const STATUS_RU_TO_SLUG = [
|
||||
'Новые' => 'new',
|
||||
// Новые названия 5-статусной воронки.
|
||||
'Новая сделка' => 'new',
|
||||
'Просмотрено' => 'viewed',
|
||||
'Проработан' => 'worked',
|
||||
'База' => 'base',
|
||||
'Недозвон' => 'missed',
|
||||
'Переговоры' => 'negotiations',
|
||||
'Ожидаем оплаты' => 'waiting_payment',
|
||||
'Партнерка' => 'partnership',
|
||||
'Оплачено' => 'paid',
|
||||
'Закрыто и не реализовано' => 'closed',
|
||||
'Тест драйв' => 'test_drive',
|
||||
'Горячий' => 'hot',
|
||||
'На замену' => 'replacement',
|
||||
'Конечный недозвон' => 'final_missed',
|
||||
'В работе' => 'in_progress',
|
||||
'Сделка' => 'won',
|
||||
'Не реализовано' => 'lost',
|
||||
// Старые 14 названий поставщика → новые slug'и (исторический CSV-импорт).
|
||||
'Новые' => 'new',
|
||||
'Проработан' => 'in_progress',
|
||||
'База' => 'in_progress',
|
||||
'Недозвон' => 'in_progress',
|
||||
'Переговоры' => 'in_progress',
|
||||
'Ожидаем оплаты' => 'in_progress',
|
||||
'Партнерка' => 'in_progress',
|
||||
'Оплачено' => 'won',
|
||||
'Закрыто и не реализовано' => 'lost',
|
||||
'Тест драйв' => 'in_progress',
|
||||
'Горячий' => 'in_progress',
|
||||
'На замену' => 'in_progress',
|
||||
'Конечный недозвон' => 'in_progress',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -39,7 +46,8 @@ class StatusRuToSlugMapper
|
||||
}
|
||||
|
||||
/**
|
||||
* Полная каноническая таблица — для UI wizard'а (показать варианты).
|
||||
* Полная таблица соответствия: русское название → slug 5-статусной воронки
|
||||
* (18 ключей — старые и новые названия схлопываются в 5 slug'ов).
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
|
||||
@@ -115,21 +115,40 @@ class ProjectService
|
||||
}
|
||||
|
||||
/**
|
||||
* LEGACY (Plan 6): обновляет только bitmask `region_mask` федеральных округов.
|
||||
* После Plan 6 источник истины региональной фильтрации — `regions` INT[];
|
||||
* outbound SyncSupplierProjectsJob читает `regions[]`, НЕ `region_mask`. Значит
|
||||
* этот bulk-action на реальную фильтрацию у поставщика не влияет. Субъект-уровневый
|
||||
* bulk-edit `regions[]` запланирован в Plan 6.5 (spec §13 — out of scope C9).
|
||||
* Plan 6.5: субъект-уровневый bulk-edit `regions` INT[].
|
||||
*
|
||||
* Для каждого проекта: regions := unique(regions ∪ add_regions) \ remove_regions,
|
||||
* отсортировано по возрастанию. `regions[]` — источник истины региональной
|
||||
* фильтрации с Plan 6 (outbound SyncSupplierProjectsJob читает именно его).
|
||||
* Legacy `region_mask` здесь не трогается — как и в одиночном PATCH
|
||||
* /api/projects/{id}; его удаление — Plan 6.5 cleanup.
|
||||
*
|
||||
* NB: проект с regions=[] («вся РФ») при add_regions сужается до выбранных
|
||||
* субъектов — это осознанное действие оператора bulk-диалога.
|
||||
*
|
||||
* Обновление идёт через model-инстанс (не query-builder mass update): каст
|
||||
* PostgresIntArray::set() сериализует PHP-массив в PG-литерал `{1,2,3}`, а
|
||||
* mass update каст не применяет. count ≤ BULK_MAX (500) — допустимо.
|
||||
*/
|
||||
private function bulkUpdateRegions($query, array $payload): array
|
||||
{
|
||||
$add = (int) ($payload['add'] ?? 0);
|
||||
$remove = (int) ($payload['remove'] ?? 0);
|
||||
$add = array_map('intval', $payload['add_regions'] ?? []);
|
||||
$remove = array_map('intval', $payload['remove_regions'] ?? []);
|
||||
|
||||
// region_mask = (region_mask | add) & ~remove, clamped to 8 bits (0–255)
|
||||
$updated = $query->update([
|
||||
'region_mask' => \DB::raw("(region_mask | {$add}) & ~{$remove} & 255"),
|
||||
]);
|
||||
if ($add === [] && $remove === []) {
|
||||
return ['updated' => 0, 'skipped' => [], 'warnings' => []];
|
||||
}
|
||||
|
||||
$projects = (clone $query)->get(['id', 'regions']);
|
||||
$updated = 0;
|
||||
|
||||
foreach ($projects as $project) {
|
||||
$next = array_values(array_unique([...($project->regions ?? []), ...$add]));
|
||||
$next = array_values(array_diff($next, $remove));
|
||||
sort($next);
|
||||
$project->update(['regions' => $next]);
|
||||
$updated++;
|
||||
}
|
||||
|
||||
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ use Illuminate\Support\Facades\DB;
|
||||
* managers_summary — агрегат сделок по менеджерам за период (audit F1).
|
||||
*
|
||||
* Группировка по deals.manager_id; неназначенные (manager_id IS NULL) сводятся
|
||||
* в строку «Не назначен». «Оплачено» = status='paid' (won-статус воронки, как
|
||||
* в DashboardController). Конверсия = paid / total * 100, округление до 0.1.
|
||||
* в строку «Не назначен». «Оплачено» = status='won' (won-статус воронки, как
|
||||
* в DashboardController). Конверсия = won / total * 100, округление до 0.1.
|
||||
*
|
||||
* parameters: date_from, date_to (Y-m-d). Исключаются soft-deleted
|
||||
* (deleted_at IS NULL) и тестовые (is_test=false) сделки. RLS-обёртка
|
||||
@@ -48,7 +48,7 @@ class ManagersSummaryProvider implements ReportDataProvider
|
||||
"deals.manager_id,
|
||||
users.first_name, users.last_name, users.email,
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE deals.status = 'paid') AS paid"
|
||||
COUNT(*) FILTER (WHERE deals.status = 'won') AS paid"
|
||||
)
|
||||
->get();
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ use Illuminate\Support\Facades\DB;
|
||||
* sources_summary — агрегат сделок по источнику (utm_source) за период (audit F1).
|
||||
*
|
||||
* Группировка по deals.utm_source; сделки без метки (NULL/пусто) сводятся в
|
||||
* строку «Прямые / без метки». «Оплачено» = status='paid'. Конверсия =
|
||||
* paid / total * 100, округление до 0.1.
|
||||
* строку «Прямые / без метки». «Оплачено» = status='won'. Конверсия =
|
||||
* won / total * 100, округление до 0.1.
|
||||
*
|
||||
* parameters: date_from, date_to (Y-m-d). Исключаются soft-deleted и тестовые
|
||||
* сделки. RLS-обёртка SET LOCAL app.current_tenant_id — паттерн DealsExportProvider.
|
||||
@@ -45,7 +45,7 @@ class SourcesSummaryProvider implements ReportDataProvider
|
||||
->selectRaw(
|
||||
"utm_source,
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE status = 'paid') AS paid"
|
||||
COUNT(*) FILTER (WHERE status = 'won') AS paid"
|
||||
)
|
||||
->get();
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Воронка статусов 14 → 5 (редизайн «Сделки» 2026-05-17).
|
||||
*
|
||||
* Новые 5: new / viewed / in_progress / won / lost. Slug'и `new` и `viewed`
|
||||
* сохраняются (RouteSupplierLeadJob / DealController@store default'ят 'new').
|
||||
* Ремап старых 14 → 5 в deals.status и import_unknown_statuses.mapped_to_slug
|
||||
* перед DELETE устаревших lead_statuses (FK-safe). tenant_status_overrides
|
||||
* со старыми slug'ами удаляются (кастомные ярлыки схлопнутых статусов
|
||||
* обсолетны + исключает PK-коллизию при ремапе).
|
||||
*
|
||||
* На migrate:fresh schema.sql уже сеет 5 — UPDATE/DELETE здесь no-op.
|
||||
* down() необратима (схлопывание lossy).
|
||||
*
|
||||
* Спека: docs/superpowers/specs/2026-05-17-deals-page-redesign-design.md §3.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
/** Старый slug → новый. new/viewed не меняются (отсутствуют в карте). */
|
||||
private const REMAP = [
|
||||
'worked' => 'in_progress', 'base' => 'in_progress', 'missed' => 'in_progress',
|
||||
'negotiations' => 'in_progress', 'waiting_payment' => 'in_progress',
|
||||
'partnership' => 'in_progress', 'test_drive' => 'in_progress', 'hot' => 'in_progress',
|
||||
'replacement' => 'in_progress', 'final_missed' => 'in_progress',
|
||||
'paid' => 'won', 'closed' => 'lost',
|
||||
];
|
||||
|
||||
private const KEEP = ['new', 'viewed', 'in_progress', 'won', 'lost'];
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
DB::transaction(function () {
|
||||
// 1) Новые slug'и обязаны существовать до ремапа FK-ссылок.
|
||||
DB::table('lead_statuses')->upsert([
|
||||
['slug' => 'new', 'name_ru' => 'Новая сделка', 'is_system' => true, 'sort_order' => 1, 'color_hex' => '#3B82F6'],
|
||||
['slug' => 'viewed', 'name_ru' => 'Просмотрено', 'is_system' => true, 'sort_order' => 2, 'color_hex' => '#8B5CF6'],
|
||||
['slug' => 'in_progress', 'name_ru' => 'В работе', 'is_system' => true, 'sort_order' => 3, 'color_hex' => '#06B6D4'],
|
||||
['slug' => 'won', 'name_ru' => 'Сделка', 'is_system' => true, 'sort_order' => 4, 'color_hex' => '#10B981'],
|
||||
['slug' => 'lost', 'name_ru' => 'Не реализовано', 'is_system' => true, 'sort_order' => 5, 'color_hex' => '#6B7280'],
|
||||
], ['slug'], ['name_ru', 'is_system', 'sort_order', 'color_hex']);
|
||||
|
||||
// 2) Ремап ссылок на старые slug'и.
|
||||
foreach (self::REMAP as $old => $new) {
|
||||
DB::table('deals')->where('status', $old)->update(['status' => $new]);
|
||||
DB::table('import_unknown_statuses')->where('mapped_to_slug', $old)->update(['mapped_to_slug' => $new]);
|
||||
}
|
||||
|
||||
// 3) Обсолетные кастомные ярлыки статусов — удалить (FK на lead_statuses).
|
||||
DB::table('tenant_status_overrides')->whereNotIn('status_slug', self::KEEP)->delete();
|
||||
|
||||
// 4) Удалить устаревшие статусы (все FK-ссылки перенаправлены).
|
||||
DB::table('lead_statuses')->whereNotIn('slug', self::KEEP)->delete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
throw new RuntimeException('Воронка 14→5 необратима (схлопывание статусов lossy).');
|
||||
}
|
||||
};
|
||||
@@ -54,12 +54,36 @@ parameters:
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/AdminTenantsController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Deal\:\:\$next_reminder_at\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/DealController.php
|
||||
|
||||
-
|
||||
message: '#^Using nullsafe method call on non\-nullable type Illuminate\\Support\\Carbon\. Use \-\> instead\.$#'
|
||||
identifier: nullsafe.neverNull
|
||||
count: 5
|
||||
path: app/Http/Controllers/Api/DealController.php
|
||||
|
||||
-
|
||||
message: '#^Expression on left side of \?\? is not nullable\.$#'
|
||||
identifier: nullCoalesce.expr
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/DealExportController.php
|
||||
|
||||
-
|
||||
message: '#^Using nullsafe method call on non\-nullable type Illuminate\\Support\\Carbon\. Use \-\> instead\.$#'
|
||||
identifier: nullsafe.neverNull
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/DealExportController.php
|
||||
|
||||
-
|
||||
message: '#^Using nullsafe property access "\?\-\>name" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
|
||||
identifier: nullsafe.neverNull
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/DealExportController.php
|
||||
|
||||
-
|
||||
message: '#^Cannot call method toIso8601String\(\) on null\.$#'
|
||||
identifier: method.nonObject
|
||||
@@ -411,7 +435,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 14
|
||||
count: 15
|
||||
path: tests/Feature/Api/ProjectBulkActionsTest.php
|
||||
|
||||
-
|
||||
@@ -837,7 +861,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 25
|
||||
count: 10
|
||||
path: tests/Feature/DealCreateTest.php
|
||||
|
||||
-
|
||||
@@ -882,6 +906,42 @@ parameters:
|
||||
count: 2
|
||||
path: tests/Feature/DealDestroyTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
|
||||
identifier: property.notFound
|
||||
count: 5
|
||||
path: tests/Feature/DealExportTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 6
|
||||
path: tests/Feature/DealExportTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealExportTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/DealExportTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:post\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 4
|
||||
path: tests/Feature/DealExportTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/DealExportTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$manager\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -897,7 +957,7 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
|
||||
identifier: property.notFound
|
||||
count: 32
|
||||
count: 38
|
||||
path: tests/Feature/DealIndexTest.php
|
||||
|
||||
-
|
||||
@@ -909,7 +969,7 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 36
|
||||
count: 41
|
||||
path: tests/Feature/DealIndexTest.php
|
||||
|
||||
-
|
||||
@@ -927,7 +987,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 24
|
||||
count: 29
|
||||
path: tests/Feature/DealIndexTest.php
|
||||
|
||||
-
|
||||
|
||||
@@ -130,6 +130,26 @@ export async function exportDealsXlsx(payload: Omit<ExportDealsPayload, 'format'
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface ExportDealsByRangePayload {
|
||||
tenant_id: number;
|
||||
received_from?: string;
|
||||
received_to?: string;
|
||||
format: 'csv' | 'xlsx';
|
||||
}
|
||||
|
||||
/**
|
||||
* Экспорт сделок по диапазону дат поставки. format='xlsx' → Blob, 'csv' → строка.
|
||||
*/
|
||||
export async function exportDealsByRange(payload: ExportDealsByRangePayload): Promise<Blob | string> {
|
||||
await ensureCsrfCookie();
|
||||
if (payload.format === 'xlsx') {
|
||||
const { data } = await apiClient.post<Blob>('/api/deals/export', payload, { responseType: 'blob' });
|
||||
return data;
|
||||
}
|
||||
const { data } = await apiClient.post<string>('/api/deals/export', payload, { responseType: 'text' });
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface ApiDeal {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
@@ -142,6 +162,10 @@ export interface ApiDeal {
|
||||
manager_name: string | null;
|
||||
manager_initials: string | null;
|
||||
received_at: string | null;
|
||||
comment: string | null;
|
||||
city: string | null;
|
||||
project_signal_type: string | null;
|
||||
next_reminder_at: string | null;
|
||||
}
|
||||
|
||||
export interface ApiDealEvent {
|
||||
@@ -175,6 +199,9 @@ export interface ListDealsParams {
|
||||
projectId?: number;
|
||||
managerId?: number;
|
||||
search?: string;
|
||||
/** Диапазон дат поставки (received_at). ISO-дата 'YYYY-MM-DD'. */
|
||||
receivedFrom?: string;
|
||||
receivedTo?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
/** «Корзина» — вернуть ТОЛЬКО soft-deleted сделки. */
|
||||
@@ -196,6 +223,8 @@ export async function listDeals(params: ListDealsParams): Promise<ListDealsRespo
|
||||
project_id: params.projectId,
|
||||
manager_id: params.managerId,
|
||||
search: params.search,
|
||||
received_from: params.receivedFrom,
|
||||
received_to: params.receivedTo,
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
only_deleted: params.onlyDeleted ? 'true' : undefined,
|
||||
|
||||
@@ -24,11 +24,11 @@ import FunnelChart from './FunnelChart.vue';
|
||||
</v-app>
|
||||
</Variant>
|
||||
|
||||
<Variant title="концентрация на 'Оплачено'">
|
||||
<Variant title="концентрация на 'Сделка'">
|
||||
<v-app>
|
||||
<v-main class="story-pane">
|
||||
<v-container>
|
||||
<FunnelChart :counts="{ paid: 100, new: 5, viewed: 5, worked: 5 }" />
|
||||
<FunnelChart :counts="{ won: 100, new: 5, viewed: 5, in_progress: 5 }" />
|
||||
</v-container>
|
||||
</v-main>
|
||||
</v-app>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Воронка распределения лидов по 14 статусам.
|
||||
* Воронка распределения лидов по 5 статусам воронки.
|
||||
*
|
||||
* Источник дизайна: liderra_v8_handoff/concepts/v8_dashboard.html секция .panel
|
||||
* с #funnel-title (segmented bar + funnel-list).
|
||||
@@ -13,7 +13,7 @@
|
||||
* Рендер:
|
||||
* 1. Segmented horizontal bar — каждый сегмент пропорционален count'у статуса
|
||||
* и закрашен colorHex из lead_statuses.
|
||||
* 2. funnel-list — 14 строк с цветным dot + name + count, отсортированы по
|
||||
* 2. funnel-list — 5 строк с цветным dot + name + count, отсортированы по
|
||||
* убыванию count'а (как в handoff).
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
@@ -26,23 +26,14 @@ interface Props {
|
||||
|
||||
// Default counts инлайнятся в withDefaults — Vue SFC compiler требует чтобы
|
||||
// factory-функция в withDefaults не реферировала модуль-уровневые const'ы
|
||||
// (checkInvalidScopeReference). Mock-распределение ~247 лидов по 14 статусам.
|
||||
// (checkInvalidScopeReference). Mock-распределение ~190 лидов по 5 статусам.
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
counts: () => ({
|
||||
new: 18,
|
||||
viewed: 14,
|
||||
worked: 22,
|
||||
base: 9,
|
||||
missed: 16,
|
||||
negotiations: 11,
|
||||
waiting_payment: 7,
|
||||
partnership: 4,
|
||||
paid: 45,
|
||||
closed: 3,
|
||||
test_drive: 38,
|
||||
hot: 5,
|
||||
replacement: 5,
|
||||
final_missed: 39,
|
||||
new: 24,
|
||||
viewed: 18,
|
||||
in_progress: 96,
|
||||
won: 41,
|
||||
lost: 11,
|
||||
}),
|
||||
title: 'Воронка',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Тело панели деталей сделки (hero + параметры + комментарий + напоминания +
|
||||
* timeline). Извлечено из DealDetailDrawer (редизайн 2026-05-17) — общее тело
|
||||
* для overlay-дровера (Канбан) и inline-панели master-detail («Сделки»).
|
||||
*
|
||||
* Backend: GET /api/deals/{id}, PATCH /api/deals/{id}, GET /api/deals/{id}/events.
|
||||
*/
|
||||
import { computed, defineAsyncComponent, ref, watch } from 'vue';
|
||||
import type { MockDeal } from '../../composables/mockDeals';
|
||||
import { type DealEvent } from '../../composables/mockDealEvents';
|
||||
import { mapApiDealEvent } from '../../composables/dealsApiMapper';
|
||||
import * as dealsApi from '../../api/deals';
|
||||
import * as remindersApi from '../../api/reminders';
|
||||
import type { ApiReminder } from '../../api/reminders';
|
||||
import { useLeadStatusesStore } from '../../stores/leadStatuses';
|
||||
import DealDetailHero from './DealDetailHero.vue';
|
||||
import DealDetailTimeline from './DealDetailTimeline.vue';
|
||||
const ReminderDialog = defineAsyncComponent(() => import('../reminders/ReminderDialog.vue'));
|
||||
|
||||
const leadStatusesStore = useLeadStatusesStore();
|
||||
|
||||
const props = defineProps<{
|
||||
deal: MockDeal | null;
|
||||
tenantId?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
const status = computed(() => {
|
||||
if (!props.deal) return null;
|
||||
return leadStatusesStore.findBySlug(props.deal.statusSlug);
|
||||
});
|
||||
|
||||
function formatCost(cost: number): string {
|
||||
return new Intl.NumberFormat('ru-RU').format(cost) + ' ₽';
|
||||
}
|
||||
|
||||
const events = ref<DealEvent[]>([]);
|
||||
const eventsLoading = ref(false);
|
||||
const eventsFetchError = ref(false);
|
||||
|
||||
const commentDraft = ref<string>('');
|
||||
const commentSaving = ref(false);
|
||||
const commentSaveError = ref(false);
|
||||
const commentToastOpen = ref(false);
|
||||
const commentToastText = ref('');
|
||||
|
||||
const reminders = ref<ApiReminder[]>([]);
|
||||
const remindersLoading = ref(false);
|
||||
const reminderDialogOpen = ref(false);
|
||||
|
||||
async function loadReminders() {
|
||||
if (!props.deal || !props.tenantId) {
|
||||
reminders.value = [];
|
||||
return;
|
||||
}
|
||||
remindersLoading.value = true;
|
||||
try {
|
||||
const res = await remindersApi.listReminders({ filter: 'active', dealId: props.deal.id });
|
||||
reminders.value = res.items;
|
||||
} catch {
|
||||
reminders.value = [];
|
||||
} finally {
|
||||
remindersLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function completeReminderInDrawer(id: number) {
|
||||
try {
|
||||
await remindersApi.completeReminder(id);
|
||||
reminders.value = reminders.value.filter((r) => r.id !== id);
|
||||
} catch {
|
||||
/* silent */
|
||||
}
|
||||
}
|
||||
|
||||
function onReminderSaved() {
|
||||
void loadReminders();
|
||||
}
|
||||
|
||||
function formatReminderTime(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const ms = new Date(iso).getTime() - Date.now();
|
||||
const min = Math.round(Math.abs(ms) / 60_000);
|
||||
const future = ms > 0;
|
||||
if (min < 60) return future ? `через ${min} мин` : `${min} мин назад`;
|
||||
const hr = Math.round(min / 60);
|
||||
if (hr < 24) return future ? `через ${hr} ч` : `${hr} ч назад`;
|
||||
const days = Math.round(hr / 24);
|
||||
return future ? `через ${days} д` : `${days} д назад`;
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
if (!props.deal || !props.tenantId) {
|
||||
events.value = [];
|
||||
commentDraft.value = '';
|
||||
return;
|
||||
}
|
||||
eventsLoading.value = true;
|
||||
eventsFetchError.value = false;
|
||||
try {
|
||||
const res = await dealsApi.getDeal(props.deal.id, props.tenantId);
|
||||
events.value = res.events.map((e) => mapApiDealEvent(e));
|
||||
commentDraft.value = res.deal.comment ?? '';
|
||||
} catch {
|
||||
eventsFetchError.value = true;
|
||||
events.value = [];
|
||||
commentDraft.value = '';
|
||||
} finally {
|
||||
eventsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveComment() {
|
||||
if (!props.deal || !props.tenantId) return;
|
||||
commentSaving.value = true;
|
||||
commentSaveError.value = false;
|
||||
try {
|
||||
await dealsApi.updateDeal(props.deal.id, {
|
||||
tenant_id: props.tenantId,
|
||||
comment: commentDraft.value || null,
|
||||
});
|
||||
commentToastText.value = 'Комментарий сохранён.';
|
||||
commentToastOpen.value = true;
|
||||
await loadEvents();
|
||||
} catch {
|
||||
commentSaveError.value = true;
|
||||
commentToastText.value = 'Не удалось сохранить — попробуйте позже.';
|
||||
commentToastOpen.value = true;
|
||||
} finally {
|
||||
commentSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Загрузка при появлении/смене сделки. Компонент смонтирован всегда — тело (<div v-if="deal">) рендерится только при deal != null.
|
||||
watch(
|
||||
() => [props.deal?.id, props.tenantId] as const,
|
||||
() => {
|
||||
if (props.deal) {
|
||||
loadEvents();
|
||||
void loadReminders();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
events, eventsLoading, eventsFetchError, loadEvents,
|
||||
commentDraft, commentSaving, commentSaveError, commentToastOpen, commentToastText, saveComment,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="deal" class="drawer-content">
|
||||
<DealDetailHero :deal="deal" :status="status" @close="emit('close')" />
|
||||
|
||||
<v-divider />
|
||||
|
||||
<section class="section pa-5">
|
||||
<h3 class="section-title text-subtitle-2 mb-3">Параметры</h3>
|
||||
<dl class="params">
|
||||
<div class="param">
|
||||
<dt class="text-caption text-medium-emphasis">Проект</dt>
|
||||
<dd class="text-body-2">{{ deal.project }}</dd>
|
||||
</div>
|
||||
<div class="param">
|
||||
<dt class="text-caption text-medium-emphasis">Стоимость лида</dt>
|
||||
<dd class="text-body-2 num">{{ formatCost(deal.cost) }}</dd>
|
||||
</div>
|
||||
<div class="param">
|
||||
<dt class="text-caption text-medium-emphasis">Менеджер</dt>
|
||||
<dd class="text-body-2">
|
||||
<v-avatar size="20" color="secondary" class="mr-1">
|
||||
<span class="text-caption">{{ deal.manager.initials }}</span>
|
||||
</v-avatar>
|
||||
{{ deal.manager.name }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="param">
|
||||
<dt class="text-caption text-medium-emphasis">Источник</dt>
|
||||
<dd class="text-body-2 link">Я.Директ → landing-1</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<section v-if="tenantId" class="section pa-5" data-testid="comment-section">
|
||||
<h3 class="section-title text-subtitle-2 mb-3">Комментарий</h3>
|
||||
<v-textarea
|
||||
v-model="commentDraft"
|
||||
placeholder="Заметка менеджера…"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
auto-grow
|
||||
rows="3"
|
||||
hide-details
|
||||
counter="5000"
|
||||
data-testid="comment-textarea"
|
||||
/>
|
||||
<div class="d-flex ga-2 mt-2 justify-end">
|
||||
<v-btn
|
||||
:loading="commentSaving"
|
||||
color="primary"
|
||||
size="small"
|
||||
prepend-icon="mdi-content-save-outline"
|
||||
data-testid="save-comment-btn"
|
||||
@click="saveComment"
|
||||
>
|
||||
Сохранить
|
||||
</v-btn>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<v-divider v-if="tenantId" />
|
||||
|
||||
<section v-if="tenantId && deal" class="section pa-5" data-testid="reminders-section">
|
||||
<div class="d-flex justify-space-between align-center mb-3">
|
||||
<h3 class="section-title text-subtitle-2 mb-0">Напоминания</h3>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
prepend-icon="mdi-plus"
|
||||
data-testid="add-reminder-btn"
|
||||
@click="reminderDialogOpen = true"
|
||||
>
|
||||
Создать
|
||||
</v-btn>
|
||||
</div>
|
||||
<div v-if="reminders.length === 0 && !remindersLoading" class="text-caption text-medium-emphasis">
|
||||
Нет активных напоминаний.
|
||||
</div>
|
||||
<ul v-else class="reminders-list">
|
||||
<li v-for="r in reminders" :key="r.id" class="reminder-row" data-testid="drawer-reminder-item">
|
||||
<v-btn
|
||||
icon="mdi-check-circle-outline"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
density="comfortable"
|
||||
:data-testid="`drawer-complete-${r.id}`"
|
||||
@click="completeReminderInDrawer(r.id)"
|
||||
/>
|
||||
<div class="reminder-body">
|
||||
<div class="reminder-text">{{ r.text || 'Без описания' }}</div>
|
||||
<div class="reminder-meta text-caption text-medium-emphasis">
|
||||
{{ formatReminderTime(r.remind_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<v-divider v-if="tenantId && deal" />
|
||||
|
||||
<DealDetailTimeline :events="events" :events-fetch-error="eventsFetchError" />
|
||||
|
||||
<v-snackbar
|
||||
v-model="commentToastOpen"
|
||||
:timeout="3000"
|
||||
:color="commentSaveError ? 'warning' : undefined"
|
||||
data-testid="comment-toast"
|
||||
location="bottom right"
|
||||
>
|
||||
{{ commentToastText }}
|
||||
</v-snackbar>
|
||||
|
||||
<ReminderDialog
|
||||
v-if="tenantId && deal"
|
||||
v-model="reminderDialogOpen"
|
||||
:deal-id="deal.id"
|
||||
@saved="onReminderSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.drawer-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.section-title {
|
||||
font-weight: 600;
|
||||
color: #081319;
|
||||
}
|
||||
.params {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px 12px;
|
||||
margin: 0;
|
||||
}
|
||||
.param dt {
|
||||
font-size: 11px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.param dd {
|
||||
margin: 0;
|
||||
color: #081319;
|
||||
}
|
||||
.param .link {
|
||||
color: #0f6e56;
|
||||
cursor: pointer;
|
||||
}
|
||||
.param .link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.num {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
.reminders-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.reminder-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #e8e3d6;
|
||||
border-radius: 6px;
|
||||
background: #fdfaf3;
|
||||
}
|
||||
.reminder-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.reminder-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: #081319;
|
||||
}
|
||||
.reminder-meta {
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -7,7 +7,7 @@ const open1 = ref(true);
|
||||
const open2 = ref(true);
|
||||
|
||||
const dealNew = MOCK_DEALS.find((d) => d.statusSlug === 'new')!;
|
||||
const dealPaid = MOCK_DEALS.find((d) => d.statusSlug === 'paid')!;
|
||||
const dealWon = MOCK_DEALS.find((d) => d.statusSlug === 'won')!;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -20,10 +20,10 @@ const dealPaid = MOCK_DEALS.find((d) => d.statusSlug === 'paid')!;
|
||||
</v-app>
|
||||
</Variant>
|
||||
|
||||
<Variant title="paid status">
|
||||
<Variant title="won status">
|
||||
<v-app>
|
||||
<v-main class="story-main">
|
||||
<DealDetailDrawer v-model:open="open2" :deal="dealPaid" />
|
||||
<DealDetailDrawer v-model:open="open2" :deal="dealWon" />
|
||||
</v-main>
|
||||
</v-app>
|
||||
</Variant>
|
||||
|
||||
@@ -1,43 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Правая панель с деталями сделки. Открывается при click на строку в DealsView
|
||||
* или на карточку в KanbanView.
|
||||
*
|
||||
* Источник дизайна: liderra_v8_handoff/concepts/v8_deal_card.html.
|
||||
* MVP: hero (имя + телефон + статус-chip + close), параметры (Проект/Стоимость/
|
||||
* Источник/Email), Activity timeline (5-7 событий).
|
||||
*
|
||||
* Не входит в этот коммит:
|
||||
* - Редактирование параметров (input-fields + save).
|
||||
* - Смена статуса через dropdown (на Канбане — через DnD).
|
||||
* - Tag management, manager assignment, reminders, comment/templates —
|
||||
* отдельные секции, отдельные коммиты.
|
||||
*
|
||||
* Backend:
|
||||
* - GET /api/deals/{id} — full detail with events.
|
||||
* - PATCH /api/deals/{id} — частичное обновление полей.
|
||||
* - GET /api/deals/{id}/events — `activity_log` фильтр по deal_id.
|
||||
* Обёртка панели деталей сделки. `inline=false` (по умолчанию) — overlay
|
||||
* v-navigation-drawer (Канбан). `inline=true` — боковая панель master-detail
|
||||
* для страницы «Сделки» (список сжимается, панель встаёт рядом, не перекрывает).
|
||||
* Тело — общий DealDetailBody.vue.
|
||||
*/
|
||||
import { computed, defineAsyncComponent, ref, watch } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import type { MockDeal } from '../../composables/mockDeals';
|
||||
import { type DealEvent } from '../../composables/mockDealEvents';
|
||||
import { mapApiDealEvent } from '../../composables/dealsApiMapper';
|
||||
import * as dealsApi from '../../api/deals';
|
||||
import * as remindersApi from '../../api/reminders';
|
||||
import type { ApiReminder } from '../../api/reminders';
|
||||
import { useLeadStatusesStore } from '../../stores/leadStatuses';
|
||||
import DealDetailHero from './DealDetailHero.vue';
|
||||
import DealDetailTimeline from './DealDetailTimeline.vue';
|
||||
// Sprint 2 Phase B / O-perf-06: ReminderDialog гейтится через v-model — chunk-split.
|
||||
const ReminderDialog = defineAsyncComponent(() => import('../reminders/ReminderDialog.vue'));
|
||||
import DealDetailBody from './DealDetailBody.vue';
|
||||
|
||||
const leadStatusesStore = useLeadStatusesStore();
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
deal: MockDeal | null;
|
||||
tenantId?: number;
|
||||
}>();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
open: boolean;
|
||||
deal: MockDeal | null;
|
||||
tenantId?: number;
|
||||
inline?: boolean;
|
||||
}>(),
|
||||
{ inline: false },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ 'update:open': [value: boolean] }>();
|
||||
|
||||
@@ -46,265 +26,24 @@ const drawerOpen = computed({
|
||||
set: (v) => emit('update:open', v),
|
||||
});
|
||||
|
||||
const status = computed(() => {
|
||||
if (!props.deal) return null;
|
||||
return leadStatusesStore.findBySlug(props.deal.statusSlug);
|
||||
});
|
||||
|
||||
function formatCost(cost: number): string {
|
||||
return new Intl.NumberFormat('ru-RU').format(cost) + ' ₽';
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
// Activity timeline: при наличии tenant_id делаем GET /api/deals/{id} и
|
||||
// показываем реальные events. На fail / без tenant_id — events пуст + eventsFetchError.
|
||||
const events = ref<DealEvent[]>([]);
|
||||
const eventsLoading = ref(false);
|
||||
const eventsFetchError = ref(false);
|
||||
|
||||
// Comment editor — редактирование текущего комментария сделки.
|
||||
const commentDraft = ref<string>('');
|
||||
const commentSaving = ref(false);
|
||||
const commentSaveError = ref(false);
|
||||
const commentToastOpen = ref(false);
|
||||
const commentToastText = ref('');
|
||||
|
||||
// Reminders на сделку — отдельная секция с inline-create + список.
|
||||
const reminders = ref<ApiReminder[]>([]);
|
||||
const remindersLoading = ref(false);
|
||||
const reminderDialogOpen = ref(false);
|
||||
|
||||
async function loadReminders() {
|
||||
if (!props.deal || !props.tenantId) {
|
||||
reminders.value = [];
|
||||
return;
|
||||
}
|
||||
remindersLoading.value = true;
|
||||
try {
|
||||
const res = await remindersApi.listReminders({ filter: 'active', dealId: props.deal.id });
|
||||
reminders.value = res.items;
|
||||
} catch {
|
||||
reminders.value = [];
|
||||
} finally {
|
||||
remindersLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function completeReminderInDrawer(id: number) {
|
||||
try {
|
||||
await remindersApi.completeReminder(id);
|
||||
reminders.value = reminders.value.filter((r) => r.id !== id);
|
||||
} catch {
|
||||
/* silent */
|
||||
}
|
||||
}
|
||||
|
||||
function onReminderSaved() {
|
||||
void loadReminders();
|
||||
}
|
||||
|
||||
function formatReminderTime(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const ms = new Date(iso).getTime() - Date.now();
|
||||
const min = Math.round(Math.abs(ms) / 60_000);
|
||||
const future = ms > 0;
|
||||
if (min < 60) return future ? `через ${min} мин` : `${min} мин назад`;
|
||||
const hr = Math.round(min / 60);
|
||||
if (hr < 24) return future ? `через ${hr} ч` : `${hr} ч назад`;
|
||||
const days = Math.round(hr / 24);
|
||||
return future ? `через ${days} д` : `${days} д назад`;
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
if (!props.deal || !props.tenantId) {
|
||||
events.value = [];
|
||||
commentDraft.value = '';
|
||||
return;
|
||||
}
|
||||
eventsLoading.value = true;
|
||||
eventsFetchError.value = false;
|
||||
try {
|
||||
const res = await dealsApi.getDeal(props.deal.id, props.tenantId);
|
||||
events.value = res.events.map((e) => mapApiDealEvent(e));
|
||||
commentDraft.value = res.deal.comment ?? '';
|
||||
} catch {
|
||||
eventsFetchError.value = true;
|
||||
events.value = [];
|
||||
commentDraft.value = '';
|
||||
} finally {
|
||||
eventsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveComment() {
|
||||
if (!props.deal || !props.tenantId) return;
|
||||
commentSaving.value = true;
|
||||
commentSaveError.value = false;
|
||||
try {
|
||||
await dealsApi.updateDeal(props.deal.id, {
|
||||
tenant_id: props.tenantId,
|
||||
comment: commentDraft.value || null,
|
||||
});
|
||||
commentToastText.value = 'Комментарий сохранён.';
|
||||
commentToastOpen.value = true;
|
||||
// Reload events чтобы показать новый deal.commented в timeline.
|
||||
await loadEvents();
|
||||
} catch {
|
||||
commentSaveError.value = true;
|
||||
commentToastText.value = 'Не удалось сохранить — попробуйте позже.';
|
||||
commentToastOpen.value = true;
|
||||
} finally {
|
||||
commentSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch при открытии drawer'а или смене сделки.
|
||||
watch(
|
||||
() => [props.open, props.deal?.id, props.tenantId] as const,
|
||||
([open]) => {
|
||||
if (open) {
|
||||
loadEvents();
|
||||
void loadReminders();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
events,
|
||||
eventsLoading,
|
||||
eventsFetchError,
|
||||
loadEvents,
|
||||
commentDraft,
|
||||
commentSaving,
|
||||
commentSaveError,
|
||||
commentToastOpen,
|
||||
commentToastText,
|
||||
saveComment,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-navigation-drawer v-model="drawerOpen" location="right" temporary :width="480" class="deal-drawer">
|
||||
<div v-if="deal" class="drawer-content">
|
||||
<DealDetailHero :deal="deal" :status="status" @close="drawerOpen = false" />
|
||||
|
||||
<v-divider />
|
||||
|
||||
<section class="section pa-5">
|
||||
<h3 class="section-title text-subtitle-2 mb-3">Параметры</h3>
|
||||
<dl class="params">
|
||||
<div class="param">
|
||||
<dt class="text-caption text-medium-emphasis">Проект</dt>
|
||||
<dd class="text-body-2">{{ deal.project }}</dd>
|
||||
</div>
|
||||
<div class="param">
|
||||
<dt class="text-caption text-medium-emphasis">Стоимость лида</dt>
|
||||
<dd class="text-body-2 num">{{ formatCost(deal.cost) }}</dd>
|
||||
</div>
|
||||
<div class="param">
|
||||
<dt class="text-caption text-medium-emphasis">Менеджер</dt>
|
||||
<dd class="text-body-2">
|
||||
<v-avatar size="20" color="secondary" class="mr-1">
|
||||
<span class="text-caption">{{ deal.manager.initials }}</span>
|
||||
</v-avatar>
|
||||
{{ deal.manager.name }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="param">
|
||||
<dt class="text-caption text-medium-emphasis">Источник</dt>
|
||||
<dd class="text-body-2 link">Я.Директ → landing-1</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<section v-if="tenantId" class="section pa-5" data-testid="comment-section">
|
||||
<h3 class="section-title text-subtitle-2 mb-3">Комментарий</h3>
|
||||
<v-textarea
|
||||
v-model="commentDraft"
|
||||
placeholder="Заметка менеджера…"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
auto-grow
|
||||
rows="3"
|
||||
hide-details
|
||||
counter="5000"
|
||||
data-testid="comment-textarea"
|
||||
/>
|
||||
<div class="d-flex ga-2 mt-2 justify-end">
|
||||
<v-btn
|
||||
:loading="commentSaving"
|
||||
color="primary"
|
||||
size="small"
|
||||
prepend-icon="mdi-content-save-outline"
|
||||
data-testid="save-comment-btn"
|
||||
@click="saveComment"
|
||||
>
|
||||
Сохранить
|
||||
</v-btn>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<v-divider v-if="tenantId" />
|
||||
|
||||
<section v-if="tenantId && deal" class="section pa-5" data-testid="reminders-section">
|
||||
<div class="d-flex justify-space-between align-center mb-3">
|
||||
<h3 class="section-title text-subtitle-2 mb-0">Напоминания</h3>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
prepend-icon="mdi-plus"
|
||||
data-testid="add-reminder-btn"
|
||||
@click="reminderDialogOpen = true"
|
||||
>
|
||||
Создать
|
||||
</v-btn>
|
||||
</div>
|
||||
<div v-if="reminders.length === 0 && !remindersLoading" class="text-caption text-medium-emphasis">
|
||||
Нет активных напоминаний.
|
||||
</div>
|
||||
<ul v-else class="reminders-list">
|
||||
<li v-for="r in reminders" :key="r.id" class="reminder-row" data-testid="drawer-reminder-item">
|
||||
<v-btn
|
||||
icon="mdi-check-circle-outline"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
density="comfortable"
|
||||
:data-testid="`drawer-complete-${r.id}`"
|
||||
@click="completeReminderInDrawer(r.id)"
|
||||
/>
|
||||
<div class="reminder-body">
|
||||
<div class="reminder-text">{{ r.text || 'Без описания' }}</div>
|
||||
<div class="reminder-meta text-caption text-medium-emphasis">
|
||||
{{ formatReminderTime(r.remind_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<v-divider v-if="tenantId && deal" />
|
||||
|
||||
<DealDetailTimeline :events="events" :events-fetch-error="eventsFetchError" />
|
||||
|
||||
<v-snackbar
|
||||
v-model="commentToastOpen"
|
||||
:timeout="3000"
|
||||
:color="commentSaveError ? 'warning' : undefined"
|
||||
data-testid="comment-toast"
|
||||
location="bottom right"
|
||||
>
|
||||
{{ commentToastText }}
|
||||
</v-snackbar>
|
||||
|
||||
<ReminderDialog
|
||||
v-if="tenantId && deal"
|
||||
v-model="reminderDialogOpen"
|
||||
:deal-id="deal.id"
|
||||
@saved="onReminderSaved"
|
||||
/>
|
||||
</div>
|
||||
<aside v-if="inline" v-show="open" class="deal-detail-inline" data-testid="deal-detail-panel">
|
||||
<DealDetailBody :deal="deal" :tenant-id="tenantId" @close="close" />
|
||||
</aside>
|
||||
<v-navigation-drawer
|
||||
v-else
|
||||
v-model="drawerOpen"
|
||||
location="right"
|
||||
temporary
|
||||
:width="480"
|
||||
class="deal-drawer"
|
||||
>
|
||||
<DealDetailBody :deal="deal" :tenant-id="tenantId" @close="close" />
|
||||
</v-navigation-drawer>
|
||||
</template>
|
||||
|
||||
@@ -312,75 +51,16 @@ defineExpose({
|
||||
.deal-drawer {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-weight: 600;
|
||||
color: #081319;
|
||||
}
|
||||
|
||||
.params {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px 12px;
|
||||
margin: 0;
|
||||
}
|
||||
.param dt {
|
||||
font-size: 11px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.param dd {
|
||||
margin: 0;
|
||||
color: #081319;
|
||||
}
|
||||
.param .link {
|
||||
color: #0f6e56;
|
||||
cursor: pointer;
|
||||
}
|
||||
.param .link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.num {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
|
||||
.reminders-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.reminder-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
.deal-detail-inline {
|
||||
flex: 0 0 400px;
|
||||
width: 400px;
|
||||
background: #fff;
|
||||
border: 1px solid #e8e3d6;
|
||||
border-radius: 6px;
|
||||
background: #fdfaf3;
|
||||
}
|
||||
|
||||
.reminder-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.reminder-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: #081319;
|
||||
}
|
||||
|
||||
.reminder-meta {
|
||||
margin-top: 2px;
|
||||
border-radius: 8px;
|
||||
overflow-y: auto;
|
||||
align-self: flex-start;
|
||||
max-height: calc(100vh - 160px);
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Sticky-bar bulk-actions для выбранных сделок (Sprint 3 Phase C).
|
||||
*
|
||||
* Показывается когда selectedCount > 0. В trash-mode — только кнопка
|
||||
* «Восстановить»; в обычном режиме — Сменить статус (menu со списком),
|
||||
* Экспорт, Удалить.
|
||||
*
|
||||
* Контракт: stateless presentation — родитель держит `selected`, `statusMenuOpen`,
|
||||
* `leadStatuses`, передаёт через props и слушает emit'ы.
|
||||
* Sticky-bar массовой смены статуса для выбранных сделок (редизайн 2026-05-17).
|
||||
* Только смена статуса — корзина/экспорт убраны (экспорт — панель по датам).
|
||||
*/
|
||||
import type { MockDeal } from '../../composables/mockDeals';
|
||||
import type { LeadStatus } from '../../composables/leadStatuses';
|
||||
|
||||
defineProps<{
|
||||
selectedCount: number;
|
||||
trashMode: boolean;
|
||||
statusMenuOpen: boolean;
|
||||
leadStatuses: LeadStatus[];
|
||||
}>();
|
||||
@@ -22,9 +15,6 @@ defineProps<{
|
||||
defineEmits<{
|
||||
'update:statusMenuOpen': [value: boolean];
|
||||
'apply-status': [slug: MockDeal['statusSlug']];
|
||||
'apply-export': [];
|
||||
'request-delete': [];
|
||||
'apply-restore-trash': [];
|
||||
'clear-selected': [];
|
||||
}>();
|
||||
</script>
|
||||
@@ -39,73 +29,38 @@ defineEmits<{
|
||||
data-testid="bulk-bar"
|
||||
>
|
||||
<div class="bulk-bar-inner">
|
||||
<span class="bulk-count">
|
||||
Выбрано <span class="num">{{ selectedCount }}</span>
|
||||
</span>
|
||||
<span class="bulk-count">Выбрано <span class="num">{{ selectedCount }}</span></span>
|
||||
<v-spacer />
|
||||
<!-- В trash-mode только Восстановить; в обычном режиме — полный набор. -->
|
||||
<v-btn
|
||||
v-if="trashMode"
|
||||
variant="tonal"
|
||||
color="success"
|
||||
size="small"
|
||||
prepend-icon="mdi-restore"
|
||||
data-testid="bulk-restore-trash-btn"
|
||||
@click="$emit('apply-restore-trash')"
|
||||
<v-menu
|
||||
:model-value="statusMenuOpen"
|
||||
:close-on-content-click="false"
|
||||
@update:model-value="(v: boolean) => $emit('update:statusMenuOpen', v)"
|
||||
>
|
||||
Восстановить
|
||||
</v-btn>
|
||||
<template v-if="!trashMode">
|
||||
<v-menu
|
||||
:model-value="statusMenuOpen"
|
||||
:close-on-content-click="false"
|
||||
@update:model-value="(v: boolean) => $emit('update:statusMenuOpen', v)"
|
||||
>
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
v-bind="menuProps"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
prepend-icon="mdi-tag-arrow-right"
|
||||
data-testid="bulk-status-btn"
|
||||
>
|
||||
Сменить статус
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact" max-height="320" min-width="240">
|
||||
<v-list-item
|
||||
v-for="s in leadStatuses"
|
||||
:key="s.slug"
|
||||
:data-testid="`bulk-status-item-${s.slug}`"
|
||||
@click="$emit('apply-status', s.slug)"
|
||||
>
|
||||
<template #prepend>
|
||||
<span class="status-dot" :style="{ background: s.colorHex }" />
|
||||
</template>
|
||||
<v-list-item-title>{{ s.nameRu }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-btn
|
||||
variant="tonal"
|
||||
size="small"
|
||||
prepend-icon="mdi-download"
|
||||
data-testid="bulk-export-btn"
|
||||
@click="$emit('apply-export')"
|
||||
>
|
||||
Экспорт
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="tonal"
|
||||
color="error"
|
||||
size="small"
|
||||
prepend-icon="mdi-trash-can-outline"
|
||||
data-testid="bulk-delete-btn"
|
||||
@click="$emit('request-delete')"
|
||||
>
|
||||
Удалить
|
||||
</v-btn>
|
||||
</template>
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
v-bind="menuProps"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
prepend-icon="mdi-tag-arrow-right"
|
||||
data-testid="bulk-status-btn"
|
||||
>
|
||||
Сменить статус
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact" max-height="320" min-width="240">
|
||||
<v-list-item
|
||||
v-for="s in leadStatuses"
|
||||
:key="s.slug"
|
||||
:data-testid="`bulk-status-item-${s.slug}`"
|
||||
@click="$emit('apply-status', s.slug)"
|
||||
>
|
||||
<template #prepend>
|
||||
<span class="status-dot" :style="{ background: s.colorHex }" />
|
||||
</template>
|
||||
<v-list-item-title>{{ s.nameRu }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
variant="text"
|
||||
@@ -123,7 +78,6 @@ defineEmits<{
|
||||
font-feature-settings: 'tnum';
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
@@ -131,7 +85,6 @@ defineEmits<{
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.bulk-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
||||
@@ -1,123 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Filter-bar для DealsView (Sprint 3 Phase C):
|
||||
* - btn-toggle с DEALS_TABS (active/all/...) + chip-counts
|
||||
* - search input (имя/телефон/проект)
|
||||
* - multi-select Проект и Менеджер
|
||||
* - кнопка «Сбросить фильтры» (если хоть один из multi-select заполнен)
|
||||
*
|
||||
* Состояние держится в родителе через v-model:* (двунаправленные связки).
|
||||
* Фильтр-бар реестра «Сделки»: поиск по телефону + 3 select'а (Статус, Проект,
|
||||
* Город). Состояние держит родитель через v-model:*. Город — пока без данных
|
||||
* (источник §4 спеки не определён): select disabled при пустом availableCities.
|
||||
*/
|
||||
import { DEALS_TABS } from '../../composables/mockDeals';
|
||||
import type { LeadStatus } from '../../composables/leadStatuses';
|
||||
|
||||
defineProps<{
|
||||
activeTab: (typeof DEALS_TABS)[number]['id'];
|
||||
searchQuery: string;
|
||||
filterProjects: string[];
|
||||
filterManagers: string[];
|
||||
availableProjects: string[];
|
||||
availableManagers: { name: string; initials: string }[];
|
||||
counts: Record<string, number>;
|
||||
const props = defineProps<{
|
||||
searchPhone: string;
|
||||
filterStatus: string | null;
|
||||
filterProject: number | null;
|
||||
filterCity: string | null;
|
||||
leadStatuses: LeadStatus[];
|
||||
availableProjects: { id: number; name: string }[];
|
||||
availableCities: string[];
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
'update:activeTab': [value: (typeof DEALS_TABS)[number]['id']];
|
||||
'update:searchQuery': [value: string];
|
||||
'update:filterProjects': [value: string[]];
|
||||
'update:filterManagers': [value: string[]];
|
||||
'update:searchPhone': [value: string];
|
||||
'update:filterStatus': [value: string | null];
|
||||
'update:filterProject': [value: number | null];
|
||||
'update:filterCity': [value: string | null];
|
||||
'clear-filters': [];
|
||||
}>();
|
||||
|
||||
const hasActiveFilter = () =>
|
||||
props.filterStatus !== null || props.filterProject !== null || props.filterCity !== null;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="filter-bar mt-4">
|
||||
<v-btn-toggle
|
||||
:model-value="activeTab"
|
||||
mandatory
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
@update:model-value="(v: (typeof DEALS_TABS)[number]['id']) => $emit('update:activeTab', v)"
|
||||
>
|
||||
<v-btn v-for="tab in DEALS_TABS" :key="tab.id" :value="tab.id" size="small">
|
||||
{{ tab.label }}
|
||||
<v-chip size="x-small" class="ml-2 chip-count" variant="tonal">
|
||||
{{ counts[tab.id] }}
|
||||
</v-chip>
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
|
||||
<div class="deals-filters">
|
||||
<v-text-field
|
||||
:model-value="searchQuery"
|
||||
placeholder="Поиск: имя, телефон, проект…"
|
||||
:model-value="searchPhone"
|
||||
placeholder="Поиск по телефону…"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
clearable
|
||||
class="search-input ml-4"
|
||||
@update:model-value="(v: string) => $emit('update:searchQuery', v ?? '')"
|
||||
class="filters-search"
|
||||
data-testid="filter-search-phone"
|
||||
@update:model-value="(v: string) => $emit('update:searchPhone', v ?? '')"
|
||||
/>
|
||||
|
||||
<v-select
|
||||
:model-value="filterProjects"
|
||||
:model-value="filterStatus"
|
||||
:items="leadStatuses"
|
||||
item-title="nameRu"
|
||||
item-value="slug"
|
||||
label="Статус"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
clearable
|
||||
class="filters-select"
|
||||
data-testid="filter-status"
|
||||
@update:model-value="(v: string | null) => $emit('update:filterStatus', v ?? null)"
|
||||
/>
|
||||
<v-select
|
||||
:model-value="filterProject"
|
||||
:items="availableProjects"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
clearable
|
||||
item-title="name"
|
||||
item-value="id"
|
||||
label="Проект"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
label="Проект"
|
||||
style="min-width: 180px; max-width: 260px"
|
||||
data-testid="filter-projects"
|
||||
@update:model-value="(v: string[]) => $emit('update:filterProjects', v ?? [])"
|
||||
clearable
|
||||
class="filters-select"
|
||||
data-testid="filter-project"
|
||||
@update:model-value="(v: number | null) => $emit('update:filterProject', v ?? null)"
|
||||
/>
|
||||
<v-select
|
||||
:model-value="filterManagers"
|
||||
:items="availableManagers"
|
||||
item-title="name"
|
||||
item-value="name"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
clearable
|
||||
:model-value="filterCity"
|
||||
:items="availableCities"
|
||||
label="Город"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
label="Менеджер"
|
||||
style="min-width: 180px; max-width: 260px"
|
||||
data-testid="filter-managers"
|
||||
@update:model-value="(v: string[]) => $emit('update:filterManagers', v ?? [])"
|
||||
clearable
|
||||
:disabled="availableCities.length === 0"
|
||||
class="filters-select"
|
||||
data-testid="filter-city"
|
||||
@update:model-value="(v: string | null) => $emit('update:filterCity', v ?? null)"
|
||||
/>
|
||||
<v-btn
|
||||
v-if="filterProjects.length > 0 || filterManagers.length > 0"
|
||||
v-if="hasActiveFilter()"
|
||||
variant="text"
|
||||
size="small"
|
||||
prepend-icon="mdi-filter-off"
|
||||
data-testid="clear-filters-btn"
|
||||
@click="$emit('clear-filters')"
|
||||
>
|
||||
Сбросить фильтры
|
||||
Сбросить
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-bar {
|
||||
.deals-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1 1 320px;
|
||||
max-width: 360px;
|
||||
.filters-search {
|
||||
flex: 1 1 240px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.chip-count {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
.filters-select {
|
||||
min-width: 170px;
|
||||
max-width: 220px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,32 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Таблица сделок (Sprint 3 Phase C — extraction из DealsView).
|
||||
*
|
||||
* Логически замкнутый блок: v-data-table со всеми типизированными слотами
|
||||
* (Vuetify 3.12 VDataTableSlots, Sprint 2 Phase B / O-stack-05).
|
||||
*
|
||||
* Контракт:
|
||||
* props:
|
||||
* - deals: MockDeal[] — отфильтрованный список (computed в родителе).
|
||||
* - selectedIds: number[] — v-model:selected (двунаправленно).
|
||||
* - statusBySlug: Map<string, LeadStatus> — для status-chip color/label.
|
||||
* emits:
|
||||
* - update:selectedIds — sync v-model selected с родителем.
|
||||
* - row-click(deal) — раскрыть drawer.
|
||||
* Таблица реестра лидов «Сделки» (редизайн 2026-05-17).
|
||||
* Колонки: чекбокс · Телефон · Источник · Город · Статус · Напоминание ·
|
||||
* Комментарий · Поставлен. Напоминание/Комментарий — read-only.
|
||||
*/
|
||||
import type { MockDeal } from '../../composables/mockDeals';
|
||||
import type { LeadStatus } from '../../composables/leadStatuses';
|
||||
import StatusPill from '../ui/StatusPill.vue';
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
deals: MockDeal[];
|
||||
selectedIds: number[];
|
||||
statusBySlug: Map<string, LeadStatus>;
|
||||
// Task 15: row height from density toggle (44 comfortable / 36 compact).
|
||||
rowHeight?: number;
|
||||
activeDealId?: number | null;
|
||||
}>(),
|
||||
{ rowHeight: 44 },
|
||||
{ activeDealId: null },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -34,18 +23,22 @@ const emit = defineEmits<{
|
||||
'row-click': [deal: MockDeal];
|
||||
}>();
|
||||
|
||||
function onSelectedUpdate(value: number[]) {
|
||||
emit('update:selectedIds', value);
|
||||
const SIGNAL_LABELS: Record<string, string> = { call: 'Звонки', site: 'Сайт', sms: 'СМС' };
|
||||
|
||||
function signalLabel(t: MockDeal['signalType']): string {
|
||||
return t ? (SIGNAL_LABELS[t] ?? '') : '';
|
||||
}
|
||||
|
||||
function formatRelative(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes} мин назад`;
|
||||
if (minutes < 60 * 24) return `${Math.floor(minutes / 60)} ч назад`;
|
||||
return `${Math.floor(minutes / (60 * 24))} д назад`;
|
||||
function formatDateTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
function formatCost(cost: number): string {
|
||||
return new Intl.NumberFormat('ru-RU').format(cost) + ' ₽';
|
||||
function rowProps(deal: MockDeal): Record<string, unknown> {
|
||||
return { class: deal.id === props.activeDealId ? 'deals-row-active' : '' };
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -55,72 +48,61 @@ function formatCost(cost: number): string {
|
||||
:model-value="selectedIds"
|
||||
:items="deals"
|
||||
:headers="[
|
||||
{ title: 'Лид', key: 'name', sortable: true },
|
||||
{ title: 'Телефон', key: 'phone', sortable: true },
|
||||
{ title: 'Источник', key: 'project', sortable: false },
|
||||
{ title: 'Город', key: 'city', sortable: false },
|
||||
{ title: 'Статус', key: 'statusSlug', sortable: false },
|
||||
{ title: 'Проект', key: 'project', sortable: false },
|
||||
{ title: 'Менеджер', key: 'manager', sortable: false },
|
||||
{ title: 'Стоимость', key: 'cost', align: 'end', sortable: true },
|
||||
{ title: 'Время', key: 'receivedMinutesAgo', align: 'end', sortable: true },
|
||||
{ title: 'Напоминание', key: 'nextReminderAt', sortable: true },
|
||||
{ title: 'Комментарий', key: 'comment', sortable: false },
|
||||
{ title: 'Поставлен', key: 'receivedAt', align: 'end', sortable: true },
|
||||
]"
|
||||
show-select
|
||||
item-value="id"
|
||||
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' } })"
|
||||
@update:model-value="onSelectedUpdate"
|
||||
:row-props="(p: { item: MockDeal }) => rowProps(p.item)"
|
||||
@update:model-value="(v: number[]) => emit('update:selectedIds', v)"
|
||||
@click:row="(_e: Event, { item }: { item: MockDeal }) => emit('row-click', item)"
|
||||
>
|
||||
<!--
|
||||
Vuetify 3.12 типизированные слоты VDataTable (Sprint 2 Phase B / O-stack-05).
|
||||
`:items="deals"` (MockDeal[]) → Vuetify через VDataTableSlots<ItemType<T>>
|
||||
выводит `item` как `MockDeal` автоматически. Дополнительная inline-аннотация
|
||||
`{ item }: { item: MockDeal }` фиксирует этот контракт явно — IDE и vue-tsc
|
||||
проверяют доступ к полям статически.
|
||||
-->
|
||||
<template #[`item.name`]="{ item }: { item: MockDeal }">
|
||||
<div class="cell-deal">
|
||||
<v-avatar size="32" color="primary" class="mr-3">
|
||||
<span class="text-caption font-weight-medium">{{
|
||||
item.name
|
||||
.split(' ')
|
||||
.map((p: string) => p[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
}}</span>
|
||||
</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>
|
||||
<template #[`item.phone`]="{ item }: { item: MockDeal }">
|
||||
<span class="num ld-mono">{{ item.phone }}</span>
|
||||
</template>
|
||||
|
||||
<template #[`item.project`]="{ item }: { item: MockDeal }">
|
||||
<div class="cell-source">
|
||||
<span class="source-project">{{ item.project }}</span>
|
||||
<span v-if="signalLabel(item.signalType)" class="source-signal">{{
|
||||
signalLabel(item.signalType)
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #[`item.city`]="{ item }: { item: MockDeal }">
|
||||
<span :class="{ 'text-medium-emphasis': !item.city }">{{ item.city || '—' }}</span>
|
||||
</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"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #[`item.manager`]="{ item }: { item: MockDeal }">
|
||||
<div class="cell-manager">
|
||||
<v-avatar size="22" color="secondary" class="mr-2">
|
||||
<span class="text-caption">{{ item.manager.initials }}</span>
|
||||
</v-avatar>
|
||||
{{ item.manager.name }}
|
||||
</div>
|
||||
<template #[`item.nextReminderAt`]="{ item }: { item: MockDeal }">
|
||||
<span class="num ld-mono-s" :class="{ 'text-medium-emphasis': !item.nextReminderAt }">{{
|
||||
formatDateTime(item.nextReminderAt)
|
||||
}}</span>
|
||||
</template>
|
||||
|
||||
<template #[`item.cost`]="{ item }: { item: MockDeal }">
|
||||
<span class="num ld-mono">{{ formatCost(item.cost) }}</span>
|
||||
<template #[`item.comment`]="{ item }: { item: MockDeal }">
|
||||
<span class="cell-comment" :class="{ 'text-medium-emphasis': !item.comment }">{{
|
||||
item.comment || '—'
|
||||
}}</span>
|
||||
</template>
|
||||
|
||||
<template #[`item.receivedMinutesAgo`]="{ item }: { item: MockDeal }">
|
||||
<span class="num ld-mono-s text-medium-emphasis">{{ formatRelative(item.receivedMinutesAgo) }}</span>
|
||||
<template #[`item.receivedAt`]="{ item }: { item: MockDeal }">
|
||||
<span class="num ld-mono-s">{{ formatDateTime(item.receivedAt) }}</span>
|
||||
</template>
|
||||
|
||||
<template #[`header.data-table-select`]="{ allSelected, selectAll, someSelected }">
|
||||
@@ -135,8 +117,8 @@ function formatCost(cost: number): string {
|
||||
<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)"
|
||||
:aria-label="`Выбрать сделку «${(item as MockDeal).phone}»`"
|
||||
@update:model-value="() => toggleSelect(internalItem)"
|
||||
/>
|
||||
</template>
|
||||
</v-data-table>
|
||||
@@ -151,34 +133,32 @@ function formatCost(cost: number): string {
|
||||
.deals-table-card {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.num {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cell-deal {
|
||||
.cell-source {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
flex-direction: column;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.deal-name {
|
||||
.source-project {
|
||||
font-weight: 500;
|
||||
color: #081319;
|
||||
}
|
||||
|
||||
.cell-manager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.source-signal {
|
||||
font-size: 11px;
|
||||
color: #6b6356;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
.cell-comment {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
:deep(.deals-row-active) {
|
||||
background: rgba(15, 110, 86, 0.07);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Wizard маппинга неизвестных статусов воронки из CSV-импорта (ТЗ §6.4/§6.6).
|
||||
*
|
||||
* Для каждого незамапленного русского статуса пользователь выбирает один из
|
||||
* 14 канонических slug'ов. Сохранение → POST /api/imports/unknown-statuses/resolve.
|
||||
* 5 slug'ов воронки. Сохранение → POST /api/imports/unknown-statuses/resolve.
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { resolveUnknownStatuses, type StatusMapping, type UnknownStatus } from '../../api/imports';
|
||||
@@ -18,22 +18,13 @@ const emit = defineEmits<{
|
||||
resolved: [];
|
||||
}>();
|
||||
|
||||
/** 14 канонических статусов воронки (ТЗ §6.4). */
|
||||
/** 5 статусов воронки (редизайн 2026-05-17). */
|
||||
const STATUS_OPTIONS: { value: string; title: string }[] = [
|
||||
{ value: 'new', title: 'Новые' },
|
||||
{ value: 'new', title: 'Новая сделка' },
|
||||
{ value: 'viewed', title: 'Просмотрено' },
|
||||
{ value: 'worked', title: 'Проработан' },
|
||||
{ value: 'base', title: 'База' },
|
||||
{ value: 'missed', title: 'Недозвон' },
|
||||
{ value: 'negotiations', title: 'Переговоры' },
|
||||
{ value: 'waiting_payment', title: 'Ожидаем оплаты' },
|
||||
{ value: 'partnership', title: 'Партнерка' },
|
||||
{ value: 'paid', title: 'Оплачено' },
|
||||
{ value: 'closed', title: 'Закрыто и не реализовано' },
|
||||
{ value: 'test_drive', title: 'Тест драйв' },
|
||||
{ value: 'hot', title: 'Горячий' },
|
||||
{ value: 'replacement', title: 'На замену' },
|
||||
{ value: 'final_missed', title: 'Конечный недозвон' },
|
||||
{ value: 'in_progress', title: 'В работе' },
|
||||
{ value: 'won', title: 'Сделка' },
|
||||
{ value: 'lost', title: 'Не реализовано' },
|
||||
];
|
||||
|
||||
const selection = reactive<Record<string, string | null>>({});
|
||||
|
||||
@@ -4,9 +4,9 @@ import { LEAD_STATUSES } from '../../composables/leadStatuses';
|
||||
import { MOCK_DEALS } from '../../composables/mockDeals';
|
||||
|
||||
const newStatus = LEAD_STATUSES.find((s) => s.slug === 'new')!;
|
||||
const paidStatus = LEAD_STATUSES.find((s) => s.slug === 'paid')!;
|
||||
const wonStatus = LEAD_STATUSES.find((s) => s.slug === 'won')!;
|
||||
const newDeals = MOCK_DEALS.filter((d) => d.statusSlug === 'new');
|
||||
const paidDeals = MOCK_DEALS.filter((d) => d.statusSlug === 'paid');
|
||||
const wonDeals = MOCK_DEALS.filter((d) => d.statusSlug === 'won');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -19,10 +19,10 @@ const paidDeals = MOCK_DEALS.filter((d) => d.statusSlug === 'paid');
|
||||
</v-app>
|
||||
</Variant>
|
||||
|
||||
<Variant title="«Оплачено» (2 сделки)">
|
||||
<Variant title="«Сделка» (2 сделки)">
|
||||
<v-app>
|
||||
<v-main class="story-pane">
|
||||
<KanbanColumn :status="paidStatus" :deals="paidDeals" />
|
||||
<KanbanColumn :status="wonStatus" :deals="wonDeals" />
|
||||
</v-main>
|
||||
</v-app>
|
||||
</Variant>
|
||||
|
||||
@@ -53,14 +53,12 @@ const navGroups = computed<NavGroup[]>(() => [
|
||||
},
|
||||
{ title: 'Канбан', icon: 'mdi-view-column-outline', to: '/kanban' },
|
||||
{ title: 'Дашборд', icon: 'mdi-view-dashboard-outline', to: '/dashboard' },
|
||||
{ title: 'Импорт данных', icon: 'mdi-database-import-outline', to: '/import' },
|
||||
],
|
||||
},
|
||||
{
|
||||
eyebrow: 'Финансы',
|
||||
items: [
|
||||
{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/billing' },
|
||||
{ title: 'Отчёты', icon: 'mdi-chart-box-outline', to: '/reports' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ import axios from 'axios';
|
||||
import type { Project } from '../../stores/projectsStore';
|
||||
import { useProjectsStore } from '../../stores/projectsStore';
|
||||
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
|
||||
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
|
||||
|
||||
const props = defineProps<{ project: Project | null }>();
|
||||
const emit = defineEmits<{ close: []; saved: [] }>();
|
||||
@@ -152,6 +153,7 @@ const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||
density="comfortable"
|
||||
hide-details
|
||||
data-testid="pdd-regions"
|
||||
@update:menu="repositionMenuAfterOpen"
|
||||
>
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<v-list-item v-bind="itemProps">
|
||||
|
||||
@@ -3,41 +3,69 @@
|
||||
<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>
|
||||
<p class="text-caption text-medium-emphasis mb-4">
|
||||
Изменения применяются к каждому из {{ count }} выбранных проектов: выбранные субъекты
|
||||
добавляются к их регионам или убираются из них.
|
||||
</p>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="text-caption text-success font-weight-medium mb-2">➕ Добавить регионы</div>
|
||||
<v-autocomplete
|
||||
v-model="addRegions"
|
||||
:items="selectableRegions"
|
||||
item-title="name"
|
||||
item-value="code"
|
||||
label="Субъекты РФ"
|
||||
multiple
|
||||
chips
|
||||
clearable
|
||||
density="comfortable"
|
||||
data-testid="region-add-select"
|
||||
@update:menu="repositionMenuAfterOpen"
|
||||
>
|
||||
<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>
|
||||
<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 class="text-caption text-error font-weight-medium mb-2">➖ Убрать регионы</div>
|
||||
<v-autocomplete
|
||||
v-model="removeRegions"
|
||||
:items="selectableRegions"
|
||||
item-title="name"
|
||||
item-value="code"
|
||||
label="Субъекты РФ"
|
||||
multiple
|
||||
chips
|
||||
clearable
|
||||
density="comfortable"
|
||||
data-testid="region-remove-select"
|
||||
@update:menu="repositionMenuAfterOpen"
|
||||
>
|
||||
<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>
|
||||
</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"
|
||||
<v-btn
|
||||
color="primary"
|
||||
data-testid="apply"
|
||||
:disabled="addRegions.length === 0 && removeRegions.length === 0"
|
||||
@click="apply"
|
||||
>Применить к {{ count }}</v-btn
|
||||
>
|
||||
</v-card-actions>
|
||||
@@ -47,47 +75,40 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { FEDERAL_DISTRICTS } from '../../constants/federal-districts';
|
||||
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
|
||||
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; count: number }>();
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
apply: [payload: { add: number; remove: number }];
|
||||
apply: [payload: { add_regions: number[]; remove_regions: number[] }];
|
||||
}>();
|
||||
|
||||
// code:0 — sentinel «Вся РФ»; в bulk add/remove субъектов не выбирается.
|
||||
const selectableRegions = REGIONS.filter((r) => r.code !== 0);
|
||||
|
||||
const open = ref(props.modelValue);
|
||||
const addMask = ref(0);
|
||||
const removeMask = ref(0);
|
||||
const addRegions = ref<number[]>([]);
|
||||
const removeRegions = ref<number[]>([]);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
open.value = val;
|
||||
if (val) {
|
||||
addMask.value = 0;
|
||||
removeMask.value = 0;
|
||||
addRegions.value = [];
|
||||
removeRegions.value = [];
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
watch(open, (val) => emit('update:modelValue', val));
|
||||
|
||||
function apply() {
|
||||
emit('apply', { add: addMask.value, remove: removeMask.value });
|
||||
addMask.value = 0;
|
||||
removeMask.value = 0;
|
||||
emit('apply', { add_regions: [...addRegions.value], remove_regions: [...removeRegions.value] });
|
||||
addRegions.value = [];
|
||||
removeRegions.value = [];
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
defineExpose({ addRegions, removeRegions, apply });
|
||||
</script>
|
||||
|
||||
@@ -73,5 +73,10 @@ export function mapApiDeal(api: ApiDeal, now: Date = new Date()): MockDeal {
|
||||
},
|
||||
cost: 0,
|
||||
receivedMinutesAgo,
|
||||
signalType: (api.project_signal_type as MockDeal['signalType']) ?? null,
|
||||
city: api.city,
|
||||
comment: api.comment,
|
||||
receivedAt: api.received_at,
|
||||
nextReminderAt: api.next_reminder_at,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 14 системных и пользовательских статусов воронки.
|
||||
* 5 системных статусов воронки (редизайн 2026-05-17).
|
||||
*
|
||||
* Источник истины: db/schema.sql:2130 (lead_statuses seed). НЕ из BRANDBOOK_v2 §3.6
|
||||
* (расхождение #1 handoff vs ТЗ из реестра v1.13: handoff содержит 14 «обобщённых»
|
||||
@@ -18,18 +18,9 @@ export interface LeadStatus {
|
||||
}
|
||||
|
||||
export const LEAD_STATUSES: LeadStatus[] = [
|
||||
{ slug: 'new', nameRu: 'Новые', isSystem: true, sortOrder: 1, colorHex: '#3B82F6' },
|
||||
{ slug: 'new', nameRu: 'Новая сделка', isSystem: true, sortOrder: 1, colorHex: '#3B82F6' },
|
||||
{ slug: 'viewed', nameRu: 'Просмотрено', isSystem: true, sortOrder: 2, colorHex: '#8B5CF6' },
|
||||
{ slug: 'worked', nameRu: 'Проработан', isSystem: true, sortOrder: 3, colorHex: '#06B6D4' },
|
||||
{ slug: 'base', nameRu: 'База', isSystem: false, sortOrder: 4, colorHex: '#64748B' },
|
||||
{ slug: 'missed', nameRu: 'Недозвон', isSystem: false, sortOrder: 5, colorHex: '#F59E0B' },
|
||||
{ slug: 'negotiations', nameRu: 'Переговоры', isSystem: false, sortOrder: 6, colorHex: '#EAB308' },
|
||||
{ slug: 'waiting_payment', nameRu: 'Ожидаем оплаты', isSystem: false, sortOrder: 7, colorHex: '#A78BFA' },
|
||||
{ slug: 'partnership', nameRu: 'Партнерка', isSystem: false, sortOrder: 8, colorHex: '#EC4899' },
|
||||
{ slug: 'paid', nameRu: 'Оплачено', isSystem: true, sortOrder: 9, colorHex: '#10B981' },
|
||||
{ slug: 'closed', nameRu: 'Закрыто и не реализовано', isSystem: true, sortOrder: 10, colorHex: '#6B7280' },
|
||||
{ slug: 'test_drive', nameRu: 'Тест драйв', isSystem: false, sortOrder: 11, colorHex: '#14B8A6' },
|
||||
{ slug: 'hot', nameRu: 'Горячий', isSystem: false, sortOrder: 12, colorHex: '#EF4444' },
|
||||
{ slug: 'replacement', nameRu: 'На замену', isSystem: false, sortOrder: 13, colorHex: '#F97316' },
|
||||
{ slug: 'final_missed', nameRu: 'Конечный недозвон', isSystem: true, sortOrder: 14, colorHex: '#1F2937' },
|
||||
{ slug: 'in_progress', nameRu: 'В работе', isSystem: true, sortOrder: 3, colorHex: '#06B6D4' },
|
||||
{ slug: 'won', nameRu: 'Сделка', isSystem: true, sortOrder: 4, colorHex: '#10B981' },
|
||||
{ slug: 'lost', nameRu: 'Не реализовано', isSystem: true, sortOrder: 5, colorHex: '#6B7280' },
|
||||
];
|
||||
|
||||
@@ -16,6 +16,12 @@ export interface MockDeal {
|
||||
manager: { initials: string; name: string };
|
||||
cost: number;
|
||||
receivedMinutesAgo: number;
|
||||
// Редизайн «Сделки» (2026-05-17). Опциональны — Канбан/MOCK_DEALS не трогаем.
|
||||
signalType?: 'call' | 'site' | 'sms' | null;
|
||||
city?: string | null;
|
||||
comment?: string | null;
|
||||
receivedAt?: string | null; // ISO — колонка «Поставлен»
|
||||
nextReminderAt?: string | null; // ISO — колонка «Напоминание»
|
||||
}
|
||||
|
||||
export const MOCK_DEALS: MockDeal[] = [
|
||||
@@ -33,7 +39,7 @@ export const MOCK_DEALS: MockDeal[] = [
|
||||
id: 2,
|
||||
name: 'Дмитрий Кузнецов',
|
||||
phone: '+7 (903) 412-58-90',
|
||||
statusSlug: 'worked',
|
||||
statusSlug: 'in_progress',
|
||||
project: 'Окна Москва',
|
||||
manager: { initials: 'ОР', name: 'Ольга Р.' },
|
||||
cost: 2400,
|
||||
@@ -43,7 +49,7 @@ export const MOCK_DEALS: MockDeal[] = [
|
||||
id: 3,
|
||||
name: 'Светлана Иванова',
|
||||
phone: '+7 (925) 309-44-12',
|
||||
statusSlug: 'negotiations',
|
||||
statusSlug: 'in_progress',
|
||||
project: 'Окна Москва',
|
||||
manager: { initials: 'ИП', name: 'Иван П.' },
|
||||
cost: 2100,
|
||||
@@ -53,7 +59,7 @@ export const MOCK_DEALS: MockDeal[] = [
|
||||
id: 4,
|
||||
name: 'Марина Лебедева',
|
||||
phone: '+7 (915) 778-90-32',
|
||||
statusSlug: 'paid',
|
||||
statusSlug: 'won',
|
||||
project: 'Натяжные потолки',
|
||||
manager: { initials: 'ОР', name: 'Ольга Р.' },
|
||||
cost: 2350,
|
||||
@@ -63,7 +69,7 @@ export const MOCK_DEALS: MockDeal[] = [
|
||||
id: 5,
|
||||
name: 'Алексей Петров',
|
||||
phone: '+7 (905) 132-46-87',
|
||||
statusSlug: 'missed',
|
||||
statusSlug: 'in_progress',
|
||||
project: 'Окна Москва',
|
||||
manager: { initials: 'ИП', name: 'Иван П.' },
|
||||
cost: 2400,
|
||||
@@ -73,7 +79,7 @@ export const MOCK_DEALS: MockDeal[] = [
|
||||
id: 6,
|
||||
name: 'Екатерина Морозова',
|
||||
phone: '+7 (926) 554-21-09',
|
||||
statusSlug: 'waiting_payment',
|
||||
statusSlug: 'in_progress',
|
||||
project: 'Натяжные потолки',
|
||||
manager: { initials: 'ОР', name: 'Ольга Р.' },
|
||||
cost: 1950,
|
||||
@@ -93,7 +99,7 @@ export const MOCK_DEALS: MockDeal[] = [
|
||||
id: 8,
|
||||
name: 'Тимур Алиев',
|
||||
phone: '+7 (903) 765-09-21',
|
||||
statusSlug: 'hot',
|
||||
statusSlug: 'in_progress',
|
||||
project: 'Натяжные потолки',
|
||||
manager: { initials: 'ОР', name: 'Ольга Р.' },
|
||||
cost: 1850,
|
||||
@@ -103,7 +109,7 @@ export const MOCK_DEALS: MockDeal[] = [
|
||||
id: 9,
|
||||
name: 'Наталья Семёнова',
|
||||
phone: '+7 (910) 244-67-83',
|
||||
statusSlug: 'closed',
|
||||
statusSlug: 'lost',
|
||||
project: 'Окна Москва',
|
||||
manager: { initials: 'ИП', name: 'Иван П.' },
|
||||
cost: 2400,
|
||||
@@ -113,7 +119,7 @@ export const MOCK_DEALS: MockDeal[] = [
|
||||
id: 10,
|
||||
name: 'Олег Григорьев',
|
||||
phone: '+7 (909) 411-52-76',
|
||||
statusSlug: 'partnership',
|
||||
statusSlug: 'in_progress',
|
||||
project: 'Натяжные потолки',
|
||||
manager: { initials: 'ОР', name: 'Ольга Р.' },
|
||||
cost: 1850,
|
||||
@@ -123,7 +129,7 @@ export const MOCK_DEALS: MockDeal[] = [
|
||||
id: 11,
|
||||
name: 'Ирина Зайцева',
|
||||
phone: '+7 (916) 671-98-04',
|
||||
statusSlug: 'final_missed',
|
||||
statusSlug: 'in_progress',
|
||||
project: 'Окна Москва',
|
||||
manager: { initials: 'ИП', name: 'Иван П.' },
|
||||
cost: 2400,
|
||||
@@ -133,7 +139,7 @@ export const MOCK_DEALS: MockDeal[] = [
|
||||
id: 12,
|
||||
name: 'Сергей Никитин',
|
||||
phone: '+7 (925) 198-43-58',
|
||||
statusSlug: 'paid',
|
||||
statusSlug: 'won',
|
||||
project: 'Натяжные потолки',
|
||||
manager: { initials: 'ОР', name: 'Ольга Р.' },
|
||||
cost: 1850,
|
||||
@@ -141,24 +147,6 @@ export const MOCK_DEALS: MockDeal[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Срезы-фильтры для chiprow в DealsView. Каждый срез — массив slug'ов или
|
||||
* предикат включения. На API-стороне уйдут как ?status_in=...
|
||||
*/
|
||||
export interface DealsTab {
|
||||
id: 'all' | 'active' | 'waiting_payment' | 'closed' | 'invalid';
|
||||
label: string;
|
||||
slugs: LeadStatus['slug'][] | null; // null = все
|
||||
}
|
||||
|
||||
export const DEALS_TABS: DealsTab[] = [
|
||||
{ id: 'all', label: 'Все', slugs: null },
|
||||
{ id: 'active', label: 'Активные', slugs: ['new', 'viewed', 'worked', 'negotiations', 'hot'] },
|
||||
{ id: 'waiting_payment', label: 'Ждут оплату', slugs: ['waiting_payment'] },
|
||||
{ id: 'closed', label: 'Закрытые', slugs: ['paid', 'closed'] },
|
||||
{ id: 'invalid', label: 'Невалидные', slugs: ['missed', 'final_missed'] },
|
||||
];
|
||||
|
||||
/**
|
||||
* Доступные проекты и менеджеры для NewDealDialog. На API: GET /api/projects /
|
||||
* GET /api/managers (фильтр по tenant_id из middleware).
|
||||
|
||||
@@ -13,11 +13,13 @@ export interface PillStyle {
|
||||
|
||||
export const STATUS_PILL_SLUGS = [
|
||||
'new',
|
||||
'viewed',
|
||||
'in_progress',
|
||||
'callback',
|
||||
'quality',
|
||||
'meeting_set',
|
||||
'won',
|
||||
'lost',
|
||||
'refund',
|
||||
'duplicate',
|
||||
'junk',
|
||||
@@ -32,11 +34,13 @@ type StatusPillSlug = (typeof STATUS_PILL_SLUGS)[number];
|
||||
|
||||
const STYLES: Record<StatusPillSlug, PillStyle> = {
|
||||
new: { bg: 'rgba(15,110,86,0.12)', color: '#0F6E56' },
|
||||
viewed: { bg: 'rgba(122,91,163,0.15)', color: '#7A5BA3' },
|
||||
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 },
|
||||
lost: { bg: 'rgba(107,99,86,0.18)', color: '#6B6356' },
|
||||
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' },
|
||||
|
||||
@@ -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: 'Дальневосточный' },
|
||||
];
|
||||
@@ -106,6 +106,9 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
action: 'pause' | 'resume' | 'archive' | 'update_regions' | 'update_days' | 'update_limit';
|
||||
add?: number;
|
||||
remove?: number;
|
||||
// Plan 6.5 — update_regions оперирует кодами субъектов (1..89), не bitmask ФО.
|
||||
add_regions?: number[];
|
||||
remove_regions?: number[];
|
||||
delta?: number;
|
||||
replace?: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Workaround для бага позиционирования Vuetify connected-location-strategy.
|
||||
*
|
||||
* Когда активатор `v-select`/`v-autocomplete` находится внутри
|
||||
* `position: fixed`-контейнера (кастомный дровер, диалог), Vuetify включает
|
||||
* ветку `activatorFixed` (`isFixedPosition()` → true). Её `getIntrinsicSize()`
|
||||
* вычитает `el.style.left` из измеренной геометрии оверлея; на переходном
|
||||
* кадре, когда контент ещё отрисован в нулевой позиции, а инлайновый
|
||||
* `style.left` уже не нулевой, `contentBox.x` становится отрицательным и
|
||||
* стратегия аккумулирует смещение — меню уезжает на кратное X активатора
|
||||
* (за край экрана).
|
||||
*
|
||||
* Обычно гонку сглаживают пересчёты, размазанные по анимации открытия. Под
|
||||
* `prefers-reduced-motion: reduce` (умолчание Windows Server) анимации нет —
|
||||
* один пересчёт на «плохом» кадре остаётся финальным.
|
||||
*
|
||||
* Фикс: дождаться, пока контент оверлея отрисован и геометрически стабилен,
|
||||
* затем один раз послать `resize` — Vuetify пересчитает позицию по уже
|
||||
* устоявшейся геометрии и поставит меню корректно. Безопасно при motion ON
|
||||
* (пересчёт по стабильной геометрии идемпотентен) и для не-fixed контейнеров.
|
||||
*
|
||||
* Привязывать к `@update:menu` нужного `v-autocomplete`/`v-select`.
|
||||
*/
|
||||
export function repositionMenuAfterOpen(open: boolean): void {
|
||||
if (!open || typeof window === 'undefined') return;
|
||||
|
||||
let prevLeft = Number.NaN;
|
||||
let stableFrames = 0;
|
||||
let totalFrames = 0;
|
||||
|
||||
const tick = (): void => {
|
||||
// Последний открытый overlay-menu (на случай вложенных оверлеев).
|
||||
const menus = document.querySelectorAll<HTMLElement>('.v-overlay.v-menu .v-overlay__content');
|
||||
const el = menus[menus.length - 1];
|
||||
|
||||
if (el && el.getBoundingClientRect().width > 0) {
|
||||
const left = Math.round(el.getBoundingClientRect().left);
|
||||
stableFrames = left === prevLeft ? stableFrames + 1 : 0;
|
||||
prevLeft = left;
|
||||
// 3 кадра без движения = геометрия устоялась → один чистый пересчёт.
|
||||
if (stableFrames >= 3) {
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Предохранитель ~1.5 c: не зацикливаться, если оверлей не появился.
|
||||
if (++totalFrames < 90) requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Канбан — альтернативный вид сделок (по статусам). 14 колонок (lead_statuses).
|
||||
* Канбан — альтернативный вид сделок (по статусам). 5 колонок (lead_statuses).
|
||||
*
|
||||
* Источник дизайна: liderra_v8_handoff/concepts/v8_kanban.html.
|
||||
* DnD реализован через vuedraggable@4 (обёртка SortableJS) — карточки можно
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
density="comfortable"
|
||||
class="ld-input-quiet"
|
||||
data-testid="regions-autocomplete"
|
||||
@update:menu="repositionMenuAfterOpen"
|
||||
>
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<v-list-item v-bind="itemProps">
|
||||
@@ -137,6 +138,7 @@
|
||||
import { ref, reactive, watch } from 'vue';
|
||||
import { apiClient, ensureCsrfCookie, extractErrorMessage } from '../../api/client';
|
||||
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
|
||||
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
|
||||
import type { Project } from '../../stores/projectsStore';
|
||||
import DevIndexBadge from '../../components/DevIndexBadge.vue';
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ test('GET show: activity возвращает с actor_email из users LEFT JOI
|
||||
'user_id' => $user->id,
|
||||
'deal_id' => 999,
|
||||
'event' => 'deal.status_changed',
|
||||
'context' => json_encode(['from' => 'new', 'to' => 'worked']),
|
||||
'context' => json_encode(['from' => 'new', 'to' => 'in_progress']),
|
||||
'created_at' => Carbon::now(),
|
||||
]);
|
||||
DB::table('activity_log')->insert([
|
||||
|
||||
@@ -6,17 +6,17 @@ use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
|
||||
it('accepts update_regions action with add/remove bitmask', function () {
|
||||
it('accepts update_regions action with subject-code arrays', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->for($tenant)->create();
|
||||
$p = Project::factory()->for($tenant)->create(['region_mask' => 1]);
|
||||
$p = Project::factory()->for($tenant)->create(['regions' => [82]]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/api/projects/bulk', [
|
||||
'action' => 'update_regions',
|
||||
'ids' => [$p->id],
|
||||
'add' => 6, // биты 2+4 = Северо-Западный + Южный
|
||||
'remove' => 1, // бит 1 = Центральный
|
||||
'add_regions' => [83, 84], // Санкт-Петербург + Севастополь
|
||||
'remove_regions' => [82], // Москва
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonStructure(['updated', 'skipped', 'warnings']);
|
||||
@@ -69,24 +69,39 @@ it('accepts empty scope.filter as valid scope (all projects)', function () {
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('applies update_regions add and remove correctly', function () {
|
||||
it('applies update_regions add_regions and remove_regions to the regions array', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->for($tenant)->create();
|
||||
$p1 = Project::factory()->for($tenant)->create(['region_mask' => 3]); // 1+2
|
||||
$p2 = Project::factory()->for($tenant)->create(['region_mask' => 5]); // 1+4
|
||||
$p1 = Project::factory()->for($tenant)->create(['regions' => [82, 56]]); // Москва + Московская обл.
|
||||
$p2 = Project::factory()->for($tenant)->create(['regions' => []]); // вся РФ
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/api/projects/bulk', [
|
||||
'action' => 'update_regions',
|
||||
'ids' => [$p1->id, $p2->id],
|
||||
'add' => 16, // 16 = Приволжский
|
||||
'remove' => 1, // 1 = Центральный
|
||||
'add_regions' => [83], // Санкт-Петербург
|
||||
'remove_regions' => [56], // Московская область
|
||||
])
|
||||
->assertOk()
|
||||
->assertJson(['updated' => 2, 'skipped' => [], 'warnings' => []]);
|
||||
|
||||
expect($p1->fresh()->region_mask)->toBe((3 | 16) & ~1); // = 18
|
||||
expect($p2->fresh()->region_mask)->toBe((5 | 16) & ~1); // = 20
|
||||
expect($p1->fresh()->regions)->toBe([82, 83]); // [82,56] ∪ {83} \ {56}, отсортировано
|
||||
expect($p2->fresh()->regions)->toBe([83]); // [] ∪ {83} \ {56}
|
||||
});
|
||||
|
||||
it('rejects update_regions with out-of-range subject code', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->for($tenant)->create();
|
||||
$p = Project::factory()->for($tenant)->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/api/projects/bulk', [
|
||||
'action' => 'update_regions',
|
||||
'ids' => [$p->id],
|
||||
'add_regions' => [90], // > 89 — невалидный код субъекта РФ
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['add_regions.0']);
|
||||
});
|
||||
|
||||
it('applies update_days add and remove correctly', function () {
|
||||
|
||||
@@ -71,7 +71,7 @@ it('leads_received считает только сделки окна, без del
|
||||
// 3 живые сделки в окне 7d + 1 deleted + 1 is_test + 1 вне окна (8 дней назад)
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(2));
|
||||
makeDashboardDeal($tenant, $project, 'paid', now()->subDays(3));
|
||||
makeDashboardDeal($tenant, $project, 'won', now()->subDays(3));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1), deletedAt: now());
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1), isTest: true);
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(8));
|
||||
@@ -81,14 +81,14 @@ it('leads_received считает только сделки окна, без del
|
||||
->assertJsonPath('leads_received.value', 3);
|
||||
});
|
||||
|
||||
it('conversion = доля статуса paid в окне', function () {
|
||||
it('conversion = доля статуса won в окне', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
makeDashboardDeal($tenant, $project, 'paid', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'won', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
// 1 paid из 4 → 25.0%; PHP json_encode кодирует 25.0 как 25 (без дроби)
|
||||
// 1 won из 4 → 25.0%; PHP json_encode кодирует 25.0 как 25 (без дроби)
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('conversion.value', 25);
|
||||
@@ -111,11 +111,11 @@ it('funnel группирует живые сделки по статусу', fu
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'paid', now()->subDays(1));
|
||||
makeDashboardDeal($tenant, $project, 'won', now()->subDays(1));
|
||||
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('funnel.new', 2)
|
||||
->assertJsonPath('funnel.paid', 1);
|
||||
->assertJsonPath('funnel.won', 1);
|
||||
});
|
||||
|
||||
it('activity возвращает 7 точек и 7 меток', function () {
|
||||
|
||||
@@ -10,7 +10,6 @@ use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
@@ -194,160 +193,3 @@ test('POST /api/deals manual БЕЗ supplier'."'а у проекта — без
|
||||
->count();
|
||||
expect($cost)->toBe(0);
|
||||
});
|
||||
|
||||
test('POST /api/deals/export возвращает CSV с правильными headers + BOM', function () {
|
||||
// Создаём 2 сделки через store endpoint (получаем реальные id).
|
||||
$r1 = $this->postJson('/api/deals', [
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 111-11-11',
|
||||
'contact_name' => 'Алиса',
|
||||
])->json('deal');
|
||||
$r2 = $this->postJson('/api/deals', [
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 222-22-22',
|
||||
'contact_name' => 'Боб',
|
||||
])->json('deal');
|
||||
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'ids' => [$r1['id'], $r2['id']],
|
||||
]);
|
||||
|
||||
$r->assertStatus(200);
|
||||
expect($r->headers->get('Content-Type'))->toContain('text/csv');
|
||||
expect($r->headers->get('Content-Disposition'))->toContain('deals_export_');
|
||||
|
||||
// Sprint 3 Phase A (O-perf-05): export → StreamedResponse через OpenSpout,
|
||||
// body читается через streamedContent() (см. TestResponse::streamedContent).
|
||||
$body = $r->streamedContent();
|
||||
// BOM первый символ
|
||||
expect($body)->toStartWith("\u{FEFF}");
|
||||
// Headers строка
|
||||
expect($body)->toContain('ID;Имя;Телефон;Статус');
|
||||
// Контент сделок
|
||||
expect($body)->toContain('Алиса');
|
||||
expect($body)->toContain('Боб');
|
||||
expect($body)->toContain('+7 (999) 111-11-11');
|
||||
});
|
||||
|
||||
test('POST /api/deals/export 422 без ids', function () {
|
||||
$r = $this->postJson('/api/deals/export', []);
|
||||
$r->assertStatus(422);
|
||||
expect($r->json('errors'))->toHaveKey('ids');
|
||||
});
|
||||
|
||||
test('POST /api/deals/export 401 без auth', function () {
|
||||
auth()->logout();
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'ids' => [1, 2, 3],
|
||||
]);
|
||||
$r->assertStatus(401);
|
||||
});
|
||||
|
||||
test('POST /api/deals/export фильтрует только запрошенные ids (своего tenant\'а)', function () {
|
||||
// Создаём 3 сделки одного tenant'а, экспортируем 1 → CSV только её.
|
||||
$a = $this->postJson('/api/deals', [
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 111-11-11',
|
||||
'contact_name' => 'Алиса',
|
||||
])->json('deal');
|
||||
$this->postJson('/api/deals', [
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 222-22-22',
|
||||
'contact_name' => 'Боб',
|
||||
])->json('deal');
|
||||
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'ids' => [$a['id']],
|
||||
]);
|
||||
$r->assertStatus(200);
|
||||
$body = $r->streamedContent();
|
||||
expect($body)->toContain('Алиса');
|
||||
expect($body)->not->toContain('Боб');
|
||||
});
|
||||
|
||||
// NB: полная RLS-изоляция (другие tenant'ы скрыты) тестируется отдельно
|
||||
// через testing_rls_user (NOLOGIN role без BYPASSRLS) — см.
|
||||
// `tests/Feature/RlsSmokeTest.php` v1.10. В этом тесте используется postgres
|
||||
// superuser, который BYPASSRLS — RLS-проверка тут была бы false-positive.
|
||||
|
||||
test('POST /api/deals/export?format=xlsx возвращает binary с корректным content-type', function () {
|
||||
$a = $this->postJson('/api/deals', [
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 111-11-11',
|
||||
'contact_name' => 'Алиса',
|
||||
])->json('deal');
|
||||
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'ids' => [$a['id']],
|
||||
'format' => 'xlsx',
|
||||
]);
|
||||
|
||||
$r->assertStatus(200);
|
||||
expect($r->headers->get('Content-Type'))
|
||||
->toBe('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
expect($r->headers->get('Content-Disposition'))->toContain('.xlsx');
|
||||
// XLSX = ZIP-archive, начинается с magic bytes "PK\x03\x04".
|
||||
$body = $r->streamedContent();
|
||||
expect(substr($body, 0, 4))->toBe("PK\x03\x04");
|
||||
expect(strlen($body))->toBeGreaterThan(2000); // sanity: реальный xlsx > 2 KB
|
||||
});
|
||||
|
||||
test('POST /api/deals/export?format=xlsx содержит данные сделки (после распаковки sheet1)', function () {
|
||||
$a = $this->postJson('/api/deals', [
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 333-33-33',
|
||||
'contact_name' => 'Кириллов',
|
||||
])->json('deal');
|
||||
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'ids' => [$a['id']],
|
||||
'format' => 'xlsx',
|
||||
]);
|
||||
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_test_');
|
||||
file_put_contents($tmp, $r->streamedContent());
|
||||
$reader = IOFactory::createReader('Xlsx');
|
||||
$book = $reader->load($tmp);
|
||||
$sheet = $book->getActiveSheet();
|
||||
|
||||
expect($sheet->getTitle())->toBe('Сделки');
|
||||
// Sprint 3 Phase A (O-perf-05): после миграции на OpenSpout streaming,
|
||||
// styled-header cells пишутся как inline-string с RichText. Используем
|
||||
// getFormattedValue() для plain-string сравнения header'ов; для data-cell'ов
|
||||
// OpenSpout продолжает писать обычные shared-strings.
|
||||
expect($sheet->getCell('A1')->getFormattedValue())->toBe('ID');
|
||||
expect($sheet->getCell('B1')->getFormattedValue())->toBe('Имя');
|
||||
expect($sheet->getStyle('A1')->getFont()->getBold())->toBeTrue();
|
||||
// Row 2 — реальная сделка. OpenSpout пишет string-cell'ы как inline-string с
|
||||
// RichText-обёрткой; для plain-string сравнения используем getFormattedValue().
|
||||
// Numeric cell A2 (ID) — обычный numeric, ->getValue() работает.
|
||||
expect($sheet->getCell('A2')->getValue())->toBe($a['id']);
|
||||
expect($sheet->getCell('B2')->getFormattedValue())->toBe('Кириллов');
|
||||
expect($sheet->getCell('C2')->getFormattedValue())->toBe('+7 (999) 333-33-33');
|
||||
|
||||
unlink($tmp);
|
||||
});
|
||||
|
||||
test('POST /api/deals/export 422 на неизвестный format', function () {
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'ids' => [1],
|
||||
'format' => 'pdf',
|
||||
]);
|
||||
$r->assertStatus(422);
|
||||
expect($r->json('errors'))->toHaveKey('format');
|
||||
});
|
||||
|
||||
test('POST /api/deals/export по умолчанию (без format) возвращает CSV — backward-compat', function () {
|
||||
$a = $this->postJson('/api/deals', [
|
||||
'project_name' => 'X',
|
||||
'phone' => '+7 (999) 444-44-44',
|
||||
'contact_name' => 'Test',
|
||||
])->json('deal');
|
||||
|
||||
$r = $this->postJson('/api/deals/export', [
|
||||
'ids' => [$a['id']],
|
||||
]);
|
||||
$r->assertStatus(200);
|
||||
expect($r->headers->get('Content-Type'))->toContain('text/csv');
|
||||
expect($r->headers->get('Content-Disposition'))->toContain('.csv');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Тесты POST /api/deals/export — экспорт по диапазону дат поставки.
|
||||
*
|
||||
* Редизайн «Сделки» (2026-05-17, Task A5): вместо ids[] — received_from/received_to.
|
||||
* Конвенции: DatabaseTransactions + actingAs + SET app.current_tenant_id
|
||||
* (аналогично DealIndexTest.php).
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->for($this->tenant)->create();
|
||||
$this->actingAs($this->user);
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$this->project = Project::factory()->for($this->tenant)->create(['name' => 'Окна Москва']);
|
||||
});
|
||||
|
||||
test('POST /api/deals/export требует auth', function () {
|
||||
auth()->logout();
|
||||
$this->postJson('/api/deals/export', ['format' => 'csv'])->assertStatus(401);
|
||||
});
|
||||
|
||||
test('POST /api/deals/export csv возвращает сделки в диапазоне дат', function () {
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create([
|
||||
'phone' => '+7 999 111-11-11', 'received_at' => '2026-05-15 10:00:00',
|
||||
]);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create([
|
||||
'phone' => '+7 999 222-22-22', 'received_at' => '2026-05-25 10:00:00',
|
||||
]);
|
||||
|
||||
$r = $this->post('/api/deals/export', [
|
||||
'received_from' => '2026-05-14', 'received_to' => '2026-05-16', 'format' => 'csv',
|
||||
]);
|
||||
|
||||
$r->assertStatus(200);
|
||||
$r->assertHeader('content-type', 'text/csv; charset=utf-8');
|
||||
$body = $r->streamedContent();
|
||||
expect($body)->toContain('+7 999 111-11-11');
|
||||
expect($body)->not->toContain('+7 999 222-22-22');
|
||||
});
|
||||
|
||||
test('POST /api/deals/export xlsx отдаёт spreadsheet content-type', function () {
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-15 10:00:00']);
|
||||
|
||||
$r = $this->post('/api/deals/export', ['format' => 'xlsx']);
|
||||
|
||||
$r->assertStatus(200);
|
||||
$r->assertHeader('content-type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
});
|
||||
|
||||
test('POST /api/deals/export не экспортирует чужой tenant (RLS)', function () {
|
||||
$other = Tenant::factory()->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$other->id);
|
||||
$foreignProject = Project::factory()->for($other)->create();
|
||||
Deal::factory()->for($other)->for($foreignProject)->create(['phone' => '+7 900 000-00-00']);
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$r = $this->post('/api/deals/export', ['format' => 'csv']);
|
||||
|
||||
expect($r->streamedContent())->not->toContain('+7 900 000-00-00');
|
||||
});
|
||||
|
||||
test('POST /api/deals/export 422 на неизвестный format', function () {
|
||||
$this->postJson('/api/deals/export', ['format' => 'pdf'])->assertStatus(422);
|
||||
});
|
||||
|
||||
test('POST /api/deals/export без format по умолчанию отдаёт CSV', function () {
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-15 10:00:00']);
|
||||
$r = $this->post('/api/deals/export', []);
|
||||
$r->assertStatus(200);
|
||||
$r->assertHeader('content-type', 'text/csv; charset=utf-8');
|
||||
});
|
||||
@@ -105,14 +105,14 @@ test('GET /api/deals сортирует по received_at DESC', function () {
|
||||
|
||||
test('GET /api/deals фильтрует по status_in[]', function () {
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'paid']);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'closed']);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'won']);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'lost']);
|
||||
|
||||
$r = $this->getJson('/api/deals?status_in[]=new&status_in[]=paid');
|
||||
$r = $this->getJson('/api/deals?status_in[]=new&status_in[]=won');
|
||||
|
||||
expect($r->json('total'))->toBe(2);
|
||||
$statuses = collect($r->json('deals'))->pluck('status')->sort()->values()->all();
|
||||
expect($statuses)->toBe(['new', 'paid']);
|
||||
expect($statuses)->toBe(['new', 'won']);
|
||||
});
|
||||
|
||||
test('GET /api/deals фильтрует по project_id', function () {
|
||||
@@ -292,7 +292,7 @@ test('GET /api/deals возвращает next_cursor когда есть ещё
|
||||
|
||||
test('GET /api/deals?count_only=1 возвращает только total без массива deals', function () {
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'paid']);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'won']);
|
||||
|
||||
$r = $this->getJson('/api/deals?count_only=1');
|
||||
|
||||
@@ -304,7 +304,7 @@ test('GET /api/deals?count_only=1 возвращает только total без
|
||||
test('GET /api/deals?count_only=1 учитывает фильтры (status_in)', function () {
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'paid']);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'won']);
|
||||
|
||||
expect($this->getJson('/api/deals?count_only=1&status_in[]=new')->json('total'))->toBe(2);
|
||||
});
|
||||
@@ -318,3 +318,42 @@ test('GET /api/deals?count_only=1 изолирует чужой tenant (RLS)', f
|
||||
|
||||
expect($this->getJson('/api/deals?count_only=1')->json('total'))->toBe(1);
|
||||
});
|
||||
|
||||
test('GET /api/deals фильтрует по received_from/received_to', function () {
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-10 12:00:00']);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-15 12:00:00']);
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-20 12:00:00']);
|
||||
|
||||
$r = $this->getJson('/api/deals?received_from=2026-05-12&received_to=2026-05-16');
|
||||
|
||||
expect($r->json('total'))->toBe(1);
|
||||
});
|
||||
|
||||
test('GET /api/deals received_to включает весь день (конец дня)', function () {
|
||||
Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-16 23:30:00']);
|
||||
|
||||
expect($this->getJson('/api/deals?received_to=2026-05-16')->json('total'))->toBe(1);
|
||||
});
|
||||
|
||||
test('GET /api/deals возвращает comment/city/project_signal_type/next_reminder_at', function () {
|
||||
$this->project->update(['signal_type' => 'call', 'signal_identifier' => '79990001122']);
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create([
|
||||
'comment' => 'перезвонить',
|
||||
'city' => 'Казань',
|
||||
]);
|
||||
|
||||
$r = $this->getJson('/api/deals');
|
||||
|
||||
expect($r->json('deals.0.comment'))->toBe('перезвонить');
|
||||
expect($r->json('deals.0.city'))->toBe('Казань');
|
||||
expect($r->json('deals.0.project_signal_type'))->toBe('call');
|
||||
expect($r->json('deals.0'))->toHaveKey('next_reminder_at');
|
||||
});
|
||||
|
||||
test('GET /api/deals возвращает 422 на невалидную received_from', function () {
|
||||
$this->getJson('/api/deals?received_from=не-дата')->assertStatus(422);
|
||||
});
|
||||
|
||||
test('GET /api/deals возвращает 422 на невалидную received_to', function () {
|
||||
$this->getJson('/api/deals?received_to=garbage')->assertStatus(422);
|
||||
});
|
||||
|
||||
@@ -95,7 +95,7 @@ test('GET /api/deals/{id} возвращает activity events отсортир
|
||||
'user_id' => $this->manager->id,
|
||||
'deal_id' => $deal->id,
|
||||
'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
|
||||
'context' => ['from' => 'new', 'to' => 'paid', 'source' => 'manual'],
|
||||
'context' => ['from' => 'new', 'to' => 'won', 'source' => 'manual'],
|
||||
'created_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
@@ -106,7 +106,7 @@ test('GET /api/deals/{id} возвращает activity events отсортир
|
||||
expect($events)->toHaveCount(2);
|
||||
// ORDER BY created_at DESC — свежее (status_changed) сверху.
|
||||
expect($events[0]['event'])->toBe('deal.status_changed');
|
||||
expect($events[0]['context'])->toMatchArray(['from' => 'new', 'to' => 'paid']);
|
||||
expect($events[0]['context'])->toMatchArray(['from' => 'new', 'to' => 'won']);
|
||||
expect($events[0]['actor']['name'])->toBe('Иван П.');
|
||||
expect($events[0]['actor']['initials'])->toBe('ИП');
|
||||
|
||||
|
||||
@@ -61,18 +61,18 @@ test('POST /api/deals/transition — обновляет статус и пише
|
||||
|
||||
$r = $this->postJson('/api/deals/transition', [
|
||||
'ids' => $deals->pluck('id')->all(),
|
||||
'status' => 'paid',
|
||||
'status' => 'won',
|
||||
]);
|
||||
|
||||
$r->assertStatus(200)->assertJson([
|
||||
'updated' => 3,
|
||||
'requested' => 3,
|
||||
'status' => 'paid',
|
||||
'status' => 'won',
|
||||
]);
|
||||
|
||||
foreach ($deals as $d) {
|
||||
$d->refresh();
|
||||
expect($d->status)->toBe('paid');
|
||||
expect($d->status)->toBe('won');
|
||||
}
|
||||
|
||||
$activity = ActivityLog::where('tenant_id', $this->tenant->id)
|
||||
@@ -81,17 +81,17 @@ test('POST /api/deals/transition — обновляет статус и пише
|
||||
expect($activity)->toHaveCount(3);
|
||||
expect($activity->first()->context)->toMatchArray([
|
||||
'from' => 'new',
|
||||
'to' => 'paid',
|
||||
'to' => 'won',
|
||||
'source' => 'bulk',
|
||||
]);
|
||||
});
|
||||
|
||||
test('POST /api/deals/transition — NO-OP не пишет ActivityLog', function () {
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'paid']);
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'won']);
|
||||
|
||||
$r = $this->postJson('/api/deals/transition', [
|
||||
'ids' => [$deal->id],
|
||||
'status' => 'paid',
|
||||
'status' => 'won',
|
||||
]);
|
||||
|
||||
$r->assertStatus(200)->assertJson(['updated' => 0, 'requested' => 1]);
|
||||
@@ -111,7 +111,7 @@ test('POST /api/deals/transition — defense-in-depth не апдейтит чу
|
||||
// Передаём оба id — чужой не должен обновиться.
|
||||
$r = $this->postJson('/api/deals/transition', [
|
||||
'ids' => [$own->id, $foreign->id],
|
||||
'status' => 'paid',
|
||||
'status' => 'won',
|
||||
]);
|
||||
|
||||
$r->assertStatus(200)->assertJson([
|
||||
@@ -121,7 +121,7 @@ test('POST /api/deals/transition — defense-in-depth не апдейтит чу
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$own->refresh();
|
||||
expect($own->status)->toBe('paid');
|
||||
expect($own->status)->toBe('won');
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->otherTenant->id);
|
||||
$foreign->refresh();
|
||||
@@ -131,6 +131,6 @@ test('POST /api/deals/transition — defense-in-depth не апдейтит чу
|
||||
test('POST /api/deals/transition — 422 если ids пустой массив', function () {
|
||||
$this->postJson('/api/deals/transition', [
|
||||
'ids' => [],
|
||||
'status' => 'paid',
|
||||
'status' => 'won',
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
@@ -83,17 +83,17 @@ test('PATCH /api/deals/{id} обновляет status + пишет deal.status_c
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
|
||||
|
||||
$r = $this->patchJson('/api/deals/'.$deal->id, [
|
||||
'status' => 'paid',
|
||||
'status' => 'won',
|
||||
]);
|
||||
$r->assertStatus(200);
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
|
||||
$deal->refresh();
|
||||
expect($deal->status)->toBe('paid');
|
||||
expect($deal->status)->toBe('won');
|
||||
|
||||
$log = ActivityLog::where('deal_id', $deal->id)->where('event', 'deal.status_changed')->first();
|
||||
expect($log)->not->toBeNull();
|
||||
expect($log->context)->toMatchArray(['from' => 'new', 'to' => 'paid', 'source' => 'manual']);
|
||||
expect($log->context)->toMatchArray(['from' => 'new', 'to' => 'won', 'source' => 'manual']);
|
||||
});
|
||||
|
||||
test('PATCH /api/deals/{id} 422 на неизвестный status slug', function () {
|
||||
@@ -123,12 +123,12 @@ test('PATCH /api/deals/{id} 422 на manager_id чужого tenant\'а', functi
|
||||
|
||||
test('PATCH /api/deals/{id} NO-OP не пишет ActivityLog', function () {
|
||||
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create([
|
||||
'status' => 'paid',
|
||||
'status' => 'won',
|
||||
'comment' => 'same',
|
||||
]);
|
||||
|
||||
$r = $this->patchJson('/api/deals/'.$deal->id, [
|
||||
'status' => 'paid', // не меняем
|
||||
'status' => 'won', // не меняем
|
||||
'comment' => 'same', // не меняем
|
||||
]);
|
||||
$r->assertStatus(200);
|
||||
@@ -145,7 +145,7 @@ test('PATCH /api/deals/{id} комбинированно — comment + status о
|
||||
|
||||
$r = $this->patchJson('/api/deals/'.$deal->id, [
|
||||
'comment' => 'Заметка',
|
||||
'status' => 'worked',
|
||||
'status' => 'in_progress',
|
||||
]);
|
||||
$r->assertStatus(200);
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ test('импортирует исторические лиды, создавая
|
||||
->and($result->updated)->toBe(0);
|
||||
|
||||
$deal = Deal::query()->where('source_crm_id', 5001)->firstOrFail();
|
||||
expect($deal->status)->toBe('negotiations')
|
||||
expect($deal->status)->toBe('in_progress')
|
||||
->and($deal->phone)->toBe('79161112233')
|
||||
->and($deal->received_at->format('Y-m-d'))->toBe('2023-07-10');
|
||||
});
|
||||
@@ -89,7 +89,7 @@ test('повторный импорт того же файла не создаё
|
||||
->and(Deal::query()->where('source_crm_id', 5003)->count())->toBe(1);
|
||||
|
||||
$deal = Deal::query()->where('source_crm_id', 5003)->firstOrFail();
|
||||
expect($deal->status)->toBe('paid') // §6.5 стадия 3a: status перезаписан
|
||||
expect($deal->status)->toBe('won') // §6.5 стадия 3a: status перезаписан
|
||||
->and($deal->contact_name)->toBe('Пётр')
|
||||
->and($deal->comment)->toBe('Обновлённый');
|
||||
});
|
||||
@@ -127,7 +127,7 @@ test('resolved-маппинг tenant-а применяется к ранее н
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'status_ru' => 'Архив',
|
||||
'occurrences' => 1,
|
||||
'mapped_to_slug' => 'closed',
|
||||
'mapped_to_slug' => 'lost',
|
||||
'resolved_at' => now(),
|
||||
]);
|
||||
$rows = parseFixture(
|
||||
@@ -135,7 +135,7 @@ test('resolved-маппинг tenant-а применяется к ранее н
|
||||
);
|
||||
$this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows);
|
||||
|
||||
expect(Deal::query()->where('source_crm_id', 5007)->firstOrFail()->status)->toBe('closed');
|
||||
expect(Deal::query()->where('source_crm_id', 5007)->firstOrFail()->status)->toBe('lost');
|
||||
});
|
||||
|
||||
test('dry_run не пишет сделки, но считает проекцию', function (): void {
|
||||
@@ -155,7 +155,7 @@ test('неизвестные статусы и resolved-маппинг изол
|
||||
'tenant_id' => $otherTenant->id,
|
||||
'status_ru' => 'Архив',
|
||||
'occurrences' => 9,
|
||||
'mapped_to_slug' => 'closed',
|
||||
'mapped_to_slug' => 'lost',
|
||||
'resolved_at' => now(),
|
||||
]);
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ test('GET /api/imports/unknown-statuses возвращает незамапле
|
||||
]);
|
||||
ImportUnknownStatus::create([
|
||||
'tenant_id' => $this->tenant->id, 'status_ru' => 'Спам', 'occurrences' => 1,
|
||||
'mapped_to_slug' => 'closed', 'resolved_at' => now(),
|
||||
'mapped_to_slug' => 'lost', 'resolved_at' => now(),
|
||||
]);
|
||||
|
||||
$this->getJson('/api/imports/unknown-statuses')
|
||||
@@ -113,10 +113,10 @@ test('POST /api/imports/unknown-statuses/resolve проставляет мапп
|
||||
]);
|
||||
|
||||
$this->postJson('/api/imports/unknown-statuses/resolve', [
|
||||
'mappings' => [['status_ru' => 'Архив', 'slug' => 'closed']],
|
||||
'mappings' => [['status_ru' => 'Архив', 'slug' => 'lost']],
|
||||
])->assertStatus(200);
|
||||
|
||||
expect($unknown->refresh()->mapped_to_slug)->toBe('closed')
|
||||
expect($unknown->refresh()->mapped_to_slug)->toBe('lost')
|
||||
->and($unknown->resolved_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ test('ImportUnknownStatus хранит маппинг и фильтруется
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'status_ru' => 'Спам',
|
||||
'occurrences' => 1,
|
||||
'mapped_to_slug' => 'closed',
|
||||
'mapped_to_slug' => 'lost',
|
||||
'resolved_at' => now(),
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
test('lead_statuses содержит ровно 5 статусов воронки', function () {
|
||||
$slugs = DB::table('lead_statuses')->orderBy('sort_order')->pluck('slug')->all();
|
||||
expect($slugs)->toBe(['new', 'viewed', 'in_progress', 'won', 'lost']);
|
||||
});
|
||||
|
||||
test('новые статусы имеют корректные русские названия', function () {
|
||||
$names = DB::table('lead_statuses')->pluck('name_ru', 'slug');
|
||||
expect($names['new'])->toBe('Новая сделка');
|
||||
expect($names['in_progress'])->toBe('В работе');
|
||||
expect($names['won'])->toBe('Сделка');
|
||||
expect($names['lost'])->toBe('Не реализовано');
|
||||
});
|
||||
|
||||
test('старых slug-ов воронки в lead_statuses не осталось', function () {
|
||||
$obsolete = DB::table('lead_statuses')
|
||||
->whereIn('slug', ['worked', 'paid', 'closed', 'hot', 'negotiations'])
|
||||
->count();
|
||||
expect($obsolete)->toBe(0);
|
||||
});
|
||||
@@ -8,9 +8,8 @@ use Illuminate\Support\Facades\DB;
|
||||
/**
|
||||
* Тесты GET /api/lead-statuses — глобальный lookup статусов воронки.
|
||||
*
|
||||
* Таблица lead_statuses не tenant-aware, seeded в schema.sql:2130 (14 системных
|
||||
* статусов: new/viewed/worked/base/missed/negotiations/waiting_payment/
|
||||
* partnership/paid/closed/test_drive/hot/replacement/final_missed).
|
||||
* Таблица lead_statuses не tenant-aware, seeded в schema.sql (5 системных
|
||||
* статусов воронки: new/viewed/in_progress/won/lost).
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
@@ -19,18 +18,14 @@ test('GET /api/lead-statuses возвращает 200 и не пустой сп
|
||||
|
||||
$r->assertStatus(200);
|
||||
expect($r->json('lead_statuses'))->toBeArray();
|
||||
expect(count($r->json('lead_statuses')))->toBeGreaterThanOrEqual(14);
|
||||
expect(count($r->json('lead_statuses')))->toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
|
||||
test('GET /api/lead-statuses возвращает все 14 системных статусов из seed', function () {
|
||||
test('GET /api/lead-statuses возвращает все 5 системных статусов из seed', function () {
|
||||
$r = $this->getJson('/api/lead-statuses');
|
||||
|
||||
$slugs = collect($r->json('lead_statuses'))->pluck('slug')->all();
|
||||
$expected = [
|
||||
'new', 'viewed', 'worked', 'base', 'missed', 'negotiations',
|
||||
'waiting_payment', 'partnership', 'paid', 'closed',
|
||||
'test_drive', 'hot', 'replacement', 'final_missed',
|
||||
];
|
||||
$expected = ['new', 'viewed', 'in_progress', 'won', 'lost'];
|
||||
foreach ($expected as $slug) {
|
||||
expect($slugs)->toContain($slug);
|
||||
}
|
||||
|
||||
@@ -55,12 +55,12 @@ test('slug = managers', function () {
|
||||
expect((new ManagersSummaryProvider)->slug())->toBe('managers');
|
||||
});
|
||||
|
||||
test('агрегирует сделки по менеджеру: total, paid, конверсия', function () {
|
||||
test('агрегирует сделки по менеджеру: total, won, конверсия', function () {
|
||||
$manager = User::factory()->create([
|
||||
'tenant_id' => $this->tenant->id, 'first_name' => 'Иван', 'last_name' => 'Петров',
|
||||
]);
|
||||
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'status' => 'paid']);
|
||||
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'status' => 'paid']);
|
||||
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'status' => 'won']);
|
||||
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'status' => 'won']);
|
||||
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'status' => 'new']);
|
||||
|
||||
$rows = (new ManagersSummaryProvider)->rows(managersJob($this->tenant->id));
|
||||
|
||||
@@ -350,7 +350,7 @@ test('POST /api/reports/jobs (sync queue): managers_summary → done с CSV', fu
|
||||
'project_id' => $project->id,
|
||||
'manager_id' => $manager->id,
|
||||
'phone' => '+79990001122',
|
||||
'status' => 'paid',
|
||||
'status' => 'won',
|
||||
'received_at' => $now,
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
@@ -383,7 +383,7 @@ test('POST /api/reports/jobs (sync queue): sources_summary → done с CSV', fun
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'project_id' => $project->id,
|
||||
'phone' => '+79990002233',
|
||||
'status' => 'paid',
|
||||
'status' => 'won',
|
||||
'utm_source' => 'yandex',
|
||||
'received_at' => $now,
|
||||
'created_at' => Carbon::now(),
|
||||
|
||||
@@ -53,9 +53,9 @@ test('slug = sources', function () {
|
||||
});
|
||||
|
||||
test('агрегирует сделки по utm_source', function () {
|
||||
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex', 'status' => 'paid']);
|
||||
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex', 'status' => 'won']);
|
||||
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex', 'status' => 'new']);
|
||||
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'vk', 'status' => 'paid']);
|
||||
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'vk', 'status' => 'won']);
|
||||
|
||||
$rows = (new SourcesSummaryProvider)->rows(sourcesJob($this->tenant->id));
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ function makeApiDetail(overrides: Partial<AdminTenantDetailResponse> = {}): Admi
|
||||
event: 'deal.status_changed',
|
||||
deal_id: 4470,
|
||||
actor_email: 'ivan@okna-moscow.ru',
|
||||
context: { from: 'viewed', to: 'worked' },
|
||||
context: { from: 'viewed', to: 'in_progress' },
|
||||
created_at: '2026-05-09T07:18:00+00:00',
|
||||
},
|
||||
],
|
||||
@@ -221,7 +221,7 @@ describe('AdminTenantDetailView.vue (API integration)', () => {
|
||||
expect(text).toContain('webhook.received');
|
||||
expect(text).toContain('deal.status_changed');
|
||||
expect(text).toContain('ivan@okna-moscow.ru'); // actor_email
|
||||
expect(text).toContain('viewed → worked'); // summary из context
|
||||
expect(text).toContain('viewed → in_progress'); // summary из context
|
||||
});
|
||||
|
||||
it('кнопка «Войти как клиент» open impersonationDialog', async () => {
|
||||
|
||||
@@ -87,14 +87,16 @@ describe('AppLayout.vue', () => {
|
||||
expect(text).toContain('Команда');
|
||||
});
|
||||
|
||||
it('содержит все 6 nav-пунктов (Менеджеры+Напоминания убраны по требованию заказчика)', async () => {
|
||||
it('содержит 6 nav-пунктов (Импорт данных + Отчёты убраны по требованию заказчика)', async () => {
|
||||
const wrapper = await mountAppLayout();
|
||||
const text = wrapper.text();
|
||||
['Проекты', 'Сделки', 'Канбан', 'Дашборд', 'Биллинг', 'Отчёты', 'Настройки'].forEach((label) =>
|
||||
['Проекты', 'Сделки', 'Канбан', 'Дашборд', 'Биллинг', 'Настройки'].forEach((label) =>
|
||||
expect(text).toContain(label),
|
||||
);
|
||||
expect(text).not.toContain('Менеджеры');
|
||||
expect(text).not.toContain('Напоминания');
|
||||
expect(text).not.toContain('Импорт данных');
|
||||
expect(text).not.toContain('Отчёты');
|
||||
});
|
||||
|
||||
it('показывает счётчики только у пунктов с count', async () => {
|
||||
|
||||
@@ -31,7 +31,7 @@ function makeSummary(overrides: Partial<DashboardSummary> = {}): DashboardSummar
|
||||
active_projects: { active: 8, limit: 10 },
|
||||
balance: { amount_rub: '14250.00', runway_days: 4, runway_leads: 285 },
|
||||
activity: { points: [3, 5, 2, 8, 6, 9, 4], labels: ['сб', 'вс', 'пн', 'вт', 'ср', 'чт', 'сегодня'], max: 10 },
|
||||
funnel: { new: 18, paid: 45 },
|
||||
funnel: { new: 18, won: 45 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,95 +1,47 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import DealDetailDrawer from '../../resources/js/components/deals/DealDetailDrawer.vue';
|
||||
import { MOCK_DEALS } from '../../resources/js/composables/mockDeals';
|
||||
import type { MockDeal } from '../../resources/js/composables/mockDeals';
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
const vuetify = createVuetify();
|
||||
const deal: MockDeal = {
|
||||
id: 1, name: '+7 999', phone: '+7 999', statusSlug: 'new', project: 'Окна',
|
||||
manager: { initials: 'AD', name: 'Admin' }, cost: 0, receivedMinutesAgo: 5,
|
||||
};
|
||||
|
||||
// DealDetailDrawer использует v-navigation-drawer, который требует layout-
|
||||
// контекст от v-app/v-layout. В Vitest auto-import недоступен — stub'им
|
||||
// v-navigation-drawer как passthrough div чтобы slot-content рендерился
|
||||
// и был доступен для assertion.
|
||||
|
||||
describe('DealDetailDrawer.vue', () => {
|
||||
const factory = (props: { open: boolean; deal: (typeof MOCK_DEALS)[number] | null }) =>
|
||||
mount(DealDetailDrawer, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [createVuetify()],
|
||||
stubs: {
|
||||
VNavigationDrawer: {
|
||||
template: '<div class="drawer-stub" v-if="modelValue"><slot /></div>',
|
||||
props: ['modelValue'],
|
||||
},
|
||||
function mountDrawer(props: Record<string, unknown>) {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
return mount(DealDetailDrawer, {
|
||||
props: { open: true, deal, ...props },
|
||||
global: {
|
||||
plugins: [vuetify, pinia],
|
||||
stubs: {
|
||||
DealDetailBody: true,
|
||||
VNavigationDrawer: {
|
||||
template: '<div class="drawer-stub" v-if="modelValue"><slot /></div>',
|
||||
props: ['modelValue'],
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const sampleDeal = MOCK_DEALS[0]; // Анна Соколова
|
||||
|
||||
it('не рендерит контент когда open=false', () => {
|
||||
const wrapper = factory({ open: false, deal: sampleDeal });
|
||||
expect(wrapper.find('.drawer-stub').exists()).toBe(false);
|
||||
describe('DealDetailDrawer wrapper', () => {
|
||||
it('inline=true рендерит <aside> (master-detail панель)', () => {
|
||||
const w = mountDrawer({ inline: true });
|
||||
expect(w.find('aside.deal-detail-inline').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('не рендерит контент когда deal=null (даже при open=true)', () => {
|
||||
const wrapper = factory({ open: true, deal: null });
|
||||
// Drawer открыт, но deal нет — content внутри v-if не рендерится.
|
||||
const stub = wrapper.find('.drawer-stub');
|
||||
if (stub.exists()) {
|
||||
// Нет hero/section элементов внутри.
|
||||
expect(wrapper.find('.hero').exists()).toBe(false);
|
||||
}
|
||||
it('inline=false (по умолчанию) рендерит overlay v-navigation-drawer', () => {
|
||||
const w = mountDrawer({});
|
||||
expect(w.find('aside.deal-detail-inline').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('рендерит hero с именем сделки и id', () => {
|
||||
const wrapper = factory({ open: true, deal: sampleDeal });
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain(sampleDeal.name);
|
||||
expect(text).toContain(`#${sampleDeal.id}`);
|
||||
});
|
||||
|
||||
it('рендерит phone как кликабельную ссылку tel:', () => {
|
||||
const wrapper = factory({ open: true, deal: sampleDeal });
|
||||
const phoneLink = wrapper.find('.phone-link');
|
||||
expect(phoneLink.exists()).toBe(true);
|
||||
expect(phoneLink.attributes('href')).toMatch(/^tel:\+/);
|
||||
expect(phoneLink.text()).toBe(sampleDeal.phone);
|
||||
});
|
||||
|
||||
it('рендерит status-chip с nameRu статуса сделки', () => {
|
||||
const wrapper = factory({ open: true, deal: sampleDeal });
|
||||
// sampleDeal.statusSlug='new' → 'Новые'.
|
||||
expect(wrapper.text()).toContain('Новые');
|
||||
});
|
||||
|
||||
it('рендерит секцию параметров с проектом, стоимостью, менеджером', () => {
|
||||
const wrapper = factory({ open: true, deal: sampleDeal });
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Параметры');
|
||||
expect(text).toContain(sampleDeal.project);
|
||||
expect(text).toContain(sampleDeal.manager.name);
|
||||
expect(text).toMatch(/1\s+850\s*₽/); // sampleDeal.cost = 1850
|
||||
});
|
||||
|
||||
it('рендерит timeline без событий (без tenantId events пуст — I3)', () => {
|
||||
const wrapper = factory({ open: true, deal: sampleDeal });
|
||||
const items = wrapper.findAll('.timeline-item');
|
||||
expect(items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('emit-ит update:open=false при close-кнопке', async () => {
|
||||
const wrapper = factory({ open: true, deal: sampleDeal });
|
||||
// Vuetify v-btn рендерит как button. close-btn — единственный с aria-label.
|
||||
const closeBtn = wrapper.find('button[aria-label="Закрыть панель"]');
|
||||
if (closeBtn.exists()) {
|
||||
await closeBtn.trigger('click');
|
||||
expect(wrapper.emitted('update:open')).toBeTruthy();
|
||||
expect(wrapper.emitted('update:open')?.[0]).toEqual([false]);
|
||||
}
|
||||
it('inline-панель содержит DealDetailBody', () => {
|
||||
const w = mountDrawer({ inline: true });
|
||||
expect(w.findComponent({ name: 'DealDetailBody' }).exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import DealDetailDrawer from '../../resources/js/components/deals/DealDetailDrawer.vue';
|
||||
import DealDetailBody from '../../resources/js/components/deals/DealDetailBody.vue';
|
||||
import { MOCK_DEALS } from '../../resources/js/composables/mockDeals';
|
||||
import type { GetDealResponse, ApiDealEvent } from '../../resources/js/api/deals';
|
||||
|
||||
@@ -22,17 +22,11 @@ beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
const factory = (props: { open: boolean; tenantId?: number }) =>
|
||||
mount(DealDetailDrawer, {
|
||||
const factory = (props: { tenantId?: number }) =>
|
||||
mount(DealDetailBody, {
|
||||
props: { deal: MOCK_DEALS[0], ...props },
|
||||
global: {
|
||||
plugins: [createVuetify()],
|
||||
stubs: {
|
||||
VNavigationDrawer: {
|
||||
template: '<div class="drawer-stub" v-if="modelValue"><slot /></div>',
|
||||
props: ['modelValue'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -47,9 +41,9 @@ function makeApiEvent(overrides: Partial<ApiDealEvent> = {}): ApiDealEvent {
|
||||
};
|
||||
}
|
||||
|
||||
describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
|
||||
describe('DealDetailBody ↔ GET /api/deals/{id} integration', () => {
|
||||
it('БЕЗ tenantId — getDeal не вызывается, events пуст (I3)', async () => {
|
||||
const wrapper = factory({ open: true });
|
||||
const wrapper = factory({});
|
||||
await flushPromises();
|
||||
|
||||
expect(dealsApi.getDeal).not.toHaveBeenCalled();
|
||||
@@ -67,6 +61,9 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
|
||||
phone: '+7 (999) 100-00-01',
|
||||
contact_name: 'Anna',
|
||||
comment: null,
|
||||
city: null,
|
||||
project_signal_type: null,
|
||||
next_reminder_at: null,
|
||||
status: 'new',
|
||||
manager_id: null,
|
||||
manager_name: null,
|
||||
@@ -79,27 +76,27 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
|
||||
makeApiEvent({
|
||||
id: 2,
|
||||
event: 'deal.status_changed',
|
||||
context: { from: 'new', to: 'paid' },
|
||||
context: { from: 'new', to: 'won' },
|
||||
actor: { id: 1, name: 'Иван П.', initials: 'ИП' },
|
||||
}),
|
||||
],
|
||||
};
|
||||
vi.mocked(dealsApi.getDeal).mockResolvedValueOnce(apiResponse);
|
||||
|
||||
const wrapper = factory({ open: true, tenantId: 1 });
|
||||
const wrapper = factory({ tenantId: 1 });
|
||||
await flushPromises();
|
||||
|
||||
expect(dealsApi.getDeal).toHaveBeenCalledWith(MOCK_DEALS[0].id, 1);
|
||||
const items = wrapper.findAll('.timeline-item');
|
||||
expect(items).toHaveLength(2);
|
||||
// status_changed event имеет detail "new → paid".
|
||||
expect(wrapper.text()).toContain('new → paid');
|
||||
// status_changed event имеет detail "new → won".
|
||||
expect(wrapper.text()).toContain('new → won');
|
||||
});
|
||||
|
||||
it('getDeal reject → eventsFetchError=true, alert виден, events пуст (I3)', async () => {
|
||||
vi.mocked(dealsApi.getDeal).mockRejectedValueOnce(new Error('500'));
|
||||
|
||||
const wrapper = factory({ open: true, tenantId: 1 });
|
||||
const wrapper = factory({ tenantId: 1 });
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as { eventsFetchError: boolean };
|
||||
@@ -110,12 +107,6 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
|
||||
expect(items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('open=false → getDeal не вызывается', async () => {
|
||||
factory({ open: false, tenantId: 1 });
|
||||
await flushPromises();
|
||||
expect(dealsApi.getDeal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('saveComment вызывает updateDeal + toast success + reload events', async () => {
|
||||
const apiResponse: GetDealResponse = {
|
||||
deal: {
|
||||
@@ -126,6 +117,9 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
|
||||
phone: '+7 (999) 100-00-01',
|
||||
contact_name: 'Anna',
|
||||
comment: 'old',
|
||||
city: null,
|
||||
project_signal_type: null,
|
||||
next_reminder_at: null,
|
||||
status: 'new',
|
||||
manager_id: null,
|
||||
manager_name: null,
|
||||
@@ -141,7 +135,7 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
|
||||
comment: 'новая заметка',
|
||||
});
|
||||
|
||||
const wrapper = factory({ open: true, tenantId: 1 });
|
||||
const wrapper = factory({ tenantId: 1 });
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
@@ -177,6 +171,9 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
|
||||
phone: '+7 (999)',
|
||||
contact_name: null,
|
||||
comment: null,
|
||||
city: null,
|
||||
project_signal_type: null,
|
||||
next_reminder_at: null,
|
||||
status: 'new',
|
||||
manager_id: null,
|
||||
manager_name: null,
|
||||
@@ -188,7 +185,7 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
|
||||
});
|
||||
vi.mocked(dealsApi.updateDeal).mockRejectedValueOnce(new Error('500'));
|
||||
|
||||
const wrapper = factory({ open: true, tenantId: 1 });
|
||||
const wrapper = factory({ tenantId: 1 });
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
@@ -206,7 +203,7 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
|
||||
});
|
||||
|
||||
it('comment-section не показывается без tenantId (read-only mode)', async () => {
|
||||
const wrapper = factory({ open: true });
|
||||
const wrapper = factory({});
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-testid="comment-section"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import DealsBulkBar from '../../resources/js/components/deals/DealsBulkBar.vue';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
const leadStatuses = [
|
||||
{ slug: 'new', nameRu: 'Новые', isSystem: true, sortOrder: 1, colorHex: '#3B82F6' },
|
||||
{ slug: 'viewed', nameRu: 'Просмотрено', isSystem: true, sortOrder: 2, colorHex: '#8B5CF6' },
|
||||
];
|
||||
|
||||
describe('DealsBulkBar', () => {
|
||||
it('скрыт при selectedCount=0', () => {
|
||||
const w = mount(DealsBulkBar, {
|
||||
props: { selectedCount: 0, statusMenuOpen: false, leadStatuses },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
expect(w.find('[data-testid="bulk-bar"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('виден при selectedCount>0 и показывает количество', () => {
|
||||
const w = mount(DealsBulkBar, {
|
||||
props: { selectedCount: 3, statusMenuOpen: false, leadStatuses },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
const bar = w.find('[data-testid="bulk-bar"]');
|
||||
expect(bar.exists()).toBe(true);
|
||||
expect(bar.text()).toContain('3');
|
||||
});
|
||||
|
||||
it('НЕ содержит кнопок Экспорт/Удалить', () => {
|
||||
const w = mount(DealsBulkBar, {
|
||||
props: { selectedCount: 2, statusMenuOpen: false, leadStatuses },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
expect(w.find('[data-testid="bulk-export-btn"]').exists()).toBe(false);
|
||||
expect(w.find('[data-testid="bulk-delete-btn"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('✕ эмитит clear-selected', async () => {
|
||||
const w = mount(DealsBulkBar, {
|
||||
props: { selectedCount: 2, statusMenuOpen: false, leadStatuses },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
await w.find('[data-testid="bulk-clear-btn"]').trigger('click');
|
||||
expect(w.emitted('clear-selected')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import DealsFilters from '../../resources/js/components/deals/DealsFilters.vue';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
const baseProps = {
|
||||
searchPhone: '',
|
||||
filterStatus: null,
|
||||
filterProject: null,
|
||||
filterCity: null,
|
||||
leadStatuses: [{ slug: 'new', nameRu: 'Новые', isSystem: true, sortOrder: 1, colorHex: '#000' }],
|
||||
availableProjects: [{ id: 1, name: 'Окна' }],
|
||||
availableCities: [] as string[],
|
||||
};
|
||||
|
||||
describe('DealsFilters', () => {
|
||||
it('рендерит поле поиска по телефону', () => {
|
||||
const w = mount(DealsFilters, { props: baseProps, global: { plugins: [vuetify] } });
|
||||
expect(w.find('[data-testid="filter-search-phone"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('эмитит update:searchPhone при вводе', async () => {
|
||||
const w = mount(DealsFilters, { props: baseProps, global: { plugins: [vuetify] } });
|
||||
await w.find('[data-testid="filter-search-phone"] input').setValue('999');
|
||||
expect(w.emitted('update:searchPhone')?.at(-1)).toEqual(['999']);
|
||||
});
|
||||
|
||||
it('город-селект disabled при пустом availableCities', () => {
|
||||
const w = mount(DealsFilters, { props: baseProps, global: { plugins: [vuetify] } });
|
||||
expect(w.find('[data-testid="filter-city"]').classes()).toContain('v-input--disabled');
|
||||
});
|
||||
|
||||
it('кнопка сброса видна когда есть активный фильтр', () => {
|
||||
const w = mount(DealsFilters, {
|
||||
props: { ...baseProps, filterStatus: 'new' },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
expect(w.find('[data-testid="clear-filters-btn"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,6 @@ import DealsView from '../../resources/js/views/DealsView.vue';
|
||||
import KanbanView from '../../resources/js/views/KanbanView.vue';
|
||||
import { useAuthStore } from '../../resources/js/stores/auth';
|
||||
import type { ApiDeal } from '../../resources/js/api/deals';
|
||||
import { MOCK_DEALS } from '../../resources/js/composables/mockDeals';
|
||||
|
||||
vi.mock('../../resources/js/api/deals', async (importOriginal) => {
|
||||
const orig = await importOriginal<typeof import('../../resources/js/api/deals')>();
|
||||
@@ -17,10 +16,6 @@ vi.mock('../../resources/js/api/deals', async (importOriginal) => {
|
||||
listManagers: vi.fn().mockResolvedValue([]),
|
||||
listProjects: vi.fn().mockResolvedValue([]),
|
||||
transitionDeals: vi.fn(),
|
||||
exportDeals: vi.fn(),
|
||||
exportDealsXlsx: vi.fn(),
|
||||
bulkDeleteDeals: vi.fn(),
|
||||
bulkRestoreDeals: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -39,6 +34,10 @@ function makeApiDeal(overrides: Partial<ApiDeal> = {}): ApiDeal {
|
||||
manager_name: 'Иван П.',
|
||||
manager_initials: 'ИП',
|
||||
received_at: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
||||
comment: null,
|
||||
city: null,
|
||||
project_signal_type: null,
|
||||
next_reminder_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -73,7 +72,7 @@ const mountDealsView = async () => {
|
||||
return mount(DealsView, {
|
||||
global: {
|
||||
plugins: [createVuetify(), router],
|
||||
stubs: { DealDetailDrawer: true, NewDealDialog: true },
|
||||
stubs: { DealDetailDrawer: true },
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -100,11 +99,11 @@ describe('DealsView ↔ GET /api/deals integration', () => {
|
||||
setupAuth(1);
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||||
deals: [
|
||||
makeApiDeal({ id: 200, contact_name: 'Из API #1', status: 'paid' }),
|
||||
makeApiDeal({ id: 200, contact_name: 'Из API #1', status: 'won' }),
|
||||
makeApiDeal({ id: 201, contact_name: 'Из API #2', status: 'new' }),
|
||||
],
|
||||
total: 2,
|
||||
limit: 200,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
@@ -112,7 +111,7 @@ describe('DealsView ↔ GET /api/deals integration', () => {
|
||||
await flushPromises();
|
||||
|
||||
expect(dealsApi.listDeals).toHaveBeenCalledTimes(1);
|
||||
expect(dealsApi.listDeals).toHaveBeenCalledWith(expect.objectContaining({ tenantId: 1, limit: 200 }));
|
||||
expect(dealsApi.listDeals).toHaveBeenCalledWith(expect.objectContaining({ tenantId: 1, limit: 20 }));
|
||||
|
||||
const vm = wrapper.vm as unknown as { dealsState: { id: number; name: string }[] };
|
||||
expect(vm.dealsState).toHaveLength(2);
|
||||
@@ -134,88 +133,12 @@ describe('DealsView ↔ GET /api/deals integration', () => {
|
||||
expect(wrapper.find('[data-testid="fetch-error-alert"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('toggleTrashMode переключает trashMode + listDeals вызывается с onlyDeleted=true', async () => {
|
||||
setupAuth(1);
|
||||
// Начальный fetch (нормальный режим)
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||||
deals: [makeApiDeal({ id: 600 })],
|
||||
total: 1,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
});
|
||||
// После toggle в trash
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||||
deals: [makeApiDeal({ id: 700, contact_name: 'Удалённый' })],
|
||||
total: 1,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
const wrapper = await mountDealsView();
|
||||
await flushPromises();
|
||||
expect(dealsApi.listDeals).toHaveBeenCalledTimes(1);
|
||||
expect(dealsApi.listDeals).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ tenantId: 1, onlyDeleted: false }),
|
||||
);
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
trashMode: boolean;
|
||||
toggleTrashMode: () => void;
|
||||
dealsState: { id: number }[];
|
||||
};
|
||||
|
||||
vm.toggleTrashMode();
|
||||
await flushPromises();
|
||||
|
||||
expect(vm.trashMode).toBe(true);
|
||||
expect(dealsApi.listDeals).toHaveBeenCalledTimes(2);
|
||||
expect(dealsApi.listDeals).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ tenantId: 1, onlyDeleted: true }),
|
||||
);
|
||||
expect(vm.dealsState.find((d) => d.id === 700)).toBeDefined();
|
||||
});
|
||||
|
||||
it('applyBulkRestoreFromTrash восстанавливает + убирает из dealsState', async () => {
|
||||
setupAuth(1);
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||||
deals: [makeApiDeal({ id: 800 }), makeApiDeal({ id: 801 })],
|
||||
total: 2,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
});
|
||||
vi.mocked(dealsApi.bulkRestoreDeals).mockResolvedValueOnce({
|
||||
restored: 2,
|
||||
requested: 2,
|
||||
});
|
||||
|
||||
const wrapper = await mountDealsView();
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
applyBulkRestoreFromTrash: () => Promise<void>;
|
||||
dealsState: { id: number }[];
|
||||
deleteToastText: string;
|
||||
};
|
||||
|
||||
vm.selected = [800, 801];
|
||||
await flushPromises();
|
||||
await vm.applyBulkRestoreFromTrash();
|
||||
await flushPromises();
|
||||
|
||||
expect(dealsApi.bulkRestoreDeals).toHaveBeenCalledWith({ tenant_id: 1, ids: [800, 801] });
|
||||
// Восстановленные убраны из текущего trash-списка.
|
||||
expect(vm.dealsState.find((d) => d.id === 800)).toBeUndefined();
|
||||
expect(vm.dealsState.find((d) => d.id === 801)).toBeUndefined();
|
||||
expect(vm.deleteToastText).toContain('Восстановлено 2');
|
||||
});
|
||||
|
||||
it('reload-btn повторно вызывает listDeals', async () => {
|
||||
setupAuth(1);
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValue({
|
||||
deals: [makeApiDeal({ id: 400 })],
|
||||
total: 1,
|
||||
limit: 200,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
@@ -233,13 +156,13 @@ describe('DealsView ↔ GET /api/deals integration', () => {
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||||
deals: [makeApiDeal({ id: 500, status: 'new' }), makeApiDeal({ id: 501, status: 'new' })],
|
||||
total: 2,
|
||||
limit: 200,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
vi.mocked(dealsApi.transitionDeals).mockResolvedValueOnce({
|
||||
updated: 2,
|
||||
requested: 2,
|
||||
status: 'paid',
|
||||
status: 'won',
|
||||
});
|
||||
|
||||
const wrapper = await mountDealsView();
|
||||
@@ -255,353 +178,27 @@ describe('DealsView ↔ GET /api/deals integration', () => {
|
||||
vm.selected = [500, 501];
|
||||
await flushPromises();
|
||||
|
||||
await vm.applyBulkStatus('paid');
|
||||
await vm.applyBulkStatus('won');
|
||||
await flushPromises();
|
||||
|
||||
// Optimistic local-update применился до завершения API-вызова.
|
||||
expect(vm.dealsState.find((d) => d.id === 500)?.statusSlug).toBe('paid');
|
||||
expect(vm.dealsState.find((d) => d.id === 501)?.statusSlug).toBe('paid');
|
||||
expect(vm.dealsState.find((d) => d.id === 500)?.statusSlug).toBe('won');
|
||||
expect(vm.dealsState.find((d) => d.id === 501)?.statusSlug).toBe('won');
|
||||
expect(dealsApi.transitionDeals).toHaveBeenCalledWith({
|
||||
tenant_id: 1,
|
||||
ids: [500, 501],
|
||||
status: 'paid',
|
||||
status: 'won',
|
||||
});
|
||||
expect(vm.statusToastOpen).toBe(true);
|
||||
expect(vm.statusToastText).toContain('Обновлено 2');
|
||||
});
|
||||
|
||||
it('applyBulkStatus БЕЗ tenant_id — только локальный update, transitionDeals НЕ вызывается', async () => {
|
||||
setupAuth(null);
|
||||
const wrapper = await mountDealsView();
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
applyBulkStatus: (slug: string) => Promise<void>;
|
||||
dealsState: { id: number; statusSlug: string }[];
|
||||
};
|
||||
// Засеваем dealsState mock-фикстурой (после I3 init пустой)
|
||||
vm.dealsState.push(...MOCK_DEALS.map((d) => ({ ...d, manager: { ...d.manager } })));
|
||||
await flushPromises();
|
||||
vm.selected = [1];
|
||||
await flushPromises();
|
||||
await vm.applyBulkStatus('paid');
|
||||
await flushPromises();
|
||||
|
||||
expect(dealsApi.transitionDeals).not.toHaveBeenCalled();
|
||||
expect(vm.dealsState.find((d) => d.id === 1)?.statusSlug).toBe('paid');
|
||||
});
|
||||
|
||||
it('applyBulkExport(xlsx) с tenant_id вызывает exportDealsXlsx и триггерит download', async () => {
|
||||
setupAuth(1);
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||||
deals: [makeApiDeal({ id: 700 })],
|
||||
total: 1,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
});
|
||||
const fakeBlob = new Blob(['fake xlsx'], { type: 'application/octet-stream' });
|
||||
vi.mocked(dealsApi.exportDealsXlsx).mockResolvedValueOnce(fakeBlob);
|
||||
|
||||
const createUrlSpy = vi.fn(() => 'blob:xlsx');
|
||||
const revokeSpy = vi.fn();
|
||||
Object.defineProperty(URL, 'createObjectURL', { value: createUrlSpy, configurable: true });
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { value: revokeSpy, configurable: true });
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
||||
|
||||
const wrapper = await mountDealsView();
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
applyBulkExport: (format?: string) => Promise<void>;
|
||||
exportToastText: string;
|
||||
};
|
||||
vm.selected = [700];
|
||||
await flushPromises();
|
||||
await vm.applyBulkExport(); // default = xlsx
|
||||
await flushPromises();
|
||||
|
||||
expect(dealsApi.exportDealsXlsx).toHaveBeenCalledWith({
|
||||
tenant_id: 1,
|
||||
ids: [700],
|
||||
});
|
||||
expect(dealsApi.exportDeals).not.toHaveBeenCalled();
|
||||
expect(createUrlSpy).toHaveBeenCalledTimes(1);
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1);
|
||||
expect(vm.exportToastText).toContain('XLSX');
|
||||
clickSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('applyBulkExport(csv) с tenant_id вызывает exportDeals (CSV branch)', async () => {
|
||||
setupAuth(1);
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||||
deals: [makeApiDeal({ id: 701 })],
|
||||
total: 1,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
});
|
||||
vi.mocked(dealsApi.exportDeals).mockResolvedValueOnce('id;...');
|
||||
|
||||
Object.defineProperty(URL, 'createObjectURL', { value: vi.fn(() => 'blob:'), configurable: true });
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
||||
|
||||
const wrapper = await mountDealsView();
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
applyBulkExport: (format?: string) => Promise<void>;
|
||||
exportToastText: string;
|
||||
};
|
||||
vm.selected = [701];
|
||||
await flushPromises();
|
||||
await vm.applyBulkExport('csv');
|
||||
await flushPromises();
|
||||
|
||||
expect(dealsApi.exportDeals).toHaveBeenCalledTimes(1);
|
||||
expect(dealsApi.exportDealsXlsx).not.toHaveBeenCalled();
|
||||
expect(vm.exportToastText).toContain('CSV');
|
||||
clickSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('applyBulkExport(xlsx) reject → fallback на local CSV', async () => {
|
||||
setupAuth(1);
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||||
deals: [makeApiDeal({ id: 702 })],
|
||||
total: 1,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
});
|
||||
vi.mocked(dealsApi.exportDealsXlsx).mockRejectedValueOnce(new Error('500'));
|
||||
|
||||
Object.defineProperty(URL, 'createObjectURL', { value: vi.fn(() => 'blob:'), configurable: true });
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
||||
|
||||
const wrapper = await mountDealsView();
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
applyBulkExport: () => Promise<void>;
|
||||
exportToastText: string;
|
||||
};
|
||||
vm.selected = [702];
|
||||
await flushPromises();
|
||||
await vm.applyBulkExport();
|
||||
await flushPromises();
|
||||
|
||||
expect(vm.exportToastText).toContain('Backend недоступен');
|
||||
// local CSV всё равно стриггерил download
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
clickSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('applyBulkDelete с tenant_id вызывает bulkDeleteDeals + optimistic local-removal', async () => {
|
||||
setupAuth(1);
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||||
deals: [makeApiDeal({ id: 800, status: 'new' }), makeApiDeal({ id: 801, status: 'new' })],
|
||||
total: 2,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
});
|
||||
vi.mocked(dealsApi.bulkDeleteDeals).mockResolvedValueOnce({
|
||||
deleted: 2,
|
||||
requested: 2,
|
||||
});
|
||||
|
||||
const wrapper = await mountDealsView();
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
applyBulkDelete: () => Promise<void>;
|
||||
dealsState: { id: number }[];
|
||||
deleteToastOpen: boolean;
|
||||
deleteToastText: string;
|
||||
};
|
||||
vm.selected = [800, 801];
|
||||
await flushPromises();
|
||||
|
||||
await vm.applyBulkDelete();
|
||||
await flushPromises();
|
||||
|
||||
// Optimistic — обе сделки убраны из state.
|
||||
expect(vm.dealsState.find((d) => d.id === 800)).toBeUndefined();
|
||||
expect(vm.dealsState.find((d) => d.id === 801)).toBeUndefined();
|
||||
expect(dealsApi.bulkDeleteDeals).toHaveBeenCalledWith({
|
||||
tenant_id: 1,
|
||||
ids: [800, 801],
|
||||
});
|
||||
expect(vm.deleteToastOpen).toBe(true);
|
||||
expect(vm.deleteToastText).toContain('Удалено 2');
|
||||
});
|
||||
|
||||
it('applyBulkDelete без tenant_id — только локально, bulkDeleteDeals НЕ вызывается', async () => {
|
||||
setupAuth(null);
|
||||
const wrapper = await mountDealsView();
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
applyBulkDelete: () => Promise<void>;
|
||||
dealsState: { id: number }[];
|
||||
};
|
||||
// Засеваем dealsState mock-фикстурой (после I3 init пустой)
|
||||
vm.dealsState.push(...MOCK_DEALS.map((d) => ({ ...d, manager: { ...d.manager } })));
|
||||
await flushPromises();
|
||||
const before = vm.dealsState.length;
|
||||
vm.selected = [1, 2];
|
||||
await flushPromises();
|
||||
await vm.applyBulkDelete();
|
||||
await flushPromises();
|
||||
|
||||
expect(dealsApi.bulkDeleteDeals).not.toHaveBeenCalled();
|
||||
expect(vm.dealsState.length).toBe(before - 2);
|
||||
});
|
||||
|
||||
it('applyBulkDelete reject → warning toast, локальный update остаётся (не откатываем)', async () => {
|
||||
setupAuth(1);
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||||
deals: [makeApiDeal({ id: 900 })],
|
||||
total: 1,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
});
|
||||
vi.mocked(dealsApi.bulkDeleteDeals).mockRejectedValueOnce(new Error('500'));
|
||||
|
||||
const wrapper = await mountDealsView();
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
applyBulkDelete: () => Promise<void>;
|
||||
dealsState: { id: number }[];
|
||||
deleteToastText: string;
|
||||
};
|
||||
vm.selected = [900];
|
||||
await flushPromises();
|
||||
await vm.applyBulkDelete();
|
||||
await flushPromises();
|
||||
|
||||
expect(vm.dealsState.find((d) => d.id === 900)).toBeUndefined(); // optimistic
|
||||
expect(vm.deleteToastText).toContain('Не удалось');
|
||||
});
|
||||
|
||||
it('bulk-delete + undo восстанавливает сделки + вызывает bulkRestoreDeals', async () => {
|
||||
setupAuth(1);
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||||
deals: [makeApiDeal({ id: 1000, contact_name: 'A' }), makeApiDeal({ id: 1001, contact_name: 'B' })],
|
||||
total: 2,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
});
|
||||
vi.mocked(dealsApi.bulkDeleteDeals).mockResolvedValueOnce({
|
||||
deleted: 2,
|
||||
requested: 2,
|
||||
});
|
||||
vi.mocked(dealsApi.bulkRestoreDeals).mockResolvedValueOnce({
|
||||
restored: 2,
|
||||
requested: 2,
|
||||
});
|
||||
|
||||
const wrapper = await mountDealsView();
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
applyBulkDelete: () => Promise<void>;
|
||||
undoBulkDelete: () => Promise<void>;
|
||||
dealsState: { id: number }[];
|
||||
lastDeletedSnapshot: { id: number }[];
|
||||
deleteToastText: string;
|
||||
};
|
||||
|
||||
// Удаляем
|
||||
vm.selected = [1000, 1001];
|
||||
await flushPromises();
|
||||
await vm.applyBulkDelete();
|
||||
await flushPromises();
|
||||
|
||||
expect(vm.dealsState.find((d) => d.id === 1000)).toBeUndefined();
|
||||
expect(vm.lastDeletedSnapshot).toHaveLength(2);
|
||||
|
||||
// Undo
|
||||
await vm.undoBulkDelete();
|
||||
await flushPromises();
|
||||
|
||||
expect(dealsApi.bulkRestoreDeals).toHaveBeenCalledWith({ tenant_id: 1, ids: [1000, 1001] });
|
||||
expect(vm.dealsState.find((d) => d.id === 1000)).toBeDefined();
|
||||
expect(vm.dealsState.find((d) => d.id === 1001)).toBeDefined();
|
||||
expect(vm.lastDeletedSnapshot).toHaveLength(0); // cleared after undo
|
||||
expect(vm.deleteToastText).toContain('Восстановлено 2');
|
||||
});
|
||||
|
||||
it('undoBulkDelete без tenant_id — только локально, bulkRestoreDeals НЕ вызывается', async () => {
|
||||
setupAuth(null);
|
||||
const wrapper = await mountDealsView();
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
applyBulkDelete: () => Promise<void>;
|
||||
undoBulkDelete: () => Promise<void>;
|
||||
dealsState: { id: number }[];
|
||||
lastDeletedSnapshot: { id: number }[];
|
||||
};
|
||||
// Засеваем dealsState mock-фикстурой (после I3 init пустой)
|
||||
vm.dealsState.push(...MOCK_DEALS.map((d) => ({ ...d, manager: { ...d.manager } })));
|
||||
await flushPromises();
|
||||
|
||||
const sample = vm.dealsState[0];
|
||||
vm.selected = [sample.id];
|
||||
await flushPromises();
|
||||
await vm.applyBulkDelete();
|
||||
await flushPromises();
|
||||
expect(vm.dealsState.find((d) => d.id === sample.id)).toBeUndefined();
|
||||
|
||||
await vm.undoBulkDelete();
|
||||
await flushPromises();
|
||||
|
||||
expect(dealsApi.bulkRestoreDeals).not.toHaveBeenCalled();
|
||||
expect(vm.dealsState.find((d) => d.id === sample.id)).toBeDefined();
|
||||
});
|
||||
|
||||
it('undoBulkDelete reject → warning toast, локальное восстановление остаётся', async () => {
|
||||
setupAuth(1);
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||||
deals: [makeApiDeal({ id: 1100 })],
|
||||
total: 1,
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
});
|
||||
vi.mocked(dealsApi.bulkDeleteDeals).mockResolvedValueOnce({ deleted: 1, requested: 1 });
|
||||
vi.mocked(dealsApi.bulkRestoreDeals).mockRejectedValueOnce(new Error('500'));
|
||||
|
||||
const wrapper = await mountDealsView();
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
applyBulkDelete: () => Promise<void>;
|
||||
undoBulkDelete: () => Promise<void>;
|
||||
dealsState: { id: number }[];
|
||||
deleteToastText: string;
|
||||
};
|
||||
vm.selected = [1100];
|
||||
await flushPromises();
|
||||
await vm.applyBulkDelete();
|
||||
await flushPromises();
|
||||
await vm.undoBulkDelete();
|
||||
await flushPromises();
|
||||
|
||||
expect(vm.dealsState.find((d) => d.id === 1100)).toBeDefined(); // optimistic
|
||||
expect(vm.deleteToastText).toContain('Не удалось восстановить');
|
||||
});
|
||||
|
||||
it('applyBulkStatus с reject → toast с warning, локальный update остаётся (не откатываем)', async () => {
|
||||
setupAuth(1);
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||||
deals: [makeApiDeal({ id: 600, status: 'new' })],
|
||||
total: 1,
|
||||
limit: 200,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
vi.mocked(dealsApi.transitionDeals).mockRejectedValueOnce(new Error('500'));
|
||||
@@ -617,10 +214,10 @@ describe('DealsView ↔ GET /api/deals integration', () => {
|
||||
};
|
||||
vm.selected = [600];
|
||||
await flushPromises();
|
||||
await vm.applyBulkStatus('paid');
|
||||
await vm.applyBulkStatus('won');
|
||||
await flushPromises();
|
||||
|
||||
expect(vm.dealsState.find((d) => d.id === 600)?.statusSlug).toBe('paid');
|
||||
expect(vm.dealsState.find((d) => d.id === 600)?.statusSlug).toBe('won');
|
||||
expect(vm.statusToastText).toContain('Не удалось');
|
||||
});
|
||||
});
|
||||
@@ -638,8 +235,8 @@ describe('KanbanView ↔ GET /api/deals integration', () => {
|
||||
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
|
||||
deals: [
|
||||
makeApiDeal({ id: 300, status: 'new' }),
|
||||
makeApiDeal({ id: 301, status: 'paid' }),
|
||||
makeApiDeal({ id: 302, status: 'paid' }),
|
||||
makeApiDeal({ id: 301, status: 'won' }),
|
||||
makeApiDeal({ id: 302, status: 'won' }),
|
||||
],
|
||||
total: 3,
|
||||
limit: 500,
|
||||
@@ -655,7 +252,7 @@ describe('KanbanView ↔ GET /api/deals integration', () => {
|
||||
fetchError: boolean;
|
||||
};
|
||||
expect(vm.dealsByStatus.new.map((d) => d.id)).toEqual([300]);
|
||||
expect(vm.dealsByStatus.paid.map((d) => d.id).sort()).toEqual([301, 302]);
|
||||
expect(vm.dealsByStatus.won.map((d) => d.id).sort()).toEqual([301, 302]);
|
||||
expect(vm.totalDeals).toBe(3);
|
||||
expect(vm.fetchError).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
|
||||
import DealsTable from '../../resources/js/components/deals/DealsTable.vue';
|
||||
import type { MockDeal } from '../../resources/js/composables/mockDeals';
|
||||
|
||||
@@ -9,51 +8,55 @@ const vuetify = createVuetify();
|
||||
|
||||
const sampleDeals: MockDeal[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Иванов И.',
|
||||
phone: '+7 (916) 100-00-01',
|
||||
statusSlug: 'new',
|
||||
project: 'B1 site',
|
||||
manager: { initials: 'AD', name: 'Admin' },
|
||||
cost: 1000,
|
||||
receivedMinutesAgo: 5,
|
||||
id: 1, name: '+7 (916) 100-00-01', phone: '+7 (916) 100-00-01', statusSlug: 'new',
|
||||
project: 'Окна', manager: { initials: 'AD', name: 'Admin' }, cost: 0, receivedMinutesAgo: 5,
|
||||
signalType: 'call', city: 'Москва', comment: 'звонил', receivedAt: '2026-05-15T09:00:00+00:00',
|
||||
nextReminderAt: '2026-05-18T07:00:00+00:00',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Петров П.',
|
||||
phone: '+7 (916) 100-00-02',
|
||||
statusSlug: 'new',
|
||||
project: 'B1 call',
|
||||
manager: { initials: 'AD', name: 'Admin' },
|
||||
cost: 1500,
|
||||
receivedMinutesAgo: 30,
|
||||
id: 2, name: '+7 (916) 100-00-02', phone: '+7 (916) 100-00-02', statusSlug: 'new',
|
||||
project: 'Двери', manager: { initials: 'AD', name: 'Admin' }, cost: 0, receivedMinutesAgo: 30,
|
||||
signalType: 'site', city: null, comment: null, receivedAt: '2026-05-14T09:00:00+00:00',
|
||||
nextReminderAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
describe('DealsTable a11y (Q.DEFER.004 sub-A)', () => {
|
||||
it('select-all header checkbox has aria-label', () => {
|
||||
const wrapper = mount(DealsTable, {
|
||||
describe('DealsTable', () => {
|
||||
it('рендерит колонки реестра лидов', () => {
|
||||
const w = mount(DealsTable, {
|
||||
props: { deals: sampleDeals, selectedIds: [], statusBySlug: new Map() },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
const headerCheckbox = wrapper.find(
|
||||
'th .v-selection-control input[type="checkbox"][aria-label="Выбрать все сделки"]',
|
||||
);
|
||||
expect(headerCheckbox.exists()).toBe(true);
|
||||
const headers = w.findAll('thead th').map((h) => h.text());
|
||||
['Телефон', 'Источник', 'Город', 'Статус', 'Напоминание', 'Комментарий', 'Поставлен'].forEach((label) => {
|
||||
expect(headers.some((h) => h.includes(label))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('each row checkbox has aria-label referencing deal name', () => {
|
||||
const wrapper = mount(DealsTable, {
|
||||
it('город без значения рендерится как «—»', () => {
|
||||
const w = mount(DealsTable, {
|
||||
props: { deals: sampleDeals, selectedIds: [], statusBySlug: new Map() },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
const rowCheckbox1 = wrapper.find(
|
||||
'tbody tr:nth-of-type(1) input[type="checkbox"][aria-label="Выбрать сделку «Иванов И.»"]',
|
||||
);
|
||||
const rowCheckbox2 = wrapper.find(
|
||||
'tbody tr:nth-of-type(2) input[type="checkbox"][aria-label="Выбрать сделку «Петров П.»"]',
|
||||
);
|
||||
expect(rowCheckbox1.exists()).toBe(true);
|
||||
expect(rowCheckbox2.exists()).toBe(true);
|
||||
expect(w.text()).toContain('—');
|
||||
});
|
||||
|
||||
it('select-all чекбокс имеет aria-label', () => {
|
||||
const w = mount(DealsTable, {
|
||||
props: { deals: sampleDeals, selectedIds: [], statusBySlug: new Map() },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
expect(
|
||||
w.find('th .v-selection-control input[type="checkbox"][aria-label="Выбрать все сделки"]').exists(),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('клик по строке эмитит row-click с deal', async () => {
|
||||
const w = mount(DealsTable, {
|
||||
props: { deals: sampleDeals, selectedIds: [], statusBySlug: new Map() },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
await w.find('tbody tr').trigger('click');
|
||||
expect(w.emitted('row-click')?.[0]?.[0]).toMatchObject({ id: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,447 +1,155 @@
|
||||
import { describe, it, test, expect, vi, afterEach } from 'vitest';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import DealsView from '../../resources/js/views/DealsView.vue';
|
||||
import { MOCK_DEALS, type MockDeal } from '../../resources/js/composables/mockDeals';
|
||||
import * as dealsApi from '../../resources/js/api/deals';
|
||||
import { useAuthStore } from '../../resources/js/stores/auth';
|
||||
import type { AuthUser } from '../../resources/js/api/auth';
|
||||
import type { MockDeal } from '../../resources/js/composables/mockDeals';
|
||||
|
||||
// Smoke-тесты DealsView с mock-данными.
|
||||
|
||||
/** Засевает dealsState фикстурой MOCK_DEALS (имитирует успешный API-ответ). */
|
||||
function seedDealsState(wrapper: ReturnType<typeof mount>) {
|
||||
const vm = wrapper.vm as unknown as { dealsState: MockDeal[] };
|
||||
vm.dealsState.push(...MOCK_DEALS.map((d) => ({ ...d, manager: { ...d.manager } })));
|
||||
function apiDeal(id: number, over: Partial<dealsApi.ApiDeal> = {}): dealsApi.ApiDeal {
|
||||
return {
|
||||
id, tenant_id: 42, project_id: 1, project_name: 'Окна', phone: `+7 916 000-00-0${id}`,
|
||||
contact_name: null, status: 'new', manager_id: null, manager_name: null,
|
||||
manager_initials: null, received_at: '2026-05-15T09:00:00+00:00',
|
||||
comment: null, city: null, project_signal_type: 'call', next_reminder_at: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const mountDeals = async () => {
|
||||
async function mountDeals(deals: dealsApi.ApiDeal[] = [apiDeal(1), apiDeal(2)], total = 2) {
|
||||
setActivePinia(createPinia());
|
||||
const auth = useAuthStore();
|
||||
auth.user = { id: 1, tenant_id: 42, email: 't@t.com' } as AuthUser;
|
||||
const dealsSpy = vi.spyOn(dealsApi, 'listDeals').mockResolvedValue({ deals, total, limit: 20, offset: 0 });
|
||||
vi.spyOn(dealsApi, 'listProjects').mockResolvedValue([
|
||||
{ id: 1, name: 'Окна', tag: null, type: 'supplier' },
|
||||
]);
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/deals', component: DealsView }],
|
||||
});
|
||||
await router.push('/deals');
|
||||
await router.isReady();
|
||||
// DealsView содержит DealDetailDrawer (v-navigation-drawer), который требует
|
||||
// injected layout от v-app — оборачиваем компонент в v-app для теста.
|
||||
// DealsView содержит DealDetailDrawer (v-navigation-drawer), который требует
|
||||
// layout-injection от v-app. В Vitest vite-plugin-vuetify auto-import не
|
||||
// работает, layout-context недоступен. Stub'им сам Drawer (тестируется
|
||||
// отдельно в DealDetailDrawer.spec.ts).
|
||||
const wrapper = mount(DealsView, {
|
||||
global: {
|
||||
plugins: [createVuetify(), router],
|
||||
stubs: { DealDetailDrawer: true, NewDealDialog: true },
|
||||
},
|
||||
global: { plugins: [createVuetify(), router], stubs: { DealDetailDrawer: true, DealsFilters: true } },
|
||||
});
|
||||
await flushPromises();
|
||||
seedDealsState(wrapper);
|
||||
await flushPromises();
|
||||
// Reset call history so subsequent vi.spyOn calls in tests start from count=0.
|
||||
dealsSpy.mockClear();
|
||||
return wrapper;
|
||||
};
|
||||
}
|
||||
|
||||
/** Audit C8/F3: монтирует DealsView по произвольному пути (с query-параметрами). */
|
||||
const mountDealsViewAt = async (path: string) => {
|
||||
setActivePinia(createPinia());
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/deals', component: DealsView }],
|
||||
});
|
||||
await router.push(path);
|
||||
await router.isReady();
|
||||
const wrapper = mount(DealsView, {
|
||||
global: {
|
||||
plugins: [createVuetify(), router],
|
||||
stubs: { DealDetailDrawer: true, NewDealDialog: true },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
seedDealsState(wrapper);
|
||||
await flushPromises();
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
describe('DealsView.vue', () => {
|
||||
it('монтируется и содержит заголовок «Сделки»', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
expect(wrapper.find('h1').text()).toBe('Сделки');
|
||||
describe('DealsView.vue — реестр лидов', () => {
|
||||
it('заголовок «Сделки»', async () => {
|
||||
expect((await mountDeals()).find('h1').text()).toBe('Сделки');
|
||||
});
|
||||
|
||||
it('содержит page-stats с числами всего/в работе/ждут оплату', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('новых лида с утра');
|
||||
expect(text).toContain('всего');
|
||||
expect(text).toContain('в работе');
|
||||
expect(text).toContain('ждут оплату');
|
||||
it('панель экспорта: поля дат + кнопки Excel/CSV', async () => {
|
||||
const w = await mountDeals();
|
||||
expect(w.find('[data-testid="export-from"]').exists()).toBe(true);
|
||||
expect(w.find('[data-testid="export-to"]').exists()).toBe(true);
|
||||
expect(w.find('[data-testid="export-xlsx-btn"]').exists()).toBe(true);
|
||||
expect(w.find('[data-testid="export-csv-btn"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('содержит ровно 5 chiprow-tabs', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const text = wrapper.text();
|
||||
['Все', 'Активные', 'Ждут оплату', 'Закрытые', 'Невалидные'].forEach((label) => expect(text).toContain(label));
|
||||
it('селектор «Показывать по» с вариантами 10/20/50', async () => {
|
||||
const w = await mountDeals();
|
||||
const toggle = w.find('[data-testid="perpage-toggle"]');
|
||||
expect(toggle.exists()).toBe(true);
|
||||
['10', '20', '50'].forEach((n) => expect(toggle.text()).toContain(n));
|
||||
});
|
||||
|
||||
it('по умолчанию активен таб «Активные», показывает только active-сделки', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
await flushPromises();
|
||||
const activeStatuses = ['new', 'viewed', 'worked', 'negotiations', 'hot'];
|
||||
const expectedCount = MOCK_DEALS.filter((d) => activeStatuses.includes(d.statusSlug)).length;
|
||||
const rows = wrapper.findAll('tbody tr');
|
||||
expect(rows).toHaveLength(expectedCount);
|
||||
it('НЕТ кнопки «Новая сделка» и режима «Корзина»', async () => {
|
||||
const w = await mountDeals();
|
||||
// «Новая сделка» присутствует как статус-пилюля в таблице (slug `new` —
|
||||
// редизайн воронки 2026-05-17), поэтому проверяем отсутствие именно
|
||||
// КНОПКИ создания сделки: ручное создание убрано в реестре лидов.
|
||||
const buttons = w.findAll('button');
|
||||
expect(buttons.some((b) => b.text().includes('Новая сделка'))).toBe(false);
|
||||
expect(w.text()).not.toContain('Корзина');
|
||||
});
|
||||
|
||||
it('содержит кнопки Экспорт и Новая сделка', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Экспорт');
|
||||
expect(text).toContain('Новая сделка');
|
||||
it('загружает сделки в dealsState через API', async () => {
|
||||
const w = await mountDeals([apiDeal(1), apiDeal(2), apiDeal(3)], 3);
|
||||
const vm = w.vm as unknown as { dealsState: MockDeal[]; total: number };
|
||||
expect(vm.dealsState.length).toBe(3);
|
||||
expect(vm.total).toBe(3);
|
||||
});
|
||||
|
||||
it('таблица содержит колонки Лид/Статус/Проект/Менеджер/Стоимость/Время', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const headers = wrapper.findAll('thead th').map((h) => h.text());
|
||||
['Лид', 'Статус', 'Проект', 'Менеджер', 'Стоимость', 'Время'].forEach((label) => {
|
||||
expect(headers.some((h) => h.includes(label))).toBe(true);
|
||||
});
|
||||
it('openPanel выбирает сделку, повторный клик закрывает', async () => {
|
||||
const w = await mountDeals();
|
||||
const vm = w.vm as unknown as {
|
||||
dealsState: MockDeal[]; panelOpen: boolean; selectedDeal: MockDeal | null;
|
||||
openPanel: (d: MockDeal) => void;
|
||||
};
|
||||
vm.openPanel(vm.dealsState[0]);
|
||||
expect(vm.panelOpen).toBe(true);
|
||||
expect(vm.selectedDeal?.id).toBe(1);
|
||||
vm.openPanel(vm.dealsState[0]);
|
||||
expect(vm.panelOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('форматирует стоимость как «N ₽» с разделителем тысяч', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const text = wrapper.text();
|
||||
// Intl.NumberFormat('ru-RU') использует non-breaking space (U+00A0) или
|
||||
// narrow nbsp (U+202F) как разделитель тысяч, не ASCII-пробел. Явные
|
||||
// \u-escape'ы — иначе ESLint ругается no-irregular-whitespace.
|
||||
expect(text).toMatch(/2\s+400\s*₽/);
|
||||
});
|
||||
|
||||
it('форматирует «время с момента» как «N мин назад» для свежих сделок', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('7 мин назад');
|
||||
});
|
||||
|
||||
it('bulk-bar скрыт когда selected пустой; виден когда selected не пустой', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
await flushPromises();
|
||||
// По умолчанию ничего не выбрано
|
||||
expect(wrapper.find('[data-testid="bulk-bar"]').exists()).toBe(false);
|
||||
// Симулируем выбор через v-model: selected
|
||||
const vm = wrapper.vm as unknown as { selected: number[] };
|
||||
it('bulk-bar появляется при выборе и applyBulkStatus меняет статус', async () => {
|
||||
const w = await mountDeals();
|
||||
const vm = w.vm as unknown as {
|
||||
selected: number[]; dealsState: MockDeal[]; applyBulkStatus: (s: string) => Promise<void>;
|
||||
};
|
||||
vi.spyOn(dealsApi, 'transitionDeals').mockResolvedValue({ updated: 2, requested: 2, status: 'viewed' });
|
||||
vm.selected = [1, 2];
|
||||
await flushPromises();
|
||||
const bar = wrapper.find('[data-testid="bulk-bar"]');
|
||||
expect(bar.exists()).toBe(true);
|
||||
expect(bar.text()).toContain('Выбрано');
|
||||
expect(bar.text()).toContain('2');
|
||||
expect(w.find('[data-testid="bulk-bar"]').exists()).toBe(true);
|
||||
await vm.applyBulkStatus('viewed');
|
||||
expect(vm.dealsState.find((d) => d.id === 1)?.statusSlug).toBe('viewed');
|
||||
});
|
||||
|
||||
it('bulk-status: применение нового статуса меняет statusSlug у выбранных сделок и закрывает меню', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
dealsState: Array<{ id: number; statusSlug: string }>;
|
||||
applyBulkStatus: (slug: string) => void;
|
||||
};
|
||||
vm.selected = [1, 2];
|
||||
await flushPromises();
|
||||
// До применения — id=1 'new', id=2 'worked' (из MOCK_DEALS)
|
||||
const before1 = vm.dealsState.find((d) => d.id === 1)!.statusSlug;
|
||||
expect(before1).toBe('new');
|
||||
vm.applyBulkStatus('paid');
|
||||
await flushPromises();
|
||||
expect(vm.dealsState.find((d) => d.id === 1)!.statusSlug).toBe('paid');
|
||||
expect(vm.dealsState.find((d) => d.id === 2)!.statusSlug).toBe('paid');
|
||||
});
|
||||
|
||||
it('bulk-delete: confirm удаляет выбранные сделки и сбрасывает selected', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
dealsState: Array<{ id: number }>;
|
||||
applyBulkDelete: () => void;
|
||||
};
|
||||
const before = vm.dealsState.length;
|
||||
vm.selected = [1, 3];
|
||||
await flushPromises();
|
||||
vm.applyBulkDelete();
|
||||
await flushPromises();
|
||||
expect(vm.dealsState.length).toBe(before - 2);
|
||||
expect(vm.dealsState.find((d) => d.id === 1)).toBeUndefined();
|
||||
expect(vm.dealsState.find((d) => d.id === 3)).toBeUndefined();
|
||||
expect(vm.selected).toEqual([]);
|
||||
});
|
||||
|
||||
it('bulk-export: показывает toast с количеством выбранных сделок + триггерит CSV-download', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
applyBulkExport: () => void;
|
||||
exportToastOpen: boolean;
|
||||
exportToastText: string;
|
||||
};
|
||||
// Шпион на createObjectURL — в jsdom он бывает не определён, заменим.
|
||||
const createUrlSpy = vi.fn(() => 'blob:mock');
|
||||
const revokeUrlSpy = vi.fn();
|
||||
Object.defineProperty(URL, 'createObjectURL', { value: createUrlSpy, configurable: true });
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { value: revokeUrlSpy, configurable: true });
|
||||
// Подменяем click() на якоре чтобы не словить navigation в jsdom.
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
||||
|
||||
vm.selected = [1, 2, 3, 4];
|
||||
await flushPromises();
|
||||
vm.applyBulkExport();
|
||||
|
||||
expect(createUrlSpy).toHaveBeenCalledTimes(1);
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1);
|
||||
it('exportByRange xlsx вызывает exportDealsByRange', async () => {
|
||||
const w = await mountDeals();
|
||||
const spy = vi.spyOn(dealsApi, 'exportDealsByRange').mockResolvedValue(new Blob());
|
||||
Object.defineProperty(URL, 'createObjectURL', { value: vi.fn(() => 'blob:m'), configurable: true });
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { value: vi.fn(), configurable: true });
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
||||
const vm = w.vm as unknown as { exportByRange: (f: string) => Promise<void>; exportToastOpen: boolean };
|
||||
await vm.exportByRange('xlsx');
|
||||
expect(spy).toHaveBeenCalledOnce();
|
||||
expect(vm.exportToastOpen).toBe(true);
|
||||
expect(vm.exportToastText).toContain('4');
|
||||
expect(vm.exportToastText).toContain('CSV');
|
||||
|
||||
clickSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('bulk-export: пустой selected → toast «Нет выбранных» без CSV', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const vm = wrapper.vm as unknown as {
|
||||
selected: number[];
|
||||
applyBulkExport: () => void;
|
||||
exportToastOpen: boolean;
|
||||
exportToastText: string;
|
||||
};
|
||||
const createUrlSpy = vi.fn(() => 'blob:mock');
|
||||
Object.defineProperty(URL, 'createObjectURL', { value: createUrlSpy, configurable: true });
|
||||
vm.selected = [];
|
||||
vm.applyBulkExport();
|
||||
expect(createUrlSpy).not.toHaveBeenCalled();
|
||||
expect(vm.exportToastText).toContain('Нет выбранных');
|
||||
it('смена фильтра вызывает loadDeals ровно один раз (без двойного fetch)', async () => {
|
||||
const w = await mountDeals();
|
||||
const vm = w.vm as unknown as { filterStatus: string | null; page: number };
|
||||
// Установим spy до смены page, чтобы перехватить все вызовы
|
||||
const spy = vi.spyOn(dealsApi, 'listDeals').mockResolvedValue({ deals: [], total: 0, limit: 20, offset: 0 });
|
||||
// Переходим на страницу 3 — это вызовет watch(page) → loadDeals один раз
|
||||
vm.page = 3;
|
||||
await flushPromises();
|
||||
spy.mockClear(); // сбрасываем счётчик: интересует только смена фильтра
|
||||
// Смена фильтра при page=3: A10 fix должен лишь сбросить page→1 (без прямого loadDeals),
|
||||
// затем watch(page) делает ровно один fetch
|
||||
vm.filterStatus = 'viewed';
|
||||
await flushPromises();
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('кнопка «Новая сделка» открывает NewDealDialog (newDealOpen=true)', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as { newDealOpen: boolean };
|
||||
expect(vm.newDealOpen).toBe(false);
|
||||
await wrapper.find('[data-testid="new-deal-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
expect(vm.newDealOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('onDealCreated добавляет новую сделку в начало dealsState', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const vm = wrapper.vm as unknown as {
|
||||
dealsState: Array<{ id: number; name: string; statusSlug: string }>;
|
||||
onDealCreated: (deal: Record<string, unknown>) => void;
|
||||
};
|
||||
const before = vm.dealsState.length;
|
||||
// Передаём полную форму deal — table-cell ожидает manager.name/phone/cost.
|
||||
vm.onDealCreated({
|
||||
id: 999,
|
||||
name: 'Новый клиент',
|
||||
phone: '+7 (999) 000-00-00',
|
||||
statusSlug: 'new',
|
||||
project: 'Окна Москва',
|
||||
manager: { initials: 'Н', name: 'Новый М.' },
|
||||
cost: 1000,
|
||||
receivedMinutesAgo: 0,
|
||||
});
|
||||
await flushPromises();
|
||||
expect(vm.dealsState.length).toBe(before + 1);
|
||||
expect(vm.dealsState[0].id).toBe(999);
|
||||
expect(vm.dealsState[0].name).toBe('Новый клиент');
|
||||
});
|
||||
|
||||
it('smart-filter projects: оставляет только сделки выбранного проекта', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const vm = wrapper.vm as unknown as { activeTab: string; filterProjects: string[] };
|
||||
vm.activeTab = 'all';
|
||||
vm.filterProjects = ['Окна Москва'];
|
||||
await flushPromises();
|
||||
const rows = wrapper.findAll('tbody tr');
|
||||
// Минимум одна строка, и все содержат «Окна Москва»
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
rows.forEach((row) => expect(row.text()).toContain('Окна Москва'));
|
||||
});
|
||||
|
||||
it('smart-filter managers: оставляет только сделки выбранного менеджера', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const vm = wrapper.vm as unknown as { activeTab: string; filterManagers: string[] };
|
||||
vm.activeTab = 'all';
|
||||
vm.filterManagers = ['Иван П.'];
|
||||
await flushPromises();
|
||||
const rows = wrapper.findAll('tbody tr');
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
rows.forEach((row) => expect(row.text()).toContain('Иван П.'));
|
||||
});
|
||||
|
||||
it('clearFilters сбрасывает projects+managers фильтры, кнопка появляется по условию', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const vm = wrapper.vm as unknown as {
|
||||
filterProjects: string[];
|
||||
filterManagers: string[];
|
||||
clearFilters: () => void;
|
||||
};
|
||||
expect(wrapper.find('[data-testid="clear-filters-btn"]').exists()).toBe(false);
|
||||
vm.filterProjects = ['Окна Москва'];
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-testid="clear-filters-btn"]').exists()).toBe(true);
|
||||
vm.clearFilters();
|
||||
await flushPromises();
|
||||
expect(vm.filterProjects).toEqual([]);
|
||||
expect(vm.filterManagers).toEqual([]);
|
||||
});
|
||||
|
||||
it('bulk-clear: иконка ✕ сбрасывает selected', async () => {
|
||||
const wrapper = await mountDeals();
|
||||
const vm = wrapper.vm as unknown as { selected: number[] };
|
||||
vm.selected = [1, 2];
|
||||
await flushPromises();
|
||||
const clearBtn = wrapper.find('[data-testid="bulk-clear-btn"]');
|
||||
expect(clearBtn.exists()).toBe(true);
|
||||
await clearBtn.trigger('click');
|
||||
await flushPromises();
|
||||
expect(vm.selected).toEqual([]);
|
||||
});
|
||||
|
||||
// Audit C8/F3: deep-link /deals?openId=
|
||||
it('route.query.openId открывает drawer соответствующей сделки', async () => {
|
||||
const openId = MOCK_DEALS[0].id;
|
||||
// Мокаем API чтобы loadDeals заполнил state до вызова openDealFromQuery в onMounted.
|
||||
vi.spyOn(dealsApi, 'listDeals').mockResolvedValue({
|
||||
deals: MOCK_DEALS.map((d) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
phone: d.phone,
|
||||
status: d.statusSlug,
|
||||
project_name: d.project,
|
||||
manager_name: d.manager.name,
|
||||
cost: d.cost,
|
||||
created_at: new Date(Date.now() - d.receivedMinutesAgo * 60000).toISOString(),
|
||||
deleted_at: null,
|
||||
})),
|
||||
total: MOCK_DEALS.length,
|
||||
} as never);
|
||||
it('loadDeals reject → dealsState пустой + fetchError', async () => {
|
||||
setActivePinia(createPinia());
|
||||
const auth = useAuthStore();
|
||||
auth.user = { id: 1, tenant_id: 42, email: 'test@test.com' } as AuthUser;
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/deals', component: DealsView }],
|
||||
});
|
||||
await router.push(`/deals?openId=${openId}`);
|
||||
auth.user = { id: 1, tenant_id: 42, email: 't@t.com' } as AuthUser;
|
||||
vi.spyOn(dealsApi, 'listDeals').mockRejectedValue(new Error('500'));
|
||||
vi.spyOn(dealsApi, 'listProjects').mockResolvedValue([]);
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/deals', component: DealsView }] });
|
||||
await router.push('/deals');
|
||||
await router.isReady();
|
||||
const wrapper = mount(DealsView, {
|
||||
global: {
|
||||
plugins: [createVuetify(), router],
|
||||
stubs: { DealDetailDrawer: true, NewDealDialog: true },
|
||||
},
|
||||
const w = mount(DealsView, {
|
||||
global: { plugins: [createVuetify(), router], stubs: { DealDetailDrawer: true, DealsFilters: true } },
|
||||
});
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as { drawerOpen: boolean; selectedDeal: { id: number } | null };
|
||||
expect(vm.drawerOpen).toBe(true);
|
||||
expect(vm.selectedDeal?.id).toBe(openId);
|
||||
const vm = w.vm as unknown as { dealsState: MockDeal[]; fetchError: boolean };
|
||||
expect(vm.dealsState.length).toBe(0);
|
||||
expect(vm.fetchError).toBe(true);
|
||||
});
|
||||
|
||||
it('openId не найден среди сделок — drawer не открывается, без ошибки', async () => {
|
||||
const wrapper = await mountDealsViewAt('/deals?openId=99999999');
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as { drawerOpen: boolean };
|
||||
expect(vm.drawerOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('навигация на /deals?openId= в смонтированном view открывает drawer (watch)', async () => {
|
||||
const openId = MOCK_DEALS[0].id;
|
||||
const wrapper = await mountDealsViewAt('/deals');
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as { drawerOpen: boolean; selectedDeal: { id: number } | null };
|
||||
expect(vm.drawerOpen).toBe(false);
|
||||
await wrapper.vm.$router.push(`/deals?openId=${openId}`);
|
||||
await flushPromises();
|
||||
expect(vm.drawerOpen).toBe(true);
|
||||
expect(vm.selectedDeal?.id).toBe(openId);
|
||||
});
|
||||
});
|
||||
|
||||
test('C3: exportAllFiltered вызывает backend-экспорт со всеми отфильтрованными id', async () => {
|
||||
const xlsxSpy = vi.spyOn(dealsApi, 'exportDealsXlsx').mockResolvedValue(new Blob());
|
||||
const wrapper = await mountDeals();
|
||||
await flushPromises();
|
||||
|
||||
// Установить auth.user с tenant_id чтобы exportDealIds пошёл в backend
|
||||
const auth = useAuthStore();
|
||||
auth.user = { id: 1, tenant_id: 42, email: 'test@test.com' } as AuthUser;
|
||||
|
||||
// activeTab по умолчанию 'active' — установить 'all' чтобы filteredDeals === dealsState
|
||||
const vm = wrapper.vm as unknown as {
|
||||
activeTab: string;
|
||||
dealsState: Array<{ id: number }>;
|
||||
exportAllFiltered: () => Promise<void>;
|
||||
exportToastOpen: boolean;
|
||||
};
|
||||
vm.activeTab = 'all';
|
||||
await flushPromises();
|
||||
|
||||
await vm.exportAllFiltered();
|
||||
|
||||
expect(xlsxSpy).toHaveBeenCalledTimes(1);
|
||||
const callArg = xlsxSpy.mock.calls[0][0];
|
||||
expect(callArg.ids).toEqual(vm.dealsState.map((d) => d.id));
|
||||
expect(vm.exportToastOpen).toBe(true);
|
||||
});
|
||||
|
||||
test('C3: exportAllFiltered на пустом списке показывает toast и не зовёт backend', async () => {
|
||||
const xlsxSpy = vi.spyOn(dealsApi, 'exportDealsXlsx').mockResolvedValue(new Blob());
|
||||
const wrapper = await mountDeals();
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
activeTab: string;
|
||||
dealsState: Array<{ id: number }>;
|
||||
exportAllFiltered: () => Promise<void>;
|
||||
exportToastOpen: boolean;
|
||||
exportToastText: string;
|
||||
};
|
||||
// Очистить список и поставить tab='all' чтобы filteredDeals тоже пустой
|
||||
vm.activeTab = 'all';
|
||||
vm.dealsState.splice(0, vm.dealsState.length);
|
||||
await flushPromises();
|
||||
|
||||
await vm.exportAllFiltered();
|
||||
|
||||
expect(xlsxSpy).not.toHaveBeenCalled();
|
||||
expect(vm.exportToastText).toBe('Список пуст — нечего экспортировать.');
|
||||
});
|
||||
|
||||
// I3 regression: API reject → dealsState пустой + fetchError=true (нет mock-fallback)
|
||||
// Faithful-паттерн: auth + mock ДО mount, onMounted сам вызывает loadDeals.
|
||||
test('I3: loadDeals reject оставляет dealsState пустым и выставляет fetchError', async () => {
|
||||
vi.spyOn(dealsApi, 'listDeals').mockRejectedValue(new Error('500'));
|
||||
setActivePinia(createPinia());
|
||||
const auth = useAuthStore();
|
||||
auth.user = { id: 1, tenant_id: 42, email: 'test@test.com' } as AuthUser;
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/deals', component: DealsView }],
|
||||
});
|
||||
await router.push('/deals');
|
||||
await router.isReady();
|
||||
const wrapper = mount(DealsView, {
|
||||
global: {
|
||||
plugins: [createVuetify(), router],
|
||||
stubs: { DealDetailDrawer: true, NewDealDialog: true },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
dealsState: MockDeal[];
|
||||
fetchError: boolean;
|
||||
};
|
||||
expect(vm.dealsState.length).toBe(0);
|
||||
expect(vm.fetchError).toBe(true);
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { createMemoryHistory, createRouter } from 'vue-router';
|
||||
import DealsView from '../../resources/js/views/DealsView.vue';
|
||||
|
||||
|
||||
function setup() {
|
||||
setActivePinia(createPinia());
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/deals', component: DealsView }] });
|
||||
router.push('/deals');
|
||||
return mount(DealsView, {
|
||||
global: {
|
||||
plugins: [router, createVuetify()],
|
||||
stubs: { RouterLink: true, VDataTable: true },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('DealsView — redesigned', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it('renders filterbar with at least 3 FilterChips', async () => {
|
||||
const w = setup();
|
||||
await flushPromises();
|
||||
const chips = w.findAll('.ld-filter-chip');
|
||||
expect(chips.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('renders DensityToggle in filterbar', async () => {
|
||||
const w = setup();
|
||||
await flushPromises();
|
||||
expect(w.find('.ld-density-toggle').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('row uses StatusPill component for status column', async () => {
|
||||
const w = setup();
|
||||
await flushPromises();
|
||||
// After data load — at least one ld-status-pill should be present
|
||||
// (если stub VDataTable — test проверяет наличие компонента в template, не в render)
|
||||
expect(w.html()).toMatch(/ld-status-pill|StatusPill/);
|
||||
});
|
||||
|
||||
it('applies ld-hover-lift utility class to table container or row wrapper', async () => {
|
||||
const w = setup();
|
||||
await flushPromises();
|
||||
expect(w.html()).toMatch(/ld-hover-lift|hover-lift/);
|
||||
});
|
||||
|
||||
it('applies ld-stagger-row class to deal rows (motion #2)', async () => {
|
||||
const w = setup();
|
||||
await flushPromises();
|
||||
expect(w.html()).toMatch(/ld-stagger-row/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FilterChip popovers (Sprint 1 C2)', () => {
|
||||
function setupWithRouter() {
|
||||
setActivePinia(createPinia());
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/deals', component: DealsView }] });
|
||||
router.push('/deals');
|
||||
return mount(DealsView, {
|
||||
global: {
|
||||
plugins: [router, createVuetify()],
|
||||
stubs: { DealDetailDrawer: true, NewDealDialog: true, VMenu: { template: '<div><slot name="activator" :props="{}" /><slot /></div>' } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it('clicking Project chip toggles projectMenuOpen ref to true', async () => {
|
||||
const wrapper = setupWithRouter();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
expect(vm.projectMenuOpen).toBe(false);
|
||||
// Trigger via direct ref assignment (v-menu activator manages this ref).
|
||||
// watch(projectMenuOpen) will seed projectMenuDraft on open=true.
|
||||
vm.projectMenuOpen = true;
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(vm.projectMenuOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('clicking Manager chip toggles managerMenuOpen ref to true', async () => {
|
||||
const wrapper = setupWithRouter();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
expect(vm.managerMenuOpen).toBe(false);
|
||||
// Trigger via direct ref assignment (v-menu activator manages this ref).
|
||||
// watch(managerMenuOpen) will seed managerMenuDraft on open=true.
|
||||
vm.managerMenuOpen = true;
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(vm.managerMenuOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('applying project selection updates filterProjects and closes menu', async () => {
|
||||
const wrapper = setupWithRouter();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
// Open menu (watch seeds draft from filterProjects), then override draft manually.
|
||||
vm.projectMenuOpen = true;
|
||||
await wrapper.vm.$nextTick();
|
||||
vm.projectMenuDraft = ['demo-project-1', 'demo-project-2'];
|
||||
vm.applyProjectFilter();
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(vm.filterProjects).toEqual(['demo-project-1', 'demo-project-2']);
|
||||
expect(vm.projectMenuOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -16,22 +16,22 @@ describe('FunnelChart.vue', () => {
|
||||
expect(wrapper.text()).toContain('Воронка');
|
||||
});
|
||||
|
||||
it('содержит ровно 14 сегментов в bar (по числу lead_statuses)', () => {
|
||||
it('содержит ровно 5 сегментов в bar (по числу lead_statuses)', () => {
|
||||
const wrapper = factory();
|
||||
const segs = wrapper.findAll('.funnel-seg');
|
||||
expect(segs).toHaveLength(14);
|
||||
expect(segs).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('содержит ровно 14 list-items', () => {
|
||||
it('содержит ровно 5 list-items', () => {
|
||||
const wrapper = factory();
|
||||
const items = wrapper.findAll('.funnel-list-item');
|
||||
expect(items).toHaveLength(14);
|
||||
expect(items).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('использует правильные slug-имена из schema (НЕ из BRANDBOOK)', () => {
|
||||
const wrapper = factory();
|
||||
const text = wrapper.text();
|
||||
// Проверка что все 14 имён из lead_statuses присутствуют.
|
||||
// Проверка что все 5 имён из lead_statuses присутствуют.
|
||||
LEAD_STATUSES.forEach((s) => {
|
||||
expect(text).toContain(s.nameRu);
|
||||
});
|
||||
@@ -41,10 +41,10 @@ describe('FunnelChart.vue', () => {
|
||||
expect(text).not.toContain('Спам');
|
||||
});
|
||||
|
||||
it('сортирует список по убыванию count (paid 45 — первый)', () => {
|
||||
it('сортирует список по убыванию count (in_progress 96 — первый)', () => {
|
||||
const wrapper = factory();
|
||||
const names = wrapper.findAll('.funnel-list-item .name').map((n) => n.text());
|
||||
expect(names[0]).toBe('Оплачено'); // count=45 — самый большой в DEFAULT_COUNTS.
|
||||
expect(names[0]).toBe('В работе'); // count=96 — самый большой в DEFAULT_COUNTS.
|
||||
});
|
||||
|
||||
it('применяет colorHex из lead_statuses к dots и сегментам', () => {
|
||||
@@ -55,7 +55,7 @@ describe('FunnelChart.vue', () => {
|
||||
});
|
||||
|
||||
it('считает total как сумму counts', () => {
|
||||
const wrapper = factory({ counts: { new: 10, paid: 20 } });
|
||||
const wrapper = factory({ counts: { new: 10, won: 20 } });
|
||||
const text = wrapper.text();
|
||||
// total = 10 + 20 = 30 (остальные слаги с counts={} → 0).
|
||||
expect(text).toContain('30 лидов');
|
||||
|
||||
@@ -28,11 +28,11 @@ describe('KanbanView.vue', () => {
|
||||
expect(wrapper.find('h1').text()).toBe('Канбан');
|
||||
});
|
||||
|
||||
it('рендерит ровно 14 KanbanColumn (по числу lead_statuses)', () => {
|
||||
it('рендерит ровно 5 KanbanColumn (по числу lead_statuses)', () => {
|
||||
const wrapper = factory();
|
||||
const cols = wrapper.findAllComponents({ name: 'KanbanColumn' });
|
||||
expect(cols).toHaveLength(LEAD_STATUSES.length);
|
||||
expect(cols).toHaveLength(14);
|
||||
expect(cols).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('каждая колонка получает соответствующий статус', () => {
|
||||
@@ -46,7 +46,7 @@ describe('KanbanView.vue', () => {
|
||||
it('содержит page-stats с числом статусов и сделок', () => {
|
||||
const wrapper = factory();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('14');
|
||||
expect(text).toContain('5');
|
||||
expect(text).toContain('статусов');
|
||||
expect(text).toContain('сделок');
|
||||
});
|
||||
@@ -110,17 +110,17 @@ describe('KanbanView.vue', () => {
|
||||
const cols = wrapper.findAllComponents({ name: 'KanbanColumn' });
|
||||
// Берём сделку из первой колонки (new) и эмулируем «added» в paid-колонке.
|
||||
const newCol = cols[0]; // new — sortOrder=1
|
||||
const paidCol = cols.find((c) => c.props('status').slug === 'paid')!;
|
||||
const wonCol = cols.find((c) => c.props('status').slug === 'won')!;
|
||||
const dealToMove = (newCol.props('deals') as { id: number; statusSlug: string }[])[0];
|
||||
|
||||
// Эмуляция события vuedraggable@change → KanbanView.onColumnChange.
|
||||
await paidCol.vm.$emit('change', {
|
||||
await wonCol.vm.$emit('change', {
|
||||
added: { element: dealToMove, newIndex: 0 },
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// statusSlug сделки должен переключиться на 'paid'.
|
||||
expect(dealToMove.statusSlug).toBe('paid');
|
||||
// statusSlug сделки должен переключиться на 'won'.
|
||||
expect(dealToMove.statusSlug).toBe('won');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -160,7 +160,7 @@ describe('KanbanView DnD persist (Sprint 1 C4)', () => {
|
||||
const transitionSpy = vi.spyOn(dealsApi, 'transitionDeals').mockResolvedValue({
|
||||
updated: 1,
|
||||
requested: 1,
|
||||
status: 'hot',
|
||||
status: 'in_progress',
|
||||
});
|
||||
const wrapper = mount(KanbanView, {
|
||||
global: {
|
||||
@@ -174,14 +174,14 @@ describe('KanbanView DnD persist (Sprint 1 C4)', () => {
|
||||
|
||||
const deal = { id: 42, statusSlug: 'new' as const, name: 'X', phone: '+79161234567', project: 'p', manager: { name: 'M', initials: 'M' }, cost: 100, receivedMinutesAgo: 5 };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (wrapper.vm as any).onColumnChange('hot', { added: { element: deal, newIndex: 0 } });
|
||||
await (wrapper.vm as any).onColumnChange('in_progress', { added: { element: deal, newIndex: 0 } });
|
||||
|
||||
expect(transitionSpy).toHaveBeenCalledWith({
|
||||
tenant_id: 7,
|
||||
ids: [42],
|
||||
status: 'hot',
|
||||
status: 'in_progress',
|
||||
});
|
||||
expect(deal.statusSlug).toBe('hot');
|
||||
expect(deal.statusSlug).toBe('in_progress');
|
||||
});
|
||||
|
||||
it('onColumnChange reverts statusSlug + opens toast when API rejects', async () => {
|
||||
@@ -200,15 +200,15 @@ describe('KanbanView DnD persist (Sprint 1 C4)', () => {
|
||||
// Имитируем vuedraggable mutation: карточка уже в target column до вызова onColumnChange.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
if (!vm.dealsByStatus.hot) vm.dealsByStatus.hot = [];
|
||||
vm.dealsByStatus.hot.push(deal);
|
||||
if (!vm.dealsByStatus.in_progress) vm.dealsByStatus.in_progress = [];
|
||||
vm.dealsByStatus.in_progress.push(deal);
|
||||
|
||||
await vm.onColumnChange('hot', { added: { element: deal, newIndex: 0 } });
|
||||
await vm.onColumnChange('in_progress', { added: { element: deal, newIndex: 0 } });
|
||||
|
||||
// statusSlug rolled back
|
||||
expect(deal.statusSlug).toBe('new');
|
||||
// Card removed from target column (array-revert branch coverage)
|
||||
expect(vm.dealsByStatus.hot.findIndex((d: { id: number }) => d.id === 43)).toBe(-1);
|
||||
expect(vm.dealsByStatus.in_progress.findIndex((d: { id: number }) => d.id === 43)).toBe(-1);
|
||||
// Card restored to source column
|
||||
expect(vm.dealsByStatus.new.findIndex((d: { id: number }) => d.id === 43)).toBeGreaterThanOrEqual(0);
|
||||
// Toast shown
|
||||
@@ -218,7 +218,7 @@ describe('KanbanView DnD persist (Sprint 1 C4)', () => {
|
||||
|
||||
it('onColumnChange skips API call if no auth.user.tenant_id', async () => {
|
||||
const transitionSpy = vi.spyOn(dealsApi, 'transitionDeals').mockResolvedValue({
|
||||
updated: 1, requested: 1, status: 'hot',
|
||||
updated: 1, requested: 1, status: 'in_progress',
|
||||
});
|
||||
const wrapper = mount(KanbanView, {
|
||||
global: {
|
||||
@@ -232,10 +232,10 @@ describe('KanbanView DnD persist (Sprint 1 C4)', () => {
|
||||
|
||||
const deal = { id: 44, statusSlug: 'new' as const, name: 'Z', phone: '+79161234567', project: 'p', manager: { name: 'M', initials: 'M' }, cost: 100, receivedMinutesAgo: 5 };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (wrapper.vm as any).onColumnChange('hot', { added: { element: deal, newIndex: 0 } });
|
||||
await (wrapper.vm as any).onColumnChange('in_progress', { added: { element: deal, newIndex: 0 } });
|
||||
|
||||
// Без auth — только optimistic local change, API не зовётся
|
||||
expect(transitionSpy).not.toHaveBeenCalled();
|
||||
expect(deal.statusSlug).toBe('hot');
|
||||
expect(deal.statusSlug).toBe('in_progress');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,10 +125,10 @@ describe('NewDealDialog.vue', () => {
|
||||
});
|
||||
|
||||
it('presetStatus → statusSlug дефолтит на пресет (для KanbanView)', async () => {
|
||||
const wrapper = factory({ modelValue: true, presetStatus: 'paid' });
|
||||
const wrapper = factory({ modelValue: true, presetStatus: 'won' });
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as { statusSlug: string };
|
||||
expect(vm.statusSlug).toBe('paid');
|
||||
expect(vm.statusSlug).toBe('won');
|
||||
});
|
||||
|
||||
it('без tenantId — submit НЕ вызывает API (local-only mode)', async () => {
|
||||
|
||||
@@ -15,15 +15,18 @@ const mountDialog = (count = 5) =>
|
||||
},
|
||||
});
|
||||
|
||||
interface DialogVm {
|
||||
addRegions: number[];
|
||||
removeRegions: number[];
|
||||
}
|
||||
|
||||
describe('RegionsBulkDialog', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()));
|
||||
|
||||
it('renders 8 federal-district chips for Add and Remove', () => {
|
||||
it('renders subject-level Add and Remove selectors (not federal districts)', () => {
|
||||
const wrapper = mountDialog();
|
||||
const addChips = wrapper.findAll('[data-testid^="region-add-"]');
|
||||
const removeChips = wrapper.findAll('[data-testid^="region-remove-"]');
|
||||
expect(addChips.length).toBe(8);
|
||||
expect(removeChips.length).toBe(8);
|
||||
expect(wrapper.find('[data-testid="region-add-select"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="region-remove-select"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('shows count from prop', () => {
|
||||
@@ -31,20 +34,25 @@ describe('RegionsBulkDialog', () => {
|
||||
expect(wrapper.text()).toContain('7');
|
||||
});
|
||||
|
||||
it('emits apply with computed bitmasks', async () => {
|
||||
it('emits apply with selected subject codes', async () => {
|
||||
const wrapper = mountDialog();
|
||||
// Toggle Центральный (bit 1) in Add
|
||||
await wrapper.find('[data-testid="region-add-1"]').trigger('click');
|
||||
// Toggle Сибирский (bit 64) in Remove
|
||||
await wrapper.find('[data-testid="region-remove-64"]').trigger('click');
|
||||
(wrapper.vm as unknown as DialogVm).addRegions = [82, 83];
|
||||
(wrapper.vm as unknown as DialogVm).removeRegions = [56];
|
||||
await wrapper.vm.$nextTick();
|
||||
await wrapper.find('[data-testid="apply"]').trigger('click');
|
||||
|
||||
expect(wrapper.emitted('apply')?.[0]).toEqual([{ add: 1, remove: 64 }]);
|
||||
expect(wrapper.emitted('apply')?.[0]).toEqual([{ add_regions: [82, 83], remove_regions: [56] }]);
|
||||
});
|
||||
|
||||
it('apply button disabled when both add and remove are 0', () => {
|
||||
it('apply button disabled when nothing selected', () => {
|
||||
const wrapper = mountDialog();
|
||||
const btn = wrapper.find('[data-testid="apply"]');
|
||||
expect(btn.attributes('disabled')).toBeDefined();
|
||||
expect(wrapper.find('[data-testid="apply"]').attributes('disabled')).toBeDefined();
|
||||
});
|
||||
|
||||
it('apply button enabled once a subject is picked', async () => {
|
||||
const wrapper = mountDialog();
|
||||
(wrapper.vm as unknown as DialogVm).addRegions = [82];
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('[data-testid="apply"]').attributes('disabled')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,15 +66,15 @@ describe('UnknownStatusesDialog', () => {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
vm.selection['Архив'] = 'closed';
|
||||
vm.selection['Спам'] = 'closed';
|
||||
vm.selection['Архив'] = 'lost';
|
||||
vm.selection['Спам'] = 'lost';
|
||||
await flushPromises();
|
||||
await vm.save();
|
||||
await flushPromises();
|
||||
|
||||
expect(spy).toHaveBeenCalledWith([
|
||||
{ status_ru: 'Архив', slug: 'closed' },
|
||||
{ status_ru: 'Спам', slug: 'closed' },
|
||||
{ status_ru: 'Архив', slug: 'lost' },
|
||||
{ status_ru: 'Спам', slug: 'lost' },
|
||||
]);
|
||||
expect(wrapper.emitted('resolved')).toBeTruthy();
|
||||
wrapper.unmount();
|
||||
|
||||
@@ -221,7 +221,7 @@ describe('adminTenantDetailMapper', () => {
|
||||
event: 'deal.status_changed',
|
||||
deal_id: 200,
|
||||
actor_email: 'user@test.io',
|
||||
context: { from: 'new', to: 'worked' },
|
||||
context: { from: 'new', to: 'in_progress' },
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
@@ -229,7 +229,7 @@ describe('adminTenantDetailMapper', () => {
|
||||
);
|
||||
expect(ui.activity[0]!.actor).toBe('system');
|
||||
expect(ui.activity[1]!.actor).toBe('user@test.io');
|
||||
expect(ui.activity[1]!.summary).toBe('Сделка #200: new → worked');
|
||||
expect(ui.activity[1]!.summary).toBe('Сделка #200: new → in_progress');
|
||||
});
|
||||
|
||||
it('metrics: leadsThisMonth/Week/avgLeadCost/runwayDays', () => {
|
||||
|
||||
@@ -15,6 +15,10 @@ describe('mapApiDeal', () => {
|
||||
manager_name: 'Иван П.',
|
||||
manager_initials: 'ИП',
|
||||
received_at: '2026-05-09T10:00:00Z',
|
||||
comment: null,
|
||||
city: null,
|
||||
project_signal_type: null,
|
||||
next_reminder_at: null,
|
||||
};
|
||||
|
||||
it('маппит обязательные поля 1:1', () => {
|
||||
@@ -65,4 +69,30 @@ describe('mapApiDeal', () => {
|
||||
const m = mapApiDeal({ ...baseApi, received_at: null }, new Date('2026-05-09T10:30:00Z'));
|
||||
expect(m.receivedMinutesAgo).toBe(0);
|
||||
});
|
||||
|
||||
it('mapApiDeal переносит city/comment/signalType/receivedAt/nextReminderAt', () => {
|
||||
const iso = '2026-05-15T09:00:00+00:00';
|
||||
const result = mapApiDeal({
|
||||
id: 7,
|
||||
tenant_id: 1,
|
||||
project_id: 2,
|
||||
project_name: 'Окна',
|
||||
phone: '+7 999 000-00-00',
|
||||
contact_name: null,
|
||||
status: 'new',
|
||||
manager_id: null,
|
||||
manager_name: null,
|
||||
manager_initials: null,
|
||||
received_at: iso,
|
||||
comment: 'звонил клиент',
|
||||
city: 'Москва',
|
||||
project_signal_type: 'call',
|
||||
next_reminder_at: iso,
|
||||
});
|
||||
expect(result.city).toBe('Москва');
|
||||
expect(result.comment).toBe('звонил клиент');
|
||||
expect(result.signalType).toBe('call');
|
||||
expect(result.receivedAt).toBe(iso);
|
||||
expect(result.nextReminderAt).toBe(iso);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,11 +22,11 @@ describe('useLeadStatusesStore', () => {
|
||||
expect(store.fetchError).toBe(false);
|
||||
});
|
||||
|
||||
it('findBySlug возвращает статус из snapshot до load', () => {
|
||||
it('findBySlug возвращает статус из snapshot до load (won)', () => {
|
||||
const store = useLeadStatusesStore();
|
||||
const found = store.findBySlug('paid');
|
||||
const found = store.findBySlug('won');
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.nameRu).toBe('Оплачено');
|
||||
expect(found!.nameRu).toBe('Сделка');
|
||||
});
|
||||
|
||||
it('findBySlug возвращает null для неизвестного slug', () => {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { repositionMenuAfterOpen } from '../../resources/js/utils/menuRepositionFix';
|
||||
|
||||
/**
|
||||
* Unit-тесты воркэраунда Vuetify location-strategy (см. menuRepositionFix.ts).
|
||||
* Реальный баг — гонка позиционирования в браузере под prefers-reduced-motion —
|
||||
* в jsdom не воспроизводится (нет layout); он покрыт Playwright-пробой. Здесь
|
||||
* проверяется контракт утилиты: при стабилизации overlay-меню шлётся один resize.
|
||||
*/
|
||||
function makeStableMenu(left: number): HTMLElement {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'v-overlay v-menu';
|
||||
const content = document.createElement('div');
|
||||
content.className = 'v-overlay__content';
|
||||
content.getBoundingClientRect = () =>
|
||||
({ width: 400, height: 300, left, top: 50, right: left + 400, bottom: 350, x: left, y: 50, toJSON() {} }) as DOMRect;
|
||||
overlay.appendChild(content);
|
||||
document.body.appendChild(overlay);
|
||||
return overlay;
|
||||
}
|
||||
|
||||
const wait = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
describe('repositionMenuAfterOpen', () => {
|
||||
afterEach(() => {
|
||||
document.querySelectorAll('.v-overlay').forEach((el) => el.remove());
|
||||
});
|
||||
|
||||
it('does nothing when menu is closing (open=false)', async () => {
|
||||
const spy = vi.fn();
|
||||
window.addEventListener('resize', spy);
|
||||
repositionMenuAfterOpen(false);
|
||||
await wait(200);
|
||||
window.removeEventListener('resize', spy);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dispatches a single resize once the overlay content is geometrically stable', async () => {
|
||||
makeStableMenu(120);
|
||||
const spy = vi.fn();
|
||||
window.addEventListener('resize', spy);
|
||||
repositionMenuAfterOpen(true);
|
||||
await wait(400);
|
||||
window.removeEventListener('resize', spy);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not dispatch resize or throw when no overlay is present', async () => {
|
||||
const spy = vi.fn();
|
||||
window.addEventListener('resize', spy);
|
||||
expect(() => repositionMenuAfterOpen(true)).not.toThrow();
|
||||
await wait(300);
|
||||
window.removeEventListener('resize', spy);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -32,12 +32,12 @@ describe('projectsStore.bulkUpdate', () => {
|
||||
store.filters.status = 'active';
|
||||
store.filters.search = 'окна';
|
||||
|
||||
await store.bulkUpdate({ action: 'update_regions', add: 6, remove: 1 });
|
||||
await store.bulkUpdate({ action: 'update_regions', add_regions: [3, 5], remove_regions: [1] });
|
||||
|
||||
expect(axios.post).toHaveBeenCalledWith('/api/projects/bulk', {
|
||||
action: 'update_regions',
|
||||
add: 6,
|
||||
remove: 1,
|
||||
add_regions: [3, 5],
|
||||
remove_regions: [1],
|
||||
scope: { filter: { signal_type: 'sms', status: 'active', search: 'окна' } },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,16 +2,18 @@ import { describe, it, expect } from 'vitest';
|
||||
import { useStatusPill, STATUS_PILL_SLUGS } from '../../resources/js/composables/useStatusPill';
|
||||
|
||||
describe('useStatusPill', () => {
|
||||
it('exposes exactly 14 known slugs', () => {
|
||||
expect(STATUS_PILL_SLUGS).toHaveLength(14);
|
||||
it('exposes exactly 16 known slugs', () => {
|
||||
expect(STATUS_PILL_SLUGS).toHaveLength(16);
|
||||
expect(STATUS_PILL_SLUGS).toEqual(
|
||||
expect.arrayContaining([
|
||||
'new',
|
||||
'viewed',
|
||||
'in_progress',
|
||||
'callback',
|
||||
'quality',
|
||||
'meeting_set',
|
||||
'won',
|
||||
'lost',
|
||||
'refund',
|
||||
'duplicate',
|
||||
'junk',
|
||||
@@ -26,11 +28,13 @@ describe('useStatusPill', () => {
|
||||
|
||||
it.each([
|
||||
['new', { bg: 'rgba(15,110,86,0.12)', color: '#0F6E56' }],
|
||||
['viewed', { bg: 'rgba(122,91,163,0.15)', color: '#7A5BA3' }],
|
||||
['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 }],
|
||||
['lost', { bg: 'rgba(107,99,86,0.18)', color: '#6B6356' }],
|
||||
['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' }],
|
||||
|
||||
@@ -4,20 +4,33 @@ declare(strict_types=1);
|
||||
|
||||
use App\Services\Import\StatusRuToSlugMapper;
|
||||
|
||||
test('маппит все 14 канонических статусов §6.4', function (): void {
|
||||
$mapper = new StatusRuToSlugMapper;
|
||||
test('старые русские статусы поставщика мапятся в 5 новых slug-ов', function (): void {
|
||||
$m = new StatusRuToSlugMapper;
|
||||
|
||||
expect($mapper->toSlug('Новые'))->toBe('new')
|
||||
->and($mapper->toSlug('Оплачено'))->toBe('paid')
|
||||
->and($mapper->toSlug('Конечный недозвон'))->toBe('final_missed')
|
||||
->and($mapper->map())->toHaveCount(14);
|
||||
expect($m->toSlug('Новые'))->toBe('new')
|
||||
->and($m->toSlug('Просмотрено'))->toBe('viewed')
|
||||
->and($m->toSlug('Проработан'))->toBe('in_progress')
|
||||
->and($m->toSlug('Переговоры'))->toBe('in_progress')
|
||||
->and($m->toSlug('Конечный недозвон'))->toBe('in_progress')
|
||||
->and($m->toSlug('Оплачено'))->toBe('won')
|
||||
->and($m->toSlug('Закрыто и не реализовано'))->toBe('lost');
|
||||
});
|
||||
|
||||
test('новые русские названия 5-статусной воронки мапятся', function (): void {
|
||||
$m = new StatusRuToSlugMapper;
|
||||
|
||||
expect($m->toSlug('Новая сделка'))->toBe('new')
|
||||
->and($m->toSlug('В работе'))->toBe('in_progress')
|
||||
->and($m->toSlug('Сделка'))->toBe('won')
|
||||
->and($m->toSlug('Не реализовано'))->toBe('lost')
|
||||
->and($m->map())->toHaveCount(18); // 5 новых + 13 старых RU-названий
|
||||
});
|
||||
|
||||
test('тримит пробелы вокруг значения', function (): void {
|
||||
expect((new StatusRuToSlugMapper)->toSlug(' Переговоры '))->toBe('negotiations');
|
||||
expect((new StatusRuToSlugMapper)->toSlug(' Переговоры '))->toBe('in_progress');
|
||||
});
|
||||
|
||||
test('возвращает null для неизвестного статуса', function (): void {
|
||||
expect((new StatusRuToSlugMapper)->toSlug('Архив'))->toBeNull()
|
||||
expect((new StatusRuToSlugMapper)->toSlug('Абракадабра'))->toBeNull()
|
||||
->and((new StatusRuToSlugMapper)->toSlug(''))->toBeNull();
|
||||
});
|
||||
|
||||
@@ -1385,3 +1385,46 @@ ivotoby
|
||||
ребейз
|
||||
ребейзнута
|
||||
ребейзом
|
||||
|
||||
# Deals page redesign — spec/plan (2026-05-17)
|
||||
гейта
|
||||
дровер
|
||||
канбаном
|
||||
коммитить
|
||||
|
||||
# C10 business-process tooling integration — spec + plan (2026-05-17)
|
||||
RACI
|
||||
DMN
|
||||
czlonkowski
|
||||
|
||||
# C10 process-modeling skill — BPMN/process vocabulary (2026-05-17)
|
||||
гейтвеи
|
||||
скилу
|
||||
|
||||
# C10 process-analysis skill — discovery/analysis vocabulary (2026-05-17)
|
||||
пином
|
||||
джобы
|
||||
|
||||
# C10 normative sync vocabulary (2026-05-17)
|
||||
линтуются
|
||||
|
||||
# discovery-interview integration — spec/plan/skill (2026-05-18)
|
||||
JTBD
|
||||
триггерится
|
||||
фичу
|
||||
фичей
|
||||
гипотетика
|
||||
гипотетику
|
||||
гипотетики
|
||||
хэндофф
|
||||
тулчейне
|
||||
пинами
|
||||
evals
|
||||
|
||||
# parallel-sessions-coordination spec (2026-05-18)
|
||||
коммитит
|
||||
инвокейшн
|
||||
парсимый
|
||||
парсится
|
||||
ревьюить
|
||||
инвокацией
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
# CHANGELOG schema.sql — Лидерра
|
||||
|
||||
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит двадцать одну запись в обратном хронологическом порядке (v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
|
||||
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит двадцать две записи в обратном хронологическом порядке (v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
|
||||
|
||||
**Файл схемы:** `schema.sql` (текущая версия — v8.22, консолидированная — разворачивает БД с нуля).
|
||||
**Файл схемы:** `schema.sql` (текущая версия — v8.23, консолидированная — разворачивает БД с нуля).
|
||||
|
||||
**История записей:**
|
||||
|
||||
## v8.23 — 2026-05-17 — Редизайн «Сделки» (воронка статусов 14 → 5)
|
||||
|
||||
**Изменения:**
|
||||
|
||||
Воронка статусов 14 → 5: seed `lead_statuses` (`new`/`viewed`/`in_progress`/`won`/`lost`). Инкрементальная миграция `2026_05_17_120000_deals_funnel_14_to_5_statuses.php` ремапит `deals.status`, `tenant_status_overrides.status_slug`, `import_unknown_statuses.mapped_to_slug`. Редизайн страницы «Сделки», спека `docs/superpowers/specs/2026-05-17-deals-page-redesign-design.md`. **Структурных изменений нет** — только seed `lead_statuses` (14 → 5 строк); schema baseline без изменений (64 базовых таблиц / 12 партиций / 119 индексов / 40 RLS / 5 функций / 13 триггеров).
|
||||
|
||||
## v8.22 — 2026-05-17 — Plan 6 (C9 — Subject-level regions)
|
||||
|
||||
**Изменения:**
|
||||
|
||||
+8
-17
@@ -1,6 +1,6 @@
|
||||
-- =============================================================================
|
||||
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
|
||||
-- Версия: v8.22 (17.05.2026 — Plan 6 (C9): projects.regions INT[] subject-level filtering + GIN-индекс idx_projects_regions)
|
||||
-- Версия: v8.23 (17.05.2026 — Редизайн «Сделки»: seed lead_statuses 14→5 (new/viewed/in_progress/won/lost))
|
||||
-- Метрики: 64 базовые таблицы (62 regular + 2 partitioned parents: deals + supplier_lead_costs) + 12 партиций / 119 индексов / 40 RLS-политик / 5 функций / 13 триггеров
|
||||
-- Базовая версия: v8.20 (11.05.2026 — Plan 5 frontend projects UI: projects.archived_at TIMESTAMPTZ NULL для soft archive flow; tenants.limits JSONB NOT NULL DEFAULT '{}' для per-tenant project/user лимитов)
|
||||
-- Базовая версия: v8.19 (11.05.2026 — Plan 4 billing+csv+admin: tenants.delivered_in_month, lead_charges.charge_source + CHECK, supplier_leads.recovered_from_csv_at, supplier_csv_reconcile_log)
|
||||
@@ -316,7 +316,7 @@ CREATE TABLE tariff_plans (
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- lead_statuses — справочник статусов воронки (раздел 7.3, 8.1)
|
||||
-- 14 статусов: 6 системных + 8 настраиваемых
|
||||
-- 5 статусов воронки (все системные)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE lead_statuses (
|
||||
slug VARCHAR(50) PRIMARY KEY,
|
||||
@@ -2574,22 +2574,13 @@ CREATE INDEX idx_admin_audit_pending ON saas_admin_audit_log(approved_at) WHERE
|
||||
-- 11. ЗАПОЛНЕНИЕ СПРАВОЧНИКОВ
|
||||
-- =============================================================================
|
||||
|
||||
-- 14 статусов воронки (раздел 7.3, 8.1)
|
||||
-- 5 статусов воронки (редизайн «Сделки» 2026-05-17 — было 14)
|
||||
INSERT INTO lead_statuses (slug, name_ru, is_system, sort_order, color_hex) VALUES
|
||||
('new', 'Новые', TRUE, 1, '#3B82F6'),
|
||||
('viewed', 'Просмотрено', TRUE, 2, '#8B5CF6'),
|
||||
('worked', 'Проработан', TRUE, 3, '#06B6D4'),
|
||||
('base', 'База', FALSE, 4, '#64748B'),
|
||||
('missed', 'Недозвон', FALSE, 5, '#F59E0B'),
|
||||
('negotiations', 'Переговоры', FALSE, 6, '#EAB308'),
|
||||
('waiting_payment', 'Ожидаем оплаты', FALSE, 7, '#A78BFA'),
|
||||
('partnership', 'Партнерка', FALSE, 8, '#EC4899'),
|
||||
('paid', 'Оплачено', TRUE, 9, '#10B981'),
|
||||
('closed', 'Закрыто и не реализовано', TRUE, 10, '#6B7280'),
|
||||
('test_drive', 'Тест драйв', FALSE,11, '#14B8A6'),
|
||||
('hot', 'Горячий', FALSE,12, '#EF4444'),
|
||||
('replacement', 'На замену', FALSE,13, '#F97316'),
|
||||
('final_missed', 'Конечный недозвон', TRUE, 14, '#1F2937');
|
||||
('new', 'Новая сделка', TRUE, 1, '#3B82F6'),
|
||||
('viewed', 'Просмотрено', TRUE, 2, '#8B5CF6'),
|
||||
('in_progress', 'В работе', TRUE, 3, '#06B6D4'),
|
||||
('won', 'Сделка', TRUE, 4, '#10B981'),
|
||||
('lost', 'Не реализовано', TRUE, 5, '#6B7280');
|
||||
|
||||
|
||||
-- НОВОЕ в v8.2: каталог поставщиков B1/B2/B3
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
# Plugin Stack Rules — Superpowers + Frontend Design (v3.10)
|
||||
# Plugin Stack Rules — Superpowers + Frontend Design (v3.13)
|
||||
|
||||
**Дата:** 17.05.2026
|
||||
**Дата:** 18.05.2026
|
||||
**Назначение:** свод правил совместного использования плагинов Claude Code в проекте Лидерра — paired-stack ядро `obra/superpowers` (14 skills) + `anthropics/frontend-design`, плюс расширенный пул UI-инструментов `ui-ux-pro-max` (skill, marketplace `nextlevelbuilder/ui-ux-pro-max-skill`) и `21st.dev Magic MCP` (MCP-сервер `magic`), плюс инфраструктурный `claude-md-management` (skills, marketplace `anthropics/claude-plugins-official`), плюс **debug-runtime MCP** `@sentry/mcp-server` + `@modelcontextprotocol/server-redis` (v2.1+, R10.1 Блок 3).
|
||||
|
||||
**v3.13** — Anthropic dev-tooling: R10.1 Блок 1 +5 строк таблицы — **skill-creator** (#56) / **plugin-dev** (#57) / **hookify** (#58) / **claude-code-setup** (#59) / **context7** (#60) — 5 Anthropic-плагинов из `anthropics/claude-plugins-official`, уже включённых в `~/.claude/settings.json` `enabledPlugins` user-level без формализации (L1-паттерн). +note (v3.13). Новые 13-я (**authoring-tooling** — #56-#58) и 14-я (**dev-support** — #59-#60) off-phase подкатегории — не UI → вне R6.0/R6.1/R14. **hookify HK1** — hard-rule pre-check на коллизию с economy/skill-discipline хуками; закрывает 🔴-конфликт карты `hookify_plugin ↔ hk_pre_claude`. Содержательных изменений R0–R14: 0. ADR-010. Связано: Tooling v2.14, Pravila v1.28, CLAUDE.md v2.15; план `docs/superpowers/plans/2026-05-18-anthropic-dev-tooling-formalization.md`.
|
||||
|
||||
**v3.12** — discovery-interview: R10.1 Блок 1 +note (v3.12) — **discovery-interview** (Tooling #55, self-authored project-скил `.claude/skills/discovery-interview/`, как process-modeling/process-analysis; режимы FEATURE + SYSTEM). Новая 12-я off-phase подкатегория **discovery-tooling** (§4.30) — не UI → вне R6.0/R6.1/R14. Содержательных изменений R0–R14: 0. Связано: Tooling v2.13, Pravila v1.26, CLAUDE.md v2.13; план `docs/superpowers/plans/2026-05-18-discovery-interview-integration.md`.
|
||||
|
||||
**v3.11** — C10 business-process: R10.1 Блок 1 +1 строка **operations** (`operations@knowledge-work-plugins` v1.2.0, Anthropic Verified, 9 скилов, marketplace-плагин) + Блок 1 note (v3.11) — **process-modeling** + **process-analysis** (self-authored project-скилы `.claude/skills/`) + Блок 3 +1 строка **n8n-mcp** (DEFERRED — workflow-движок n8n, у портала нет n8n). Новая 11-я off-phase подкатегория **business-process** (Tooling #51-54, раздел C10 карты) — не UI → вне R6.0/R6.1/R14, как architecture-tooling/audit-security/ml-ai-tooling. Содержательных изменений R0–R14: 0. Связано: Tooling v2.12, Pravila v1.25, CLAUDE.md v2.12; план `docs/superpowers/plans/2026-05-17-c10-business-process-tooling-integration.md`.
|
||||
|
||||
**v3.10** — A11 ml-ai-tooling: R10.1 Блок 3 +1 строка **Jupyter MCP** (DEFERRED — требует Python ML-окружения; ml-ai-tooling, off-phase, раздел A11 карты) + Блок 1 note (v3.10) — **promptfoo** (npm devDependency `promptfoo`, CLI-eval LLM-промптов) + **Data Scientist skill** (вендоренный сторонний скил `.claude/skills/data-scientist/`). Десятая off-phase подкатегория ml-ai-tooling. Не UI → вне R6/R14. Содержательных изменений R0–R14: 0. Связано: Tooling v2.10, Pravila v1.24, CLAUDE.md v2.10; план `docs/superpowers/plans/2026-05-17-a11-ml-ai-tooling-integration.md`.
|
||||
|
||||
**v3.9** — A3 integration-tooling: R10.1 Блок 3 +1 строка **openapi-mcp-server** (категория integration-tooling, off-phase, раздел A3 карты, stdio MCP, server `openapi` в `.mcp.json`, Tooling §4.22 #47). Не UI → вне R6/R14. Содержательных изменений R0–R14: 0. Связано: Tooling v2.9, Pravila v1.23, CLAUDE.md v2.9; план `docs/superpowers/plans/2026-05-17-a3-integration-tooling-integration.md`.
|
||||
@@ -413,6 +419,12 @@ Stack — **головной**. Все плагины вне stack'а — **ин
|
||||
| **CCPM** *(vendored standalone skill, `/pm` flow, 14 bash-скриптов)* | `automazeio/ccpm` (вендорен в `.claude/skills/ccpm/`) | PRD→эпик→GitHub-issue→код с полной трассируемостью. GitHub-issue-backed модель (ADR-004). PRD/epic store в `.claude/prds/`/`.claude/epics/`. Категория: **project-management** (Tooling #41, вне UI-пула). Bus-factor mitigation — вендорен (community-проект). 0 хуков | при авторинге PRD/epic и создании GitHub-issue из CCPM flow. Не UI → вне R6.0/R6.1/R14 |
|
||||
| **product-management** *(6 команд `/write-spec`, `/roadmap-update` и др.)* | `anthropics/knowledge-work-plugins` (plugin `product-management@knowledge-work-plugins`, Anthropic Verified) | product-strategy церемонии (problem→spec, roadmap, stakeholder updates, research synthesis, competitive analysis, metrics review). Категория: **project-management** (Tooling #42). 0 хуков | при product-strategy work: написание спеки, обновление роадмапа, анализ конкурентов. Не UI → вне R6.0/R6.1/R14 |
|
||||
| **Design plugin** *(Design Critique / Accessibility Audit / UX Writing / Research Synthesis)* | `anthropics/knowledge-work-plugins` (Anthropic Verified) | дизайн-критика и UX — ревью макетов, дизайн-уровневый a11y-аудит, UX-копирайт, research synthesis. Категория: **design-tooling** (Tooling #46, вне UI-пула) | при дизайн-критике макета, UX-анализе, написании микрокопирайта — pre-code (ADR-006). Не подменяет FD #30 (генерация) и `requesting-code-review`. Не UI → вне R6.0/R6.1/R14 |
|
||||
| **operations** *(9 skills: `process-doc` / `process-optimization` / `change-request` / `capacity-plan` / `compliance-tracking` / `risk-assessment` / `runbook` / `status-report` / `vendor-review`)* | `anthropics/knowledge-work-plugins` (plugin `operations@knowledge-work-plugins` v1.2.0, Anthropic Verified) | бизнес-процессы — документирование процесса, оптимизация, change-management, capacity-планирование. Категория: **business-process** (Tooling #51, вне UI-пула). 0 lifecycle-хуков | при работе с бизнес-процессом — документирование/оптимизация/change-request/capacity. Не UI → вне R6.0/R6.1/R14 |
|
||||
| **skill-creator** *(1 skill)* | `anthropics/claude-plugins-official` (Anthropic Verified) | конструктор скилов — создание standalone-скилов с нуля, модификация, performance-eval/benchmark, оптимизация `description` под триггеринг. Категория: **authoring-tooling** (Tooling #56, вне UI-пула) | при создании нового **standalone** проектного скила. SC1 — граница с plugin-dev:skill-development (скилы внутри плагина); SC2 — вендоренные/self-authored скилы правятся прямым Edit, не через skill-creator (риск потери провенанса). Не UI → вне R6.0/R6.1/R14 |
|
||||
| **plugin-dev** *(8 skills + агенты `agent-creator` / `plugin-validator` / `skill-reviewer`)* | `anthropics/claude-plugins-official` (Anthropic Verified) | конструктор Claude-плагинов — структура / агенты / скилы / команды / хуки / MCP-интеграция / settings. Категория: **authoring-tooling** (Tooling #57) | при разработке собственного marketplace-плагина. PD1 — не для модификации вендоренного/self-authored (SC2); PD3 — `plugin-dev:hook-development` генерирует хук → применяется правило HK1. Не UI → вне R6.0/R6.1/R14 |
|
||||
| **hookify** *(skills `/hookify` / `/configure` / `/list` / `/help` + `writing-rules` + агент `conversation-analyzer`)* | `anthropics/claude-plugins-official` (Anthropic Verified) | генератор хуков из анализа транскриптов диалога / явных инструкций. Категория: **authoring-tooling** (Tooling #58) | **только по явному `/hookify`**, не проактивно (HK2). **HK1 hard-rule:** перед генерацией хука — обязательный pre-check на коллизию с уже-зарегистрированными хуками в `~/.claude/settings.json`; перезапись 6-компонентной economy/skill-discipline архитектуры (economy-mode / skill-marker / skill-check / state-guard / postcompact / verifier) **запрещена**; при коллизии — остановка, ручное согласование. HK3 — закрывает 🔴-конфликт карты `hookify_plugin ↔ hk_pre_claude`. Не UI → вне R6.0/R6.1/R14 |
|
||||
| **claude-code-setup** *(skill `claude-automation-recommender`)* | `anthropics/claude-plugins-official` (Anthropic Verified) | рекомендатель Claude Code automations — анализ кодовой базы + советы (хуки / суб-агенты / скилы / плагины / MCP). Read-only. Категория: **dev-support** (Tooling #59, вне UI-пула) | при запросе на оптимизацию Claude Code setup. CCS1 — рекомендации фильтруются R0 stack-gate + R10.1; ничего не устанавливается без явного согласования заказчика. Не UI → вне R6.0/R6.1/R14 |
|
||||
| **context7** *(MCP-tools `query-docs` / `resolve-library-id`)* | `anthropics/claude-plugins-official` (Anthropic Verified) — плагин в `enabledPlugins`, не `.mcp.json`-сервер | актуальная документация библиотек / фреймворков / SDK — отдаёт upstream-доки, обходит cutoff training data. Категория: **dev-support** (Tooling #60) | **первый выбор** для документации **известной библиотеки** (Laravel / Vue / Vuetify / Pest / React / …). CTX1 — WebFetch для конкретного URL, WebSearch — поиск без знания библиотеки. Не UI → вне R6.0/R6.1/R14 |
|
||||
|
||||
**Блок 1 — note (v3.3):** **mermaid-skill** (Tooling #37, генератор C4/architecture-диаграмм) — вендоренный сторонний скил в `.claude/skills/mermaid/` (`WH-2099/mermaid-skill`, MIT), **не** через marketplace и **не** в `enabledPlugins`. Пассивная утилита (генерация Mermaid-исходника), не решатель — формально вне типологии трёх блоков; регистрируется здесь для полноты. Категория **architecture-tooling**, вне R6/R14.
|
||||
|
||||
@@ -422,6 +434,12 @@ Stack — **головной**. Все плагины вне stack'а — **ин
|
||||
|
||||
**Блок 1 — note (v3.10):** **promptfoo** (Tooling #48, ml-ai-tooling) — npm devDependency (`promptfoo`, MIT) в корневом `package.json`, **не** marketplace-плагин и **не** в `enabledPlugins`; CLI-инструмент eval LLM-промптов, запуск `npx promptfoo` вручную/CI (платные LLM-вызовы — никогда в хук, ML1). **Data Scientist skill** (Tooling #49, ml-ai-tooling) — аналогично mermaid-skill/CCPM: вендоренный сторонний скил в `.claude/skills/data-scientist/` (`sickn33/antigravity-awesome-skills`, код MIT / контент CC BY 4.0), **не** через marketplace. Оба формально вне типологии трёх блоков, регистрируются здесь для полноты. Категория **ml-ai-tooling** (раздел A11 карты), вне R6.0/R6.1/R14.
|
||||
|
||||
**Блок 1 — note (v3.11):** **process-modeling** (Tooling #52) + **process-analysis** (Tooling #53) — self-authored project-скилы в `.claude/skills/process-modeling/` и `.claude/skills/process-analysis/`, **не** вендоренные сторонние и **не** через marketplace; написаны проектом (паттерн project-скилов `audit-portal`/`regression`). В отличие от вендоренных mermaid-skill/CCPM/Data Scientist — **линтуются** lefthook'ом (cspell+markdownlint), **не** в `cspell.json` `ignorePaths` / `.markdownlintignore` (конфликт-аудит LINT1). Категория **business-process** (раздел C10 карты), вне R6.0/R6.1/R14.
|
||||
|
||||
**Блок 1 — note (v3.12):** **discovery-interview** (Tooling #55) — self-authored project-скил в `.claude/skills/discovery-interview/`, **не** вендоренный сторонний и **не** через marketplace; написан проектом (паттерн project-скилов `audit-portal`/`regression`/`process-modeling`/`process-analysis`). **Линтуется** lefthook'ом (cspell+markdownlint), **не** в `cspell.json` `ignorePaths` / `.markdownlintignore` (LINT1). Категория **discovery-tooling** (12-я off-phase подкатегория), вне R6.0/R6.1/R14.
|
||||
|
||||
**Блок 1 — note (v3.13):** 5 Anthropic dev-плагинов — **skill-creator** (#56) / **plugin-dev** (#57) / **hookify** (#58) / **claude-code-setup** (#59) / **context7** (#60) — marketplace-плагины из `anthropics/claude-plugins-official`, включены в `~/.claude/settings.json` `enabledPlugins` user-level. Формализованы 18.05.2026 после аудита «мозга» (L1-паттерн «плагин включён без формализации» — повтор UPM/21st 10.05 и Sentry/Redis 13.05). Две новые off-phase подкатегории: **authoring-tooling** (13-я — #56-#58, создание Claude-артефактов) + **dev-support** (14-я — #59-#60, поддержка/документация Claude-разработки), не UI → вне R6.0/R6.1/R14. **hookify** несёт hard-rule HK1 (pre-check на коллизию с existing хуками). `context7` — плагин из marketplace (не `.mcp.json`-сервер Блока 3), хотя предоставляет MCP-tools. ADR-010, Tooling §4.31–§4.35.
|
||||
|
||||
**Отмена:** через удаление из `enabledPlugins` в `~/.claude/settings.json` или через live-override `/имя-плагина` (R0.4.B) на одно действие.
|
||||
|
||||
#### Блок 2: Built-in skills Claude Code (всегда доступны через `Skill` tool по `/имя`)
|
||||
@@ -455,6 +473,7 @@ Stack — **головной**. Все плагины вне stack'а — **ин
|
||||
| **Figma MCP** *(remote `https://mcp.figma.com/mcp`)* — **DEFERRED** | `.mcp.json` (HTTP-транспорт, OAuth) — не установлен, precondition: Figma-аккаунт | извлечение дизайн-токенов/variables из Figma-источника (`get_variable_defs`). **Extract-only** (ADR-006) — code-gen не используется. Категория: **design-tooling** (Tooling #44) | DEFERRED (FM2 — у проекта нет Figma-файла). При появлении Figma-аккаунта. Extract-only — FD #30 остаётся UI-решателем. Вне R6.0/R6.1/R14 |
|
||||
| **openapi-mcp-server** *(`openapi` сервер, tools `mcp__openapi__*`)* | `.mcp.json` (stdio MCP, env `OPENAPI_SPEC_URL` или локальный файл) | **integration-tooling MCP** — OpenAPI/Swagger-спецификации интеграций (inspect, introspect внешних API). Категория: **integration-tooling** (Tooling §4.22 #47). Раздел A3 карты «Программирование — интеграции (API, вебхуки)». Off-phase | при работе с внешними API-интеграциями (introspection спецификаций). **READ-ONLY introspection** — не мутировать внешние API из Claude. Не trigger'ит R6.0/R6.1 фильтры и не входит в R14 pipeline UI-генераторов. Вне R6/R14 |
|
||||
| **Jupyter MCP** *(`jupyter` сервер)* — **DEFERRED** | `.mcp.json` (stdio MCP) — не установлен, precondition: Python ML-окружение | **ml-ai-tooling MCP** — исполняемые ноутбуки (классический ML: обучение моделей). Категория: **ml-ai-tooling** (Tooling §4.25 #50). Раздел A11 карты «ML / AI-разработка». Off-phase | DEFERRED — на native-Windows машине нет Python ML-рантайма и нет модели для обучения. Зарегистрирован как pending-слот (как Figma MCP); устанавливается отдельной severable-задачей при появлении конкретной модели. Вне R6/R14 |
|
||||
| **n8n-mcp** *(`n8n` сервер)* — **DEFERRED** | `.mcp.json` (stdio MCP) — не установлен, precondition: принятие n8n в стек портала | **business-process MCP** — workflow-движок платформы n8n (построение/запуск автоматизированных workflow). Категория: **business-process** (Tooling §4.29 #54). Раздел C10 карты «Бизнес-процессы (общее)». Off-phase | DEFERRED — стек Лидерры не содержит n8n (движок процессов = очередь Laravel + события/джобы); принятие n8n как инфраструктуры — отдельное архитектурное решение (свой ADR), не выбор инструмента (N8N1). Зарегистрирован как pending-слот (как Figma MCP / Jupyter MCP); устанавливается отдельной severable-задачей. Вне R6/R14 |
|
||||
|
||||
**Отмена:** через удаление из `~/.claude.json` или `.mcp.json`. Live-override через `/команду` для MCP не предусмотрен — MCP-серверы не имеют slash-интерфейса.
|
||||
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
# Правила работы Claude в проекте «Лидерра»
|
||||
|
||||
**Версия:** v1.24 (17.05.2026)
|
||||
**Дата:** 17.05.2026
|
||||
**Версия:** v1.28 (18.05.2026)
|
||||
**Дата:** 18.05.2026
|
||||
**Назначение:** настройки проекта (Project instructions) — Claude читает этот файл в каждом чате и следует правилам ниже.
|
||||
**Статус документа:** ✅ утверждён. Содержимое скопировано в поле "Project instructions" Claude.ai. Файл хранится в архиве как служебный документ.
|
||||
|
||||
**Что изменилось в v1.28 относительно v1.27:** §13.2 +абзац «Off-phase authoring-tooling + dev-support» — формализованы 5 Anthropic dev-плагинов из `anthropics/claude-plugins-official`, уже включённых в `~/.claude/settings.json` `enabledPlugins` user-level без формализации (#56 skill-creator, #57 plugin-dev, #58 hookify — тринадцатая off-phase подкатегория authoring-tooling; #59 claude-code-setup, #60 context7 — четырнадцатая off-phase подкатегория dev-support). L1-паттерн «плагин включён без формализации» (повтор UPM/21st 10.05, Sentry/Redis 13.05). hookify несёт hard-rule HK1 — pre-check на коллизию с economy/skill-discipline хуками. Границы — ADR-010. Связано: Tooling v2.14 / PSR_v1 v3.13 / CLAUDE.md v2.15. План `docs/superpowers/plans/2026-05-18-anthropic-dev-tooling-formalization.md`.
|
||||
|
||||
**Что изменилось в v1.27 относительно v1.26:** +§15 hard-rule «Параллельные сессии» (15.1 субагенты+git Sonnet/Opus only, 15.2 нормативка+pre-flight sync, 15.3 cross-refs). §15 третье hard-rule после §12 и §14. Список «нормативка» — 8 позиций. Спек — `docs/superpowers/specs/2026-05-18-parallel-sessions-coordination-design.md`.
|
||||
|
||||
**Что изменилось в v1.26 относительно v1.25:** §13.2 +абзац «Off-phase discovery-tooling» — формализован скил `discovery-interview` (Tooling #55; self-authored project-скил `.claude/skills/discovery-interview/`, режимы FEATURE+SYSTEM) как двенадцатая off-phase подкатегория; как проектный скил регистрируется в §13.2, не §12.2. Границы — ADR-009 (DI1–DI6, разрез по слою-источнику с process-analysis #53). Связано: Tooling v2.13 / PSR_v1 v3.12 / CLAUDE.md v2.13. План `docs/superpowers/plans/2026-05-18-discovery-interview-integration.md`.
|
||||
|
||||
**Что изменилось в v1.25 относительно v1.24:** §13.2 +абзац «Off-phase business-process» — формализованы инструменты раздела C10 карты «Бизнес-процессы (общее)» (#51 operations — marketplace-плагин 9 скилов; #52 process-modeling, #53 process-analysis — self-authored project-скилы; #54 n8n-mcp — DEFERRED, у портала нет n8n) как одиннадцатая off-phase подкатегория. Границы — ADR-008. Связано: Tooling v2.12 / PSR_v1 v3.11 / CLAUDE.md v2.12. План `docs/superpowers/plans/2026-05-17-c10-business-process-tooling-integration.md`.
|
||||
|
||||
**Что изменилось в v1.24 относительно v1.23:** §13.2 +абзац «Off-phase ml-ai-tooling» — формализованы инструменты раздела A11 карты «ML / AI-разработка» (#48 promptfoo, #49 Data Scientist skill, #50 Jupyter MCP DEFERRED) как десятая off-phase подкатегория; promptfoo делает платные LLM-вызовы — только вручную/CI, никогда в хук (ML1). Границы — ADR-007. Связано: Tooling v2.10 / PSR_v1 v3.10 / CLAUDE.md v2.10. План `docs/superpowers/plans/2026-05-17-a11-ml-ai-tooling-integration.md`.
|
||||
|
||||
**Что изменилось в v1.23 относительно v1.22:** §13.2 +абзац «Off-phase integration-tooling» — формализованы инструменты раздела A3 карты «Программирование — интеграции (API, вебхуки)» (#47 openapi-mcp-server, api-docs agent) как девятая off-phase подкатегория; READ-ONLY introspection. Связано: Tooling v2.9 / PSR_v1 v3.9 / CLAUDE.md v2.9. План `docs/superpowers/plans/2026-05-17-a3-integration-tooling-integration.md`.
|
||||
@@ -579,6 +587,10 @@ P0 = блокер старта спринта или регуляторного
|
||||
| **v1.22** | **17.05.2026** | A4 design-tooling: §13.2 +абзац «Off-phase design-tooling» — формализованы 3 инструмента раздела A4 карты «Дизайн (UI/UX, графика, бренд)» (#44 Figma MCP / #45 Universal Icons MCP / #46 Design plugin) как восьмая off-phase подкатегория, отдельная от UI-пула / infrastructure / debug-runtime / orchestration / architecture-tooling / audit-security / project-management; не UI → вне R6.0/R6.1/R14. §13.2 PSR_v1 cross-ref v3.3+ → v3.8+ (текст застрял на v3.3+ — changelog v1.18-v1.20 заявлял bump'ы, но §13.2 не обновлялся; теперь синхронизирован). Связано: Tooling v2.8 / PSR_v1 v3.8 / CLAUDE.md v2.8. План `docs/superpowers/plans/2026-05-17-a4-design-tooling-integration.md`. |
|
||||
| **v1.23** | **17.05.2026** | A3 integration-tooling: §13.2 +абзац «Off-phase integration-tooling» — формализованы инструменты раздела A3 карты «Программирование — интеграции (API, вебхуки)» (#47 `openapi-mcp-server`, Tooling §4.22; `api-docs` agent, claude-flow, без Tooling-номера) как девятая off-phase подкатегория, отдельная от всех предыдущих; не UI → вне R6.0/R6.1/R14. READ-ONLY introspection. Регулируются PSR_v1 R10.1 Блок 3. Связано: Tooling v2.9 / PSR_v1 v3.9 / CLAUDE.md v2.9. План `docs/superpowers/plans/2026-05-17-a3-integration-tooling-integration.md`. Архитектурных изменений в §§1–12 + §§13.1, 13.3–14: 0. |
|
||||
| **v1.24** | **17.05.2026** | A11 ml-ai-tooling: §13.2 +абзац «Off-phase ml-ai-tooling» — формализованы инструменты раздела A11 карты «ML / AI-разработка» (#48 promptfoo — npm devDependency, CLI-eval LLM-промптов; #49 Data Scientist skill — вендоренный сторонний скил; #50 Jupyter MCP — DEFERRED, требует Python ML-окружения) как десятая off-phase подкатегория, отдельная от всех предыдущих; не UI → вне R6.0/R6.1/R14. promptfoo делает платные LLM-вызовы — только вручную/CI, никогда в хук (ML1). Границы — ADR-007. Связано: Tooling v2.10 / PSR_v1 v3.10 / CLAUDE.md v2.10. Через manual Edit всех 4 нормативных файлов (claude-md-management неприменим — исполнение в worktree, §5 п.10 worktree-constraint эксцепшн). План `docs/superpowers/plans/2026-05-17-a11-ml-ai-tooling-integration.md`. Архитектурных изменений в §§1–12 + §§13.1, 13.3–14: 0. |
|
||||
| **v1.25** | **17.05.2026** | C10 business-process: §13.2 +абзац «Off-phase business-process» — формализованы инструменты раздела C10 карты «Бизнес-процессы (общее)» (#51 operations — marketplace-плагин 9 скилов; #52 process-modeling — self-authored BPMN-скил; #53 process-analysis — self-authored discovery-скил; #54 n8n-mcp — DEFERRED, workflow-движок, у портала нет n8n) как одиннадцатая off-phase подкатегория, отдельная от всех предыдущих; не UI → вне R6.0/R6.1/R14. Границы — ADR-008. Связано: Tooling v2.12 / PSR_v1 v3.11 / CLAUDE.md v2.12. Через manual Edit всех 4 нормативных файлов (claude-md-management неприменим — исполнение в worktree, §5 п.10 worktree-constraint эксцепшн — как v1.24). План `docs/superpowers/plans/2026-05-17-c10-business-process-tooling-integration.md`. Архитектурных изменений в §§1–12 + §§13.1, 13.3–14: 0. |
|
||||
| **v1.26** | **18.05.2026** | discovery-interview: §13.2 +абзац «Off-phase discovery-tooling» — формализован скил `discovery-interview` (Tooling #55, §4.30; self-authored project-скил `.claude/skills/discovery-interview/`, режимы FEATURE+SYSTEM — интервью-discovery до проектирования) как двенадцатая off-phase подкатегория, отдельная от всех предыдущих; не UI → вне R6.0/R6.1/R14. Как проектный скил регистрируется в §13.2, **не** в §12.2 (карта Superpowers-скилов); триггер-eval 20/20. Границы — ADR-009 (DI1–DI6). Связано: Tooling v2.13 / PSR_v1 v3.12 / CLAUDE.md v2.13. Через manual Edit всех 4 нормативных файлов (claude-md-management неприменим — исполнение в worktree, §5 п.10 worktree-constraint эксцепшн — как v1.24/v1.25). План `docs/superpowers/plans/2026-05-18-discovery-interview-integration.md`. Архитектурных изменений в §§1–12 + §§13.1, 13.3–14: 0. |
|
||||
| **v1.27** | **18.05.2026** | Параллельные сессии: координация. +§15 hard-rule (15.1 субагенты+git Sonnet/Opus only, 15.2 нормативка+pre-flight sync, 15.3 cross-refs). §15 третье hard-rule после §12 и §14; список «нормативка» — 8 позиций. Лечит два класса инцидентов параллельных-сессий: (A) субагенты теряются между worktree (Sprint 6 прецедент), (B) нормативка/MEMORY дрейфует (Tooling v2.11 collision 17.05.2026). Спек — `docs/superpowers/specs/2026-05-18-parallel-sessions-coordination-design.md`, план — `docs/superpowers/plans/2026-05-18-parallel-sessions-coordination.md`. |
|
||||
| **v1.28** | **18.05.2026** | Anthropic dev-tooling: §13.2 +абзац «Off-phase authoring-tooling + dev-support» — формализованы 5 Anthropic-плагинов из `anthropics/claude-plugins-official`, уже включённых в `~/.claude/settings.json` `enabledPlugins` user-level без формализации (#56 skill-creator / #57 plugin-dev / #58 hookify — тринадцатая off-phase подкатегория authoring-tooling; #59 claude-code-setup / #60 context7 — четырнадцатая off-phase подкатегория dev-support); не UI → вне R6.0/R6.1/R14. L1-паттерн «плагин включён без формализации» (повтор UPM/21st 10.05, Sentry/Redis 13.05). hookify несёт hard-rule HK1 — pre-check на коллизию с economy/skill-discipline хуками; закрывает 🔴-конфликт карты `hookify_plugin ↔ hk_pre_claude`. Границы — ADR-010 (SC1–SC3 / PD1–PD3 / HK1–HK3 / CCS1 / CTX1–CTX2). Связано: Tooling v2.14 / PSR_v1 v3.13 / CLAUDE.md v2.15. Через manual Edit всех 4 нормативных файлов (claude-md-management неприменим — исполнение в worktree, §5 п.10 worktree-constraint эксцепшн — как v1.24/v1.25/v1.26). **NB:** перенумеровано v1.27→v1.28 — v1.27 параллельно занят parallel-sessions §15 (origin/main `781a59c`); ветка `feat/anthropic-dev-tooling` ребейзнута на §15. План `docs/superpowers/plans/2026-05-18-anthropic-dev-tooling-formalization.md`. Архитектурных изменений в §§1–12 + §§13.1, 13.3–14: 0. |
|
||||
|
||||
---
|
||||
|
||||
@@ -725,6 +737,12 @@ Frontend Design и `obra/superpowers` (v5.1.0, 14 skills) — **парный sta
|
||||
|
||||
**Off-phase ml-ai-tooling (A11, v1.24, 17.05.2026):** Инструменты раздела A11 карты «ML / AI-разработка» — #48 `promptfoo` (Tooling §4.23; npm devDependency, CLI-eval LLM-промптов, MIT), #49 `Data Scientist skill` (Tooling §4.24; вендоренный сторонний скил в `.claude/skills/data-scientist/`, классический ML-воркфлоу; код MIT / контент CC BY 4.0), #50 `Jupyter MCP` (Tooling §4.25; **DEFERRED** — требует Python ML-окружения, на native-Windows машине не ставится; зарегистрирован как pending-слот, как Figma MCP #44). Плюс reuse-слой — claude-api skill (PSR_v1 R10.1 Блок 2), context7 MCP, Sentry MCP — без новых номеров. Десятая off-phase подкатегория. Off-phase, не UI → вне R6.0/R6.1/R14 PSR_v1. promptfoo делает платные LLM-вызовы — запуск только вручную/CI, никогда в хук (конфликт-аудит ML1). Границы — ADR-007. Регулируются PSR_v1 R10.1 (Блок 1 — promptfoo dev-dep + Data Scientist skill вендорен; Блок 3 — Jupyter MCP). Установлены 17.05.2026 на ветке `worktree-a11-ml-ai-tooling`; план `docs/superpowers/plans/2026-05-17-a11-ml-ai-tooling-integration.md`.
|
||||
|
||||
**Off-phase business-process (C10, v1.25, 17.05.2026):** Инструменты раздела C10 карты «Бизнес-процессы (общее)» — #51 `operations` (Tooling §4.26; marketplace-плагин `operations@knowledge-work-plugins` v1.2.0, Anthropic Verified, 9 скилов — документирование/оптимизация/change-management/capacity бизнес-процессов; 0 lifecycle-хуков), #52 `process-modeling` (Tooling §4.27; self-authored project-скил `.claude/skills/process-modeling/` — BPMN 2.0 моделирование to-be, рендер делегируется скилу `mermaid`), #53 `process-analysis` (Tooling §4.28; self-authored project-скил `.claude/skills/process-analysis/` — as-is discovery из кода Laravel, узкие места, трассировка, метрики), #54 `n8n-mcp` (Tooling §4.29; **DEFERRED** — workflow-движок платформы n8n; стек Лидерры не содержит n8n: движок процессов = очередь Laravel + события/джобы; принятие n8n = отдельное архитектурное решение; зарегистрирован как pending-слот, как Figma MCP #44 / Jupyter MCP #50). Плюс 5 reuse-кросс-ссылок (mermaid #37, architecture-patterns #38, CCPM #41, product-management #42, superpowers writing-plans) — surface в C10 через `NODE_SECTION_SECONDARY`, без новых номеров. **Одиннадцатая** off-phase подкатегория. Off-phase, не UI → вне R6.0/R6.1/R14 PSR_v1. self-authored скилы process-modeling/process-analysis **линтуются** (cspell+markdownlint), **не** в ignorePaths — в отличие от вендоренных mermaid-skill/CCPM/Data Scientist (конфликт-аудит LINT1). Границы — ADR-008. Регулируются PSR_v1 R10.1 (Блок 1 — operations + note self-authored скилы; Блок 3 — n8n-mcp). Установлены 17.05.2026 на ветке `worktree-c10-business-process-tooling`; план `docs/superpowers/plans/2026-05-17-c10-business-process-tooling-integration.md`.
|
||||
|
||||
**Off-phase discovery-tooling (v1.26, 18.05.2026):** скил `discovery-interview` (Tooling #55, §4.30; self-authored project-скил `.claude/skills/discovery-interview/` — как `audit-portal`/`regression`/`process-modeling`/`process-analysis`) — структурированное интервью-discovery до проектирования: режим FEATURE (JTBD-интервью заказчика — вскрывает проблему, отдаёт discovery-brief в `brainstorming`), режим SYSTEM (интервью-ориентация по мета-слою проекта — карта/CLAUDE.md/MEMORY/Открытые_вопросы/Tooling/git log). **Двенадцатая** off-phase подкатегория. Не UI → вне R6.0/R6.1/R14 PSR_v1. Как **проектный** скил (не Superpowers-скил) регистрируется здесь в §13.2, **не** в §12.2 (карта Superpowers-скилов) — триггерится штатным механизмом using-superpowers по `description` (триггер-eval 20/20). Дубль с `process-analysis` #53 исключён разрезом по слою-источнику; границы — ADR-009 (DI1–DI6). Регулируется PSR_v1 R10.1 Блок 1 note (self-authored project-скил). Установлен 18.05.2026 на ветке `worktree-discovery-interview`; план `docs/superpowers/plans/2026-05-18-discovery-interview-integration.md`.
|
||||
|
||||
**Off-phase authoring-tooling + dev-support (v1.28, 18.05.2026):** 5 Anthropic dev-плагинов из marketplace `anthropics/claude-plugins-official`, уже включённых в `~/.claude/settings.json` `enabledPlugins` user-level — формализованы 18.05.2026 после аудита «мозга» (L1-паттерн «плагин фактически включён без формализации в правилах» — повтор UPM/21st 10.05 и Sentry/Redis 13.05). Подкатегория **authoring-tooling** (тринадцатая, создание Claude-артефактов): #56 `skill-creator` (Tooling §4.31; конструктор standalone-скилов), #57 `plugin-dev` (§4.32; конструктор marketplace-плагинов — 8 sub-skills + 3 агента), #58 `hookify` (§4.33; генератор хуков). Подкатегория **dev-support** (четырнадцатая, поддержка/документация Claude-разработки): #59 `claude-code-setup` (§4.34; рекомендатель Claude Code automations, read-only), #60 `context7` (§4.35; актуальная документация библиотек). Off-phase, не UI → вне R6.0/R6.1/R14 PSR_v1. **hookify** — особое правило: вызов только по явному `/hookify`, перед генерацией хука обязательный pre-check на коллизию с уже-зарегистрированными хуками в `~/.claude/settings.json` (перезапись 6-компонентной economy/skill-discipline архитектуры запрещена — конфликт-аудит HK1; закрывает 🔴-конфликт карты `hookify_plugin ↔ hk_pre_claude`). Границы D2–D5 — ADR-010. Регулируется PSR_v1 R10.1 Блок 1. Установлены 18.05.2026 на ветке `feat/anthropic-dev-tooling`; план `docs/superpowers/plans/2026-05-18-anthropic-dev-tooling-formalization.md`.
|
||||
|
||||
### 13.3. Скоуп
|
||||
|
||||
| Тип задачи | Кто отвечает |
|
||||
@@ -850,6 +868,53 @@ Hard-link идёт через цепочку: R14 нарушено → R10.4 «
|
||||
|
||||
---
|
||||
|
||||
## 15. Параллельные сессии — hard rule (субагенты + git, нормативка + pre-flight sync)
|
||||
|
||||
Действует с 18.05.2026. **Hard rule**: §9 «Отступления» к §15 не применяется (как §12 и §14).
|
||||
|
||||
### 15.1 Субагенты + git
|
||||
|
||||
Git-коммит-задачи субагенту (любой `Task`-инвокейшн, чей prompt содержит `git commit`, `git push`, `git stage`, `git checkout`, `git switch`, `git merge`, `git rebase`, либо где явно ожидается коммит в результате) — **только модель Sonnet или Opus**, никогда Haiku. Контроллер, делегирующий git-операцию Haiku-субагенту — нарушение §15.1, фиксируется в feedback того же уровня, что §12.
|
||||
|
||||
Исключение — read-only git-операции (`git log`, `git status`, `git diff`, `git rev-parse`, `git branch --show-current`, `git worktree list`) — разрешены любой модели.
|
||||
|
||||
Прецедент-источник: Sprint 6 (17.05.2026) — Haiku-субагенты угнали ветку параллельной сессии, устранено через `git reflog` + `reset`. Корневая причина — отсутствие верификации HEAD/branch после Task-инвокации. Verify-протокол — `.claude/skills/subagent-driven-development/references/git-safety-checklist.md`.
|
||||
|
||||
### 15.2 Нормативные правки + pre-flight sync
|
||||
|
||||
Любая правка файлов из списка «нормативка» (см. ниже) выполняется **только** на актуальной базе `origin/main`. Pre-flight обязателен:
|
||||
|
||||
```bash
|
||||
git fetch origin && git log HEAD..origin/main --oneline
|
||||
```
|
||||
|
||||
Если есть untracked commits на `origin/main`, ребейз/merge **до начала правки**, не после.
|
||||
|
||||
Параллельная нормативная правка на устаревшей базе — нарушение §15.2. Признак нарушения: коммит правит файл, чья последняя версия на `origin/main` новее, чем версия в parent коммите правки.
|
||||
|
||||
**Список «нормативка» — 8 позиций:**
|
||||
|
||||
1. `docs/Pravila_raboty_Claude_v1_1.md`
|
||||
2. `CLAUDE.md`
|
||||
3. `docs/Tooling_v8_3.md`
|
||||
4. `docs/Plugin_stack_rules_v1.md`
|
||||
5. `memory/MEMORY.md` (и все `memory/*.md`)
|
||||
6. `docs/Открытые_вопросы_v8_3.md`
|
||||
7. `docs/adr/*.md` (glob — collision на ADR-NNN номере = тот же класс, что version-bump нормативки)
|
||||
8. `db/schema.sql` (параллельные миграции из разных сессий = реальный риск; запись в `db/CHANGELOG_schema.md` сама не защищает от version-base дрейфа)
|
||||
|
||||
Расширение списка — отдельная правка §15.2, не «по ощущениям».
|
||||
|
||||
Дополнительно: при параллельных активных сессиях контроллер обязан добавить запись в `docs/sessions/CURRENT.md` до первой нормативной правки (claim) — формат и жизненный цикл записи описаны в `docs/sessions/README.md`. Конфликт-резолюция (file overlap / section overlap / version-claim collision) — там же.
|
||||
|
||||
### 15.3 Cross-refs в других файлах
|
||||
|
||||
- **CLAUDE.md §1 priority chain** — §15 рядом с §12 и §14 как hard-rule (см. footer-абзац после цепочки).
|
||||
- **PSR_v1** — не правится: §15 не про координацию плагинов, а про координацию сессий.
|
||||
- **Tooling** — не правится.
|
||||
|
||||
---
|
||||
|
||||
## Что сделано после утверждения
|
||||
|
||||
Заказчик согласовал v1.1-DRAFT (короткий ответ «а» = вариант A: поправить §4.8 и шапку, выпустить v1.1) в сессии 05.05.2026. Claude выполнил:
|
||||
|
||||
+146
-4
File diff suppressed because one or more lines are too long
@@ -0,0 +1,44 @@
|
||||
# ADR-008: Business-process tooling (C10)
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-05-17
|
||||
- **Deciders:** Дмитрий
|
||||
|
||||
## Context
|
||||
|
||||
The `C10 «Бизнес-процессы (общее)»` map section had zero tooling. C10 is the
|
||||
catch-all of bucket C — its work (modeling, automation, analysis of business
|
||||
processes) partly overlaps already-populated sections (C9 PM, E2 orchestration,
|
||||
A6 diagrams). A toolset is needed without duplicating those.
|
||||
|
||||
## Decision
|
||||
|
||||
C10 adopts a hybrid toolset (Approach 3):
|
||||
|
||||
- **operations plugin** (`operations@knowledge-work-plugins`, Anthropic) —
|
||||
process documentation, change management, capacity planning.
|
||||
- **process-modeling skill** — self-authored vendored skill: BPMN 2.0, process
|
||||
maps, RACI, state-machines. Renders via the mermaid skill.
|
||||
- **process-analysis skill** — self-authored vendored skill: as-is discovery,
|
||||
bottlenecks, traceability, BP metrics.
|
||||
- **Five reuse cross-references** (mermaid, architecture-patterns, CCPM,
|
||||
product-management, writing-plans) surfaced via `NODE_SECTION_SECONDARY` — no
|
||||
re-tagging of their home sections.
|
||||
- **n8n-mcp** (workflow engine) is **deferred**: the portal stack has no n8n
|
||||
(the process engine is the Laravel queue); adopting n8n is an architecture
|
||||
decision with its own ADR. n8n-mcp is a reserved registry slot.
|
||||
- C10 tools are non-UI → the `business-process` off-phase category, outside the
|
||||
PSR_v1 UI-pool.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Positive: C10 populated; modeling + automation + analysis covered with zero
|
||||
duplication of C9/E2/A6 tools.
|
||||
- Risk: the two skills are self-authored — owned by the project, no upstream
|
||||
dependency (this is the mitigation, not a risk).
|
||||
- Deferred: no workflow engine until n8n is adopted as infrastructure — accepted,
|
||||
this is the decision.
|
||||
|
||||
## Enforcement
|
||||
|
||||
None — C10 tools are advisory; verified by use and code review.
|
||||
@@ -0,0 +1,59 @@
|
||||
# ADR-009: Discovery-interview tooling
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-05-18
|
||||
- **Deciders:** Дмитрий
|
||||
|
||||
## Context
|
||||
|
||||
Запрос вида «менеджеры жалуются на X» или «хочу, чтобы Y» — симптом, не задача.
|
||||
`brainstorming` уходит в проектирование решения, не удерживая разговор в проблемном
|
||||
поле; для расплывчатых проблемных запросов нет слоя, который вскрывает проблему до
|
||||
решения (JTBD / customer discovery). Аналогично у заказчика нет способа получить
|
||||
синтезированную ориентацию по состоянию проекта — CLAUDE.md и MEMORY грузятся
|
||||
пассивно, `audit-portal` даёт качественный вердикт, не ориентацию.
|
||||
|
||||
Параллельно 17.05.2026 раздел C10 карты ввёл скил `process-analysis`, чей режим 1 —
|
||||
«process discovery» (реконструкция as-is бизнес-процесса из кода). Это создаёт риск
|
||||
дубля (§5 п.6 CLAUDE.md) и коллизии триггеров по слову «discovery».
|
||||
|
||||
## Decision
|
||||
|
||||
Вводится проектный vendored-скил `discovery-interview` (`.claude/skills/`), два
|
||||
режима:
|
||||
|
||||
- **FEATURE** — интервью заказчика перед фичей: JTBD вскрывает проблему, отдаёт
|
||||
discovery-brief в `brainstorming`.
|
||||
- **SYSTEM** — интервью-ориентация по состоянию проекта: синтез по мета-слою (карта,
|
||||
CLAUDE.md, MEMORY, Открытые_вопросы, Tooling, git log).
|
||||
|
||||
Режим «интервью конечных пользователей» — **defer** post-Б-1 (нет живых
|
||||
пользователей; дублировал бы `design:user-research`).
|
||||
|
||||
Дубль с `process-analysis` исключён **разрезом по слою-источнику**: `process-analysis`
|
||||
работает с app-кодом (`routes/`, `app/Jobs`, `audit_*`); discovery-interview — с
|
||||
головой заказчика (FEATURE) и мета-слоем управления (SYSTEM). Триггер-коллизия по
|
||||
слову «discovery» снята лексическим разведением описаний + взаимными SKIP-блоками;
|
||||
проверено триггер-eval'ом 20/20 (`.claude/skills/discovery-interview/evals/`) —
|
||||
переименование скила (fallback) не понадобилось.
|
||||
|
||||
discovery-interview — *проектный* скил (как `audit-portal`, `regression`), не
|
||||
Superpowers-скил → регистрируется в Pravila §13.2; §12.2 (карта Superpowers-скилов)
|
||||
не трогается. Категория — новая 12-я off-phase подкатегория `discovery-tooling`,
|
||||
вне UI-пула PSR_v1; реестр Tooling — #55.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Положительно: расплывчатый проблемный запрос получает дисциплину discovery до
|
||||
проектирования; заказчик получает синтез-ориентацию on-demand; дубля с C10
|
||||
`process-analysis` нет (разрез по слою), коллизия триггеров снята (eval 20/20).
|
||||
- Риск: скил self-authored — принадлежит проекту, без upstream-зависимости (это
|
||||
смягчение, не риск).
|
||||
- Defer: режим «интервью конечных пользователей» — до появления живых пользователей
|
||||
(блокер Б-1).
|
||||
|
||||
## Enforcement
|
||||
|
||||
None — discovery-interview advisory; корректность срабатывания проверяется
|
||||
триггер-eval'ом (`evals/evals.json`) и code review. Границы с `process-analysis`,
|
||||
`brainstorming` и `audit-portal` зафиксированы в SKILL.md секции «Границы».
|
||||
@@ -0,0 +1,77 @@
|
||||
# ADR-010: Anthropic dev-tooling formalization
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-05-18
|
||||
- **Deciders:** Дмитрий
|
||||
|
||||
## Context
|
||||
|
||||
Пять Anthropic-плагинов включены в `~/.claude/settings.json` `enabledPlugins`
|
||||
user-level, но не имеют номера в реестре Tooling §3.3 / PSR_v1 R10.1:
|
||||
`skill-creator`, `plugin-dev`, `hookify`, `claude-code-setup`, `context7`. Все пять
|
||||
из marketplace `anthropics/claude-plugins-official`.
|
||||
|
||||
Это повторение L1-паттерна «плагин фактически включён без формализации в правилах»:
|
||||
зафиксирован 2026-05-10 (UPM #31 / 21st #32 — обнаружены только когда заказчик
|
||||
спросил про конфликты), повторился 2026-05-13 (Sentry #34 / Redis #35 —
|
||||
формализованы retrospective в v1.92). Любое использование неформализованного
|
||||
плагина — байпас PSR_v1 R0.2/R10. Карта `docs/automation-graph.html` имеет
|
||||
соответствующие 5 узлов (iter7 audit-actualization 16.05.2026), но без номеров и
|
||||
без edge к governing-правилу; узел `hookify_plugin` несёт незакрытый 🔴-конфликт
|
||||
`hookify_plugin ↔ hk_pre_claude` (плагин hookify может перезаписать существующие
|
||||
хуки в `settings.json`).
|
||||
|
||||
Аудит «мозга» (discovery-interview SYSTEM-режим, 2026-05-18) вскрыл долг; заказчик
|
||||
выбрал формализовать все 5, предварительно закрыв риски.
|
||||
|
||||
## Decision
|
||||
|
||||
Пять плагинов формализуются как позиции #56–#60 реестра Tooling в **двух новых
|
||||
off-phase подкатегориях** (семантика разная — одна категория запутала бы правила):
|
||||
|
||||
- **authoring-tooling** — создание Claude-артефактов: #56 skill-creator,
|
||||
#57 plugin-dev, #58 hookify.
|
||||
- **dev-support** — поддержка/документация Claude-разработки: #59 claude-code-setup,
|
||||
#60 context7.
|
||||
|
||||
Граничные правила (locked):
|
||||
|
||||
1. **hookify (#58)** — вызов только по явному `/hookify`, не проактивно. Перед
|
||||
генерацией хука — обязательный pre-check на коллизию с уже-зарегистрированными
|
||||
хуками в `~/.claude/settings.json`; перезапись 6-компонентной economy/
|
||||
skill-discipline архитектуры запрещена. Это закрывает 🔴-конфликт
|
||||
`hookify_plugin ↔ hk_pre_claude` (🔴 → 🟢).
|
||||
2. **skill-creator (#56) ↔ plugin-dev:skill-development (#57)** — skill-creator для
|
||||
standalone проектных скилов; plugin-dev:skill-development — для скилов внутри
|
||||
разрабатываемого marketplace-плагина. Вендоренные и self-authored скилы
|
||||
модифицируются прямым Edit, не через skill-creator.
|
||||
3. **context7 (#60) ↔ WebFetch ↔ WebSearch** — context7 первый выбор для
|
||||
документации известной библиотеки; WebFetch — конкретный URL; WebSearch — поиск
|
||||
без URL.
|
||||
4. **claude-code-setup (#59)** — read-only анализатор; рекомендации фильтруются
|
||||
через R0/R10.1, ничего не устанавливается без явного согласования.
|
||||
|
||||
Обе подкатегории — не UI → вне фильтров PSR_v1 R6.0/R6.1 и R14 pipeline; регулируются
|
||||
R10.1 Блок 1 как infrastructure (по образцу claude-md-management #33).
|
||||
|
||||
ADR обязателен (не retrospective-без-ADR как Sentry/Redis #34/#35): здесь 5 позиций
|
||||
и 2 новые подкатегории — decision-grain выше порога.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Положительно: L1-долг для 5 Anthropic-плагинов закрыт — использование больше не
|
||||
байпас R0.2/R10; 🔴-конфликт hookify закрыт правилом (🔴 → 🟢, классификация карты
|
||||
🔴1/⚫3/🟢7 → 🔴0/⚫3/🟢8); карта получает edge к governing-правилу для 5 узлов.
|
||||
- Отрицательно: реестр Tooling растёт 55 → 60; число off-phase подкатегорий 12 → 14.
|
||||
- Риск: эти 5 плагинов включены user-level — влияют на все проекты машины;
|
||||
формализация в Лидерра-нормативке другие проекты не ломает (они её не читают) —
|
||||
это не риск, а ограничение области действия.
|
||||
- Defer: изменение `enabledPlugins` (выключение плагинов) — отвергнуто заказчиком в
|
||||
пользу формализации; не выполняется.
|
||||
|
||||
## Enforcement
|
||||
|
||||
None — формализация декларативная (реестр + границы в R10.1 / Pravila §13.2).
|
||||
hookify pre-check на коллизию хуков — поведенческое правило, проверяется code review,
|
||||
не автоматическим гейтом. Границы #56–#60 зафиксированы в Tooling §4.31–§4.32 и
|
||||
PSR_v1 R10.1 Блок 1.
|
||||
+103
-27
@@ -228,10 +228,10 @@ function pos(ring, angleDeg) {
|
||||
|
||||
const NODES = [
|
||||
// ── ПРАВИЛА (4) ── центр + первое кольцо ───────
|
||||
{ id: 'pravila', label: 'Pravila v1.24', group: 'rules', size: 38, ring: 0, ...pos(0, 0) },
|
||||
{ id: 'claude_md', label: 'CLAUDE.md v2.10', group: 'rules', size: 34, ring: 1, ...pos(1, 30) },
|
||||
{ id: 'psr_v1', label: 'PSR_v1 v3.10', group: 'rules', size: 32, ring: 1, ...pos(1, 150) },
|
||||
{ id: 'tooling', label: 'Tooling v2.10', group: 'rules', size: 30, ring: 1, ...pos(1, 270) },
|
||||
{ id: 'pravila', label: 'Pravila v1.28', group: 'rules', size: 38, ring: 0, ...pos(0, 0) },
|
||||
{ id: 'claude_md', label: 'CLAUDE.md v2.15', group: 'rules', size: 34, ring: 1, ...pos(1, 30) },
|
||||
{ id: 'psr_v1', label: 'PSR_v1 v3.13', group: 'rules', size: 32, ring: 1, ...pos(1, 150) },
|
||||
{ id: 'tooling', label: 'Tooling v2.14', group: 'rules', size: 30, ring: 1, ...pos(1, 270) },
|
||||
|
||||
// ── ПЛАГИНЫ (13) ── второе кольцо ──────────────
|
||||
{ id: 'superpowers', label: 'Superpowers v5.1', group: 'plugins', size: 30, ring: 2, ...pos(2, 45) },
|
||||
@@ -286,6 +286,12 @@ const NODES = [
|
||||
{ id: 'claude_api', label: 'claude-api\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 345) },
|
||||
{ id: 'data_scientist', label: 'Data Scientist\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 355) },
|
||||
{ id: 'promptfoo', label: 'promptfoo', group: 'plugins', size: 20, ring: 2, ...pos(2, 365) },
|
||||
// C10 business-process (17.05.2026) — плагин и скилы раздела «Бизнес-процессы (общее)»
|
||||
{ id: 'ops_plugin', label: 'operations\n(plugin)', group: 'plugins', size: 20, ring: 2, ...pos(2, 385) },
|
||||
{ id: 'process_modeling', label: 'process-modeling\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 367) },
|
||||
{ id: 'process_analysis', label: 'process-analysis\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 377) },
|
||||
// discovery-tooling (18.05.2026) — self-authored скил интервью-discovery
|
||||
{ id: 'discovery_interview', label: 'discovery-interview\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 387) },
|
||||
|
||||
// ── ХУКИ (12) — S+infra + E (economy/skill) ───
|
||||
{ id: 'hk_session', label: 'SessionStart:\ncontext-inject', group: 'hooks', size: 24, ring: 4, ...pos(4, 100) },
|
||||
@@ -503,10 +509,8 @@ const EDGES = [
|
||||
E('mcp_boost', 'ag_rls', 'схема БД\nдля RLS-review'),
|
||||
|
||||
// ── АУДИТ-АКТУАЛИЗАЦИЯ 16.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'skill_creator', 'R10.1:\nвнешний инструмент'),
|
||||
E('psr_v1', 'claude_setup', 'R10.1:\nвнешний инструмент'),
|
||||
E('psr_v1', 'plugin_dev', 'R10.1:\nвнешний инструмент'),
|
||||
E('psr_v1', 'context7', 'R10.1:\nвнешний инструмент'),
|
||||
// 4 ребра psr_v1→skill_creator/claude_setup/plugin_dev/context7 — перенесены
|
||||
// в ADT-блок 18.05.2026 (точные категории authoring-tooling/dev-support, дедуп)
|
||||
E('plugin_dev', 'ag_pcreator', 'содержит\nагента'),
|
||||
E('plugin_dev', 'ag_pvalid', 'содержит\nагента'),
|
||||
E('plugin_dev', 'ag_skreview', 'содержит\nагента'),
|
||||
@@ -551,12 +555,30 @@ const EDGES = [
|
||||
E('tooling', 'claude_api', 'reuse — built-in skill\n(PSR_v1 R10.1 блок 2)'),
|
||||
E('tooling', 'data_scientist', '§4.24 #49 — реестр'),
|
||||
|
||||
// ── C10 BUSINESS-PROCESS 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'ops_plugin', 'R10.1 блок 1:\nbusiness-process'),
|
||||
E('tooling', 'process_modeling', '§4.27 #52 — реестр'),
|
||||
E('tooling', 'process_analysis', '§4.28 #53 — реестр'),
|
||||
|
||||
// ── DISCOVERY-TOOLING 18.05.2026 — связи узла discovery-interview ──
|
||||
E('tooling', 'discovery_interview', '§4.30 #55 — реестр'),
|
||||
E('psr_v1', 'discovery_interview', 'R10.1 блок 1 note:\ndiscovery-tooling'),
|
||||
E('discovery_interview', 'sk_brainstorm', 'хэндофф:\nFEATURE-brief'),
|
||||
E('discovery_interview', 'process_analysis', 'граница: слой-источник\n(ADR-009 DI2)'),
|
||||
|
||||
// ── ANTHROPIC DEV-TOOLING 18.05.2026 — связи 5 узлов ──
|
||||
E('psr_v1', 'skill_creator', 'R10.1 блок 1:\nauthoring-tooling'),
|
||||
E('psr_v1', 'plugin_dev', 'R10.1 блок 1:\nauthoring-tooling'),
|
||||
E('psr_v1', 'hookify_plugin', 'R10.1 блок 1:\nauthoring-tooling (HK1)'),
|
||||
E('psr_v1', 'claude_setup', 'R10.1 блок 1:\ndev-support'),
|
||||
E('psr_v1', 'context7', 'R10.1 блок 1:\ndev-support'),
|
||||
|
||||
// ══════════════════════════════════════════════════
|
||||
// КОНФЛИКТЫ — 3-color classification (iter2 §4)
|
||||
// 🔴 не закрыт правилом / ⚫ возник на практике / 🟢 закрыт правилом
|
||||
// ══════════════════════════════════════════════════
|
||||
CONFLICT('sk_rls', 'ag_rls', 'RLS: граница задана — скил по таблице, агент по diff/PR (spec 2026-05-16)', 'GREEN'),
|
||||
CONFLICT('hookify_plugin', 'hk_pre_claude', 'hookify может перезаписать существующий хук', 'RED'),
|
||||
CONFLICT('hookify_plugin', 'hk_pre_claude', 'Закрыто правилом HK1 (ADR-010, PSR_v1 R10.1 v3.13): hookify вызывается только по явному /hookify + обязательный pre-check на коллизию с зарегистрированными хуками; перезапись economy/skill-discipline архитектуры запрещена', 'GREEN'),
|
||||
CONFLICT('mcp_pw', 'sk_parallel', 'Browser is already in use (квирк #2)', 'BLACK'),
|
||||
CONFLICT('ag_pest', 'mcp_redis', 'Квирк 72 устранён 16.05.2026 (commit 0fa1a73 — array-стор в тестах): гонки в Redis при Pest --parallel больше нет', 'GREEN'),
|
||||
CONFLICT('psr_v1', 'claude_md', 'Закрыто §5п.10 CLAUDE.md + хук CLAUDE.md-warn', 'GREEN'),
|
||||
@@ -664,7 +686,7 @@ const NODE_DETAILS = {
|
||||
[{ name: 'CLAUDE.md', desc: 'CLAUDE.md §5 п.10 требует править только через скил claude-md-management, а PSR_v1 это ограничение не повторяет — риск прямых Edit', type: 'GREEN' }]
|
||||
),
|
||||
tooling: nd(
|
||||
'Реестр 70 позиций — 50 формализованных инструментов + 20 ruflo-плагинов; §4.10 — ruflo как advisory/automation-подсистема. Когда что использовать, команды установки, конфликты.',
|
||||
'Реестр 80 позиций — 60 формализованных инструментов + 20 ruflo-плагинов; §4.10 — ruflo как advisory/automation-подсистема. Когда что использовать, команды установки, конфликты.',
|
||||
'При выборе инструмента для фазы (нулевая документация / первая backend / вторая frontend / третья перед запуском в боевую среду), при добавлении нового инструмента, при обновлении версий.',
|
||||
'При прямом конфликте с CLAUDE.md побеждает CLAUDE.md (оперативная карта уровня 2a). Любая правка требует синхронизации с CLAUDE.md §3.',
|
||||
[
|
||||
@@ -720,12 +742,12 @@ const NODE_DETAILS = {
|
||||
hookify_plugin: nd(
|
||||
'Плагин создания хуков — анализирует разговоры и предлагает новые автоматизации в виде хуков.',
|
||||
'При запросе «давай повесим хук на это поведение» или после серии повторяющихся ошибок — анализ через агента conversation-analyzer.',
|
||||
'Правило PSR_v1 R10.1. Новые хуки могут конфликтовать с существующими (см. конфликты ниже) — обязательная проверка файла настроек до создания.',
|
||||
[{ name: 'PSR_v1', cond: 'R10.1: формализован' }],
|
||||
'PSR_v1 R10.1 блок 1 #58 (authoring-tooling). HK1 hard-rule: только по явному /hookify, не проактивно; перед генерацией хука — обязательный pre-check на коллизию с зарегистрированными хуками settings.json; перезапись 6-компонентной economy/skill-discipline архитектуры запрещена. ADR-010.',
|
||||
[{ name: 'PSR_v1', cond: 'R10.1 блок 1 #58: authoring-tooling, HK1 pre-check (ADR-010)' }],
|
||||
[{ name: 'агент hookify:conversation-analyzer', cond: 'запускает анализ разговоров' }],
|
||||
[{ name: 'агент hookify:conversation-analyzer', cond: 'плагин и агент работают в паре' }],
|
||||
[
|
||||
{ name: 'хук pre-claude-warn', desc: 'плагин hookify создаёт новые хуки PreToolUse на лету — может перезаписать или конкурировать с этим хуком', type: 'RED' }
|
||||
{ name: 'хук pre-claude-warn', desc: 'Закрыто правилом HK1 (ADR-010): hookify — только по явному /hookify, перед генерацией хука обязательный pre-check на коллизию с существующими хуками settings.json; перезапись 6-компонентной economy/skill-discipline архитектуры запрещена', type: 'GREEN' }
|
||||
]
|
||||
),
|
||||
|
||||
@@ -886,6 +908,42 @@ const NODE_DETAILS = {
|
||||
[]
|
||||
),
|
||||
|
||||
// ── C10 BUSINESS-PROCESS (17.05.2026) ────────────
|
||||
ops_plugin: nd(
|
||||
'Плагин Anthropic operations — 9 скилов бизнес-процессов: документирование, оптимизация, change-management, capacity-планирование.',
|
||||
'При работе с бизнес-процессом — документировать процесс, спланировать change-request, рассчитать capacity. Marketplace-плагин, 0 lifecycle-хуков.',
|
||||
'Правило PSR_v1 R10.1 блок 1 (business-process, off-phase). Marketplace `operations@knowledge-work-plugins` v1.2.0, тот же marketplace что #42/#46. Не UI → вне фильтров R6.0/R6.1/R14. Tooling §4.26 #51, CLAUDE.md §3.3 #51.',
|
||||
[{ name: 'PSR_v1', cond: 'R10.1 блок 1: business-process' }],
|
||||
[{ name: 'OPS1', cond: 'process-doc → Mermaid-исходник; рендер за mermaid' }, { name: 'OPS5', cond: 'generic ↔ self-authored stack-grounded скилы' }],
|
||||
[{ name: 'mermaid', cond: 'рендер диаграмм процесса' }]
|
||||
),
|
||||
process_modeling: nd(
|
||||
'Self-authored скил: моделирование to-be бизнес-процесса — BPMN 2.0, карты процессов, RACI, state-машины.',
|
||||
'При проектировании бизнес-процесса — выбрать артефакт (BPMN/swimlane/journey/RACI), построить модель. Рендер делегируется скилу mermaid.',
|
||||
'Свой project-скил в .claude/skills/process-modeling/ (не вендоренный → линтуется, конфликт-аудит LINT1). Не UI → вне фильтров R6.0/R6.1/R14. Tooling §4.27 #52, CLAUDE.md §3.3 #52.',
|
||||
[{ name: 'Tooling', cond: '§4.27 #52 — реестр' }],
|
||||
[{ name: 'BPMN1', cond: 'нотация process-modeling ≠ mermaid рендер' }],
|
||||
[{ name: 'mermaid', cond: 'рендер BPMN/диаграмм' }, { name: 'process-analysis', cond: 'as-is ↔ to-be пара' }]
|
||||
),
|
||||
process_analysis: nd(
|
||||
'Self-authored скил: анализ as-is бизнес-процесса — discovery из кода Laravel, узкие места, трассировка, метрики.',
|
||||
'При вскрытии существующего процесса — реконструировать из routes/jobs/audit-логов, найти узкие места, посчитать KPI.',
|
||||
'Свой project-скил в .claude/skills/process-analysis/ (не вендоренный → линтуется, LINT1). Не UI → вне фильтров R6.0/R6.1/R14. Tooling §4.28 #53, CLAUDE.md §3.3 #53.',
|
||||
[{ name: 'Tooling', cond: '§4.28 #53 — реестр' }],
|
||||
[{ name: 'PA1', cond: 'процессные узкие места ≠ runtime (perf-analyzer)' }],
|
||||
[{ name: 'process-modeling', cond: 'as-is ↔ to-be пара' }]
|
||||
),
|
||||
|
||||
// ── DISCOVERY-TOOLING (18.05.2026) ────────────
|
||||
discovery_interview: nd(
|
||||
'Self-authored скил: структурированное интервью-discovery до проектирования — FEATURE (JTBD-интервью заказчика) + SYSTEM (ориентация по мета-слою проекта).',
|
||||
'При расплывчатом проблемном запросе — провести JTBD-интервью, отдать discovery-brief в brainstorming; при «сориентируй по проекту» — синтез по карте/CLAUDE.md/MEMORY/Открытые_вопросы/Tooling.',
|
||||
'Свой project-скил в .claude/skills/discovery-interview/ (не вендоренный → линтуется, LINT1). Не UI → вне фильтров R6.0/R6.1/R14. Триггер-eval 20/20. Tooling §4.30 #55, CLAUDE.md §3.3 #55, ADR-009.',
|
||||
[{ name: 'PSR_v1', cond: 'R10.1 блок 1 note: discovery-tooling' }, { name: 'Tooling', cond: '§4.30 #55 — реестр' }],
|
||||
[{ name: 'DI2', cond: 'разрез по слою-источнику с process-analysis (ADR-009)' }],
|
||||
[{ name: 'process-analysis', cond: 'граница: app-код ↔ голова заказчика/мета-слой' }, { name: 'brainstorming', cond: 'хэндофф FEATURE-brief' }]
|
||||
),
|
||||
|
||||
// ── СКИЛЫ SUPERPOWERS ────────────────────────────
|
||||
sk_brainstorm: nd(
|
||||
'Продумывает задачу вместе с заказчиком, формулирует варианты A/B/C и согласует дизайн до написания кода.',
|
||||
@@ -1546,7 +1604,7 @@ const NODE_DETAILS = {
|
||||
'Плагин Anthropic для создания новых скилов — eval-driven подход: датасеты сценариев, train/test split, бенчмарк-цикл.',
|
||||
'При формализации повторяющегося процесса в скил с проверяемым выводом (генерация кода, преобразование файлов).',
|
||||
'Включён в настройках (~/.claude/settings.json). Для discipline-скилов (TDD-типа) предпочтительнее скил writing-skills плагина Superpowers — у них разные философии.',
|
||||
[{ name: 'PSR_v1', cond: 'R10.1: внешний плагин-инструмент' }],
|
||||
[{ name: 'PSR_v1', cond: 'R10.1 блок 1 #56: authoring-tooling (ADR-010)' }],
|
||||
[],
|
||||
[{ name: 'скил writing-skills', cond: 'обе создают скилы — skill-creator eval-driven, writing-skills через TDD' }]
|
||||
),
|
||||
@@ -1554,7 +1612,7 @@ const NODE_DETAILS = {
|
||||
'Плагин Anthropic — рекомендатель автоматизаций (claude-automation-recommender): анализирует репозиторий и советует, какие MCP-серверы, скилы, хуки, суб-агентов добавить.',
|
||||
'При настройке/ревизии автоматизации проекта — «чего не хватает в тулчейне».',
|
||||
'Включён в настройках (~/.claude/settings.json). Рекомендации — совещательные, решение за заказчиком.',
|
||||
[{ name: 'PSR_v1', cond: 'R10.1: внешний плагин-инструмент' }],
|
||||
[{ name: 'PSR_v1', cond: 'R10.1 блок 1 #59: dev-support — рекомендации фильтруются R0/R10.1 (CCS1, ADR-010)' }],
|
||||
[],
|
||||
[]
|
||||
),
|
||||
@@ -1562,7 +1620,7 @@ const NODE_DETAILS = {
|
||||
'Плагин Anthropic для разработки плагинов Claude Code — 7 скилов (структура плагина, разработка скилов / агентов / хуков / команд, интеграция MCP, настройки).',
|
||||
'При создании или правке плагина и его компонентов.',
|
||||
'Включён в настройках. Содержит 3 агента, уже представленные на карте (agent-creator / plugin-validator / skill-reviewer).',
|
||||
[{ name: 'PSR_v1', cond: 'R10.1: внешний плагин-инструмент' }],
|
||||
[{ name: 'PSR_v1', cond: 'R10.1 блок 1 #57: authoring-tooling — только для marketplace-плагинов, не для вендоренного/self-authored (PD1, ADR-010)' }],
|
||||
[
|
||||
{ name: 'агент plugin-dev:agent-creator', cond: 'входит в плагин' },
|
||||
{ name: 'агент plugin-dev:plugin-validator', cond: 'входит в плагин' },
|
||||
@@ -1574,7 +1632,7 @@ const NODE_DETAILS = {
|
||||
'Плагин Anthropic — актуальная документация библиотек / фреймворков / API через MCP-инструменты query-docs и resolve-library-id.',
|
||||
'При вопросах по библиотеке / фреймворку / SDK / CLI — синтаксис API, конфигурация, миграция версий. Предпочтительнее веб-поиска для документации библиотек.',
|
||||
'Включён в настройках. Не для рефакторинга / отладки бизнес-логики / ревью — только документация.',
|
||||
[{ name: 'PSR_v1', cond: 'R10.1: внешний плагин-инструмент' }],
|
||||
[{ name: 'PSR_v1', cond: 'R10.1 блок 1 #60: dev-support — первый выбор для документации библиотек; WebFetch/WebSearch как fallback (CTX1, ADR-010)' }],
|
||||
[],
|
||||
[]
|
||||
),
|
||||
@@ -1829,17 +1887,17 @@ const META_WINDOW = '09–16.05.2026'; // окно подсчёта испо
|
||||
// usesSrc: 'скил' | 'агент' | 'MCP' | 'хук' | 'memory-чтение' | 'коммиты' | 'инспекция' | '—'
|
||||
const NODE_META = {
|
||||
// ── ПРАВИЛА (4) — узлы-правила, напрямую не вызываются ──
|
||||
pravila: { since: '06.05.2026', changed: '17.05.2026', uses: null, usesSrc: '—' },
|
||||
claude_md: { since: '06.05.2026', changed: '17.05.2026', uses: null, usesSrc: '—' },
|
||||
psr_v1: { since: '09.05.2026', changed: '17.05.2026', uses: null, usesSrc: '—' },
|
||||
tooling: { since: '06.05.2026', changed: '17.05.2026', uses: null, usesSrc: '—' },
|
||||
pravila: { since: '06.05.2026', changed: '18.05.2026', uses: null, usesSrc: '—' },
|
||||
claude_md: { since: '06.05.2026', changed: '18.05.2026', uses: null, usesSrc: '—' },
|
||||
psr_v1: { since: '09.05.2026', changed: '18.05.2026', uses: null, usesSrc: '—' },
|
||||
tooling: { since: '06.05.2026', changed: '18.05.2026', uses: null, usesSrc: '—' },
|
||||
|
||||
// ── ПЛАГИНЫ (5) ──
|
||||
superpowers: { since: '09.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
fd_plugin: { since: '10.05.2026', changed: '—', uses: 1, usesSrc: 'скил' },
|
||||
upm: { since: '10.05.2026', changed: '—', uses: 0, usesSrc: 'скил' },
|
||||
claude_md_mgmt: { since: '10.05.2026', changed: '—', uses: 15, usesSrc: 'скил' },
|
||||
hookify_plugin: { since: '—', changed: '—', uses: null, usesSrc: '—' },
|
||||
hookify_plugin: { since: '—', changed: '18.05.2026', uses: null, usesSrc: '—' },
|
||||
|
||||
// ── СКИЛЫ SUPERPOWERS (14) — связка подключена 09.05.2026 ──
|
||||
sk_brainstorm: { since: '09.05.2026', changed: '—', uses: 44, usesSrc: 'скил' },
|
||||
@@ -1938,10 +1996,10 @@ const NODE_META = {
|
||||
|
||||
// ── АУДИТ-АКТУАЛИЗАЦИЯ 16.05.2026 — узлы добавлены по полному аудиту карты ──
|
||||
// uses новых узлов по транскриптам не измерялись (null = нет данных).
|
||||
skill_creator: { since: '11.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
claude_setup: { since: '11.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
plugin_dev: { since: '—', changed: '—', uses: null, usesSrc: '—' },
|
||||
context7: { since: '—', changed: '—', uses: null, usesSrc: '—' },
|
||||
skill_creator: { since: '11.05.2026', changed: '18.05.2026', uses: null, usesSrc: '—' },
|
||||
claude_setup: { since: '11.05.2026', changed: '18.05.2026', uses: null, usesSrc: '—' },
|
||||
plugin_dev: { since: '—', changed: '18.05.2026', uses: null, usesSrc: '—' },
|
||||
context7: { since: '—', changed: '18.05.2026', uses: null, usesSrc: '—' },
|
||||
hk_self_check: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
hk_skill_marker: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
hk_skill_check: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
@@ -1988,6 +2046,14 @@ const NODE_META = {
|
||||
claude_api: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'скил' },
|
||||
promptfoo: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'CLI' },
|
||||
data_scientist: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'скил' },
|
||||
|
||||
// ── C10 BUSINESS-PROCESS (17.05.2026) ──
|
||||
ops_plugin: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'плагин' },
|
||||
process_modeling: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'скил' },
|
||||
process_analysis: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'скил' },
|
||||
|
||||
// ── DISCOVERY-TOOLING (18.05.2026) ──
|
||||
discovery_interview: { since: '18.05.2026', changed: '—', uses: null, usesSrc: 'скил' },
|
||||
};
|
||||
|
||||
// Явные парные дубли (Фича 3) — попадают в кнопку «⧉ Дубли».
|
||||
@@ -2070,7 +2136,7 @@ const SECTIONS = [
|
||||
{ id: 'E7', bucket: 'E', label: 'Исследования' },
|
||||
{ id: 'E8', bucket: 'E', label: 'Самообучение Claude' },
|
||||
];
|
||||
// Узел -> раздел. Покрывает все 121 узлов карты.
|
||||
// Узел -> раздел. Покрывает все 125 узлов карты.
|
||||
const NODE_SECTION = {
|
||||
// правила (4)
|
||||
pravila: 'E1', claude_md: 'E1', psr_v1: 'E1', tooling: 'E1',
|
||||
@@ -2124,6 +2190,10 @@ const NODE_SECTION = {
|
||||
ag_apidocs: 'A3', mcp_openapi: 'A3',
|
||||
// A11 ml-ai-tooling 17.05.2026 — раздел «ML / AI-разработка» наполнен
|
||||
claude_api: 'A11', promptfoo: 'A11', data_scientist: 'A11',
|
||||
// C10 business-process 17.05.2026 — раздел «Бизнес-процессы (общее)» наполнен
|
||||
ops_plugin: 'C10', process_modeling: 'C10', process_analysis: 'C10',
|
||||
// discovery-interview 18.05.2026 — раздел E5 «Стратегия и принятие решений» (рядом с brainstorming)
|
||||
discovery_interview: 'E5',
|
||||
};
|
||||
// Вторичная классификация: узел первично в NODE_SECTION, дополнительно — в этих
|
||||
// разделах (кросс-реф). Введено A3-интеграцией 17.05.2026 — раздел A3 наполняется
|
||||
@@ -2134,6 +2204,12 @@ const NODE_SECTION_SECONDARY = {
|
||||
ag_pest: ['A3'],
|
||||
mcp_semgrep: ['A3'],
|
||||
mcp_sentry: ['A3'],
|
||||
// C10 business-process 17.05.2026 — кросс-реф reuse-инструментов раздела «Бизнес-процессы»
|
||||
mermaid_skill: ['C10'],
|
||||
arch_patterns: ['C10'],
|
||||
ccpm: ['C10'],
|
||||
product_mgmt: ['C10'],
|
||||
sk_wplans: ['C10'],
|
||||
};
|
||||
// Производные индексы для рендера панели и Паспорта.
|
||||
const SECTION_BY_ID = new Map(SECTIONS.map(s => [s.id, s]));
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# docs/discovery — артефакты discovery interview
|
||||
|
||||
Home раздела `discovery-tooling` карты. Каталог хранит артефакты скила
|
||||
`discovery-interview` (`.claude/skills/discovery-interview/`).
|
||||
|
||||
## Что здесь лежит
|
||||
|
||||
- **SYSTEM-snapshot'ы** — `YYYY-MM-DD-<тема>.md`, результаты режима SYSTEM
|
||||
(синтез-ориентация по состоянию проекта). Шаблон — `templates/system-snapshot.md`.
|
||||
|
||||
## Чего здесь НЕ лежит
|
||||
|
||||
- **FEATURE-brief** (режим FEATURE) отдельным файлом не сохраняется — он вливается
|
||||
проблемной секцией в спеку `brainstorming` (`docs/superpowers/specs/`). Шаблон
|
||||
`templates/discovery-brief.md` задаёт структуру этой секции.
|
||||
|
||||
## Связано
|
||||
|
||||
- Скил — `../../.claude/skills/discovery-interview/SKILL.md`
|
||||
- Дизайн — `../superpowers/specs/2026-05-18-discovery-interview-design.md`
|
||||
- ADR — `../adr/ADR-009-discovery-interview-tooling.md`
|
||||
@@ -0,0 +1,30 @@
|
||||
# Discovery-brief — шаблон (режим FEATURE)
|
||||
|
||||
Структура проблемной секции, которую `discovery-interview` FEATURE отдаёт в
|
||||
`brainstorming`. Заполняется по итогам интервью. Отдельным файлом не коммитится —
|
||||
вливается в спеку brainstorming как готовая проблемная секция.
|
||||
|
||||
## Проблема
|
||||
|
||||
<Что именно болит — одно-два предложения, формулировкой заказчика.>
|
||||
|
||||
## JTBD
|
||||
|
||||
<Какую работу заказчик «нанимает» решение сделать. Формат: «Когда <ситуация>, я хочу
|
||||
<мотив>, чтобы <результат>».>
|
||||
|
||||
## Текущий обходной путь
|
||||
|
||||
<Как заказчик решает это сейчас — вручную или другим инструментом.>
|
||||
|
||||
## Цена боли
|
||||
|
||||
<Время / деньги / частота. Сколько стоит НЕ решать проблему.>
|
||||
|
||||
## Сигнал успеха
|
||||
|
||||
<Как поймём, что проблема закрыта — наблюдаемый признак.>
|
||||
|
||||
## Ограничения
|
||||
|
||||
<Что нельзя ломать или менять; сроки; технические и процессные рамки.>
|
||||
@@ -0,0 +1,26 @@
|
||||
# System-snapshot — шаблон (режим SYSTEM)
|
||||
|
||||
Результат режима SYSTEM скила `discovery-interview` — синтез-ориентация по состоянию
|
||||
проекта. Сохраняется как `docs/discovery/YYYY-MM-DD-<тема>.md`.
|
||||
|
||||
## Запрос ориентации
|
||||
|
||||
<Что просили сориентировать. Scope: весь проект / конкретный раздел / тулчейн /
|
||||
открытые вопросы.>
|
||||
|
||||
## Состояние
|
||||
|
||||
<Синтез: где проект сейчас по запрошенному срезу.>
|
||||
|
||||
## Что открыто
|
||||
|
||||
<Незакрытые вопросы, блокеры, недоделанное в рамках scope.>
|
||||
|
||||
## Источники
|
||||
|
||||
<Пины на мета-слой: карта, CLAUDE.md, MEMORY, Открытые_вопросы, Tooling, git log —
|
||||
конкретные файлы, секции, коммиты.>
|
||||
|
||||
## Следующий шаг
|
||||
|
||||
<Что логично сделать дальше, если применимо.>
|
||||
@@ -0,0 +1,43 @@
|
||||
# docs/process — business-process playbook (map section C10)
|
||||
|
||||
Home of the `C10 «Бизнес-процессы (общее)»` section. Defines the tooling Лидерра
|
||||
uses to model, automate and analyze business processes.
|
||||
|
||||
## Toolset
|
||||
|
||||
| Tool | Role | Status |
|
||||
|---|---|---|
|
||||
| **operations plugin** | 9 skills — `process-doc`, `process-optimization`, `change-request`, `capacity-plan`, `compliance-tracking`, `risk-assessment`, `runbook`, `status-report`, `vendor-review`. | installed — `operations@knowledge-work-plugins` v1.2.0 |
|
||||
| **process-modeling skill** | Model a to-be process — BPMN 2.0, process maps, customer-journey / value-stream, RACI, state-machines. | installed — self-authored skill |
|
||||
| **process-analysis skill** | Analyze an as-is process — discovery from code/audit-logs, bottlenecks, traceability, BP metrics. | installed — self-authored skill |
|
||||
| **n8n-mcp** | Workflow-automation engine. | **deferred** — see below |
|
||||
| mermaid · architecture-patterns · CCPM · product-management · writing-plans | Reuse — diagram render · DDD process boundaries · requirements→process traceability · process specs/metrics · process decomposition. | reuse — cross-referenced into C10 |
|
||||
|
||||
## Boundaries (which tool for which job)
|
||||
|
||||
- **Documenting a process / a change to it / capacity / generic optimization** →
|
||||
the **operations** plugin skills (`process-doc`, `change-request`,
|
||||
`capacity-plan`, `process-optimization`, …).
|
||||
- **Designing a to-be process model** (formal BPMN 2.0, swimlane, state-machine) →
|
||||
the **process-modeling** skill — which delegates *rendering* to the **mermaid**
|
||||
skill (operations and process-modeling emit Mermaid source; mermaid renders —
|
||||
OPS1/BPMN1).
|
||||
- **Reverse-engineering an as-is process from the Лидерра codebase** → the
|
||||
**process-analysis** skill (discovery from `routes/`, `app/Jobs`, `audit_*`
|
||||
tables — what the generic `process-optimization` skill cannot do; OPS5).
|
||||
- The operations `capacity-plan` skill plans **cross-functional business-process**
|
||||
capacity; dev-team sprint capacity is product-management `/sprint-planning` (OPS2).
|
||||
- The operations `change-request` skill = a **business-process** change; an
|
||||
architecture decision is an ADR (adr-kit); a normative-doc edit is
|
||||
claude-md-management (OPS3).
|
||||
- process-analysis finds **business-process** bottlenecks; **code/runtime**
|
||||
performance is `perf-analyzer` / `analysis:bottleneck-detect` (PA1).
|
||||
|
||||
## n8n-mcp — why deferred
|
||||
|
||||
n8n-mcp builds workflows for the n8n platform. The Лидерра stack has **no n8n** —
|
||||
the process engine is the Laravel queue (Redis) + events/jobs. Adopting n8n as
|
||||
portal infrastructure is an architecture decision, not a tooling pick. n8n-mcp is a
|
||||
**reserved slot**: registered in the Tooling registry as *pending*, installed by a
|
||||
separate severable task when (and if) n8n is adopted via its own ADR. See the C10
|
||||
plan's "Deferred Task".
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user