# MVP Spec 09 — Lead Management (Seller Side)

## Паспорт документа

- Статус документа: working spec
- Актуально на: 28 марта 2026 года
- Владелец: backend/platform-команда
- Пересмотр: перед реализацией, при изменении domain rules или при смене source-of-truth по контракту
- Область применения: детальные feature-спеки для product backlog и реализации MVP/v1 направлений
- Связанные документы:
  - [Product roadmap и delivery checklist](../product-roadmap.md)
  - [Roadmap](../../project/roadmap.md)
  - [Карта API-маршрутов](../../architecture/api-routes.md)

> Version: MVP · Priority: P0 · Phase: A (Supply)
> Status: Draft v1

---

## 1. Контекст и цель

Лиды — это главный продукт, который получают продавцы от платформы. Покупатель оставляет заявку на курс (trial или buy), заявка поступает в кабинет продавца и превращается в сделку.

**Цель модуля:** дать продавцу полный контроль над входящими лидами — видеть их в реальном времени, управлять статусами по согласованной воронке, фильтровать и детализировать каждую заявку. Это ключевой инструмент для превращения лидов в выручку.

**Монетизация (CPL):** продавец платит $30 за каждый доставленный лид (статус `new`). Учёт выставляется еженедельно/ежемесячно через reconciliation (Spec 15). Данный спек описывает только управление лидами, не биллинг.

**Что не входит в этот модуль:**
- Форма оставления заявки покупателем → Spec 06 (Buyer Flow)
- Создание и управление курсами/айтемами → Spec 02
- Уведомления при поступлении лида → Spec 10
- Биллинг и reconciliation продавца → Spec 15
- Публичная страница курса → Spec 08

---

## 2. Роли пользователей

| Роль | Действие в этом модуле |
|------|----------------------|
| **Seller (Owner)** | Просматривает входящие лиды, меняет статусы, фильтрует, смотрит детали |
| **Seller Staff** | Те же права что у Seller — просмотр и управление лидами своей организации (Spec 03) |
| **Admin** | Видит все лиды в системе; может просматривать детали, не может менять статусы от имени продавца |
| **Гость / Buyer** | Не имеет доступа к этому разделу |

---

## 3. Use Cases

---

### UC-01: Продавец открывает список лидов

**Актор:** Seller (Owner) или Seller Staff
**Предусловие:** Пользователь авторизован как продавец, находится в личном кабинете
**Триггер:** Продавец хочет посмотреть входящие заявки

**Полный поток:**

```
[Точка входа]
→ Продавец авторизован, находится на /seller (дашборд)
→ Видит в левой навигации раздел "Заявки" (или "Лиды") с бейджем числа новых (синий кружок)
→ Нажимает "Заявки"
→ Открывается /seller/leads

───────────────────────────────────────────────────────
ШАГ 1 — Загрузка страницы списка лидов
───────────────────────────────────────────────────────
→ Страница загружает лиды: GET /api/v1/seller/leads
→ Пока грузится: скелетон-таблица (3–5 строк серых placeholder)
→ После загрузки: таблица с лидами, отсортированными по created_at DESC

───────────────────────────────────────────────────────
ШАГ 2 — Состояние таблицы при наличии лидов
───────────────────────────────────────────────────────
→ Таблица/карточный вид с колонками:
    [ ] | Имя покупателя | Телефон | Курс (item_name) | Тип | Статус | Дата | Комментарий | Действия

→ Каждая строка:
    - Имя: lead_name
    - Телефон: lead_phone (показывается полностью, кликабельный tel: ссылкой на мобайле)
    - Курс: item_name + ссылка на /seller/items/:item_id
    - Тип: бейдж "Пробное" (если trial) или "Запись" (если buy)
    - Статус: цветной бейдж (см. ниже)
    - Дата: created_at в формате "25 мар 2026, 14:35"
    - Комментарий: первые 60 символов + "..." если длиннее
    - Действия: кнопка быстрого действия + иконка раскрытия деталей

→ Бейджи статусов:
    new           = синий фон      "Новая"
    contacted     = жёлтый фон     "Контакт"
    enrolled      = зелёный фон    "Записан"
    attended      = тёмно-зелёный  "Посетил"
    no_show       = красный фон    "Не пришёл"
    purchased     = жирный зелёный "Оплатил"
    not_purchased = серый фон      "Не купил"

→ Кнопка быстрого действия для статуса new:
    [Контакт] — нажатие немедленно меняет статус на contacted (UC-02)

→ Для остальных статусов: кнопка-дропдаун [▾ Статус]
    → Выпадают только допустимые следующие переходы (UC-03)

→ Нажатие на строку (кроме кнопки действия) → разворачивает детальный вид (UC-05)

───────────────────────────────────────────────────────
ШАГ 3 — Пустое состояние (нет лидов)
───────────────────────────────────────────────────────
→ Иконка: конверт с вопросительным знаком
→ Заголовок: "Пока нет заявок"
→ Текст: "Когда покупатели оставят заявки на ваши курсы, они появятся здесь."
→ Кнопка: "Посмотреть мои курсы" → /seller/items
```

---

### UC-01 — Альтернативные потоки и обработка ошибок

**1a. Сеть недоступна или сервер вернул 5xx при загрузке списка:**
```
UI-реакция:
→ Вместо скелетона: иконка "нет сети" + текст "Не удалось загрузить заявки."
→ Кнопка "Попробовать снова" — повторяет запрос
→ Если ошибка повторяется: "Проблема на нашей стороне. Попробуйте через несколько минут или напишите в поддержку."
→ Ссылка "Написать в поддержку" → Telegram/email поддержки
```

**1b. Продавец не имеет ни одного активного айтема:**
```
→ Лиды могут появиться только на опубликованные айтемы
→ Если у продавца нет айтемов: отображается баннер под заголовком страницы
→ Баннер (жёлтый): "У вас ещё нет опубликованных курсов. Добавьте курс чтобы начать получать заявки."
→ Кнопка: "Добавить курс" → /seller/items/new
→ Список лидов при этом пуст, показывается пустое состояние
```

**1c. Токен истёк при открытии страницы:**
```
→ API возвращает 401
→ Фронтенд пытается обновить токен через refresh endpoint
→ Если refresh успешен: прозрачно повторяет запрос и показывает страницу
→ Если refresh провалился: редирект на /login с параметром ?redirect=/seller/leads
→ После логина: возврат на /seller/leads
```

---

### UC-02: Продавец меняет статус лида (new → contacted)

**Актор:** Seller (Owner) или Seller Staff
**Предусловие:** В списке есть лид со статусом `new`
**Триггер:** Продавец позвонил покупателю и хочет отметить контакт

**Полный поток:**

```
[Точка входа]
→ Продавец находится на /seller/leads
→ Видит строку лида со статусом "Новая" (синий бейдж)
→ Справа в строке: кнопка [Контакт]

───────────────────────────────────────────────────────
ШАГ 1 — Быстрый контакт
───────────────────────────────────────────────────────
→ Продавец нажимает кнопку [Контакт]
→ Кнопка переходит в состояние loading (spinner, disabled)
→ Отправляется: PATCH /api/v1/seller/leads/:lead_id/status { status: 'contacted' }
→ Статус обновляется в БД

───────────────────────────────────────────────────────
ШАГ 2 — Отображение результата
───────────────────────────────────────────────────────
→ Бейдж в строке меняется: "Новая" (синий) → "Контакт" (жёлтый) — анимация смены
→ Кнопка [Контакт] заменяется на дропдаун [▾ Статус] с дальнейшими переходами
→ Toast (зелёный): "Статус изменён на «Контакт»"
→ Бейдж "новых" в навигации уменьшается на 1
```

---

### UC-02 — Альтернативные потоки

**2a. Сеть пропала во время изменения статуса:**
```
UI-реакция:
→ Кнопка разблокируется после таймаута 10 секунд
→ Toast (красный): "Не удалось изменить статус. Проверьте соединение и попробуйте снова."
→ Статус строки остаётся прежним (оптимистичный UI не применяем для статусов — только после подтверждения сервера)
```

**2b. Сервер вернул 409 (статус уже изменён другим сотрудником):**
```
UI-реакция:
→ Toast (жёлтый): "Статус уже был изменён другим сотрудником. Обновляем данные..."
→ Строка лида автоматически обновляется до актуального статуса
```

**2c. Лид не найден (404):**
```
UI-реакция:
→ Toast (красный): "Заявка не найдена. Возможно, она была удалена."
→ Строка исчезает из списка
```

---

### UC-03: Продавец проводит лид через полный статусный конвейер

**Актор:** Seller (Owner) или Seller Staff
**Предусловие:** Лид существует в любом промежуточном статусе
**Триггер:** Продавец хочет зафиксировать прогресс по заявке

**Полный поток:**

```
[Точка входа]
→ Продавец находится на /seller/leads
→ Нажимает дропдаун [▾ Статус] на строке лида

───────────────────────────────────────────────────────
Статусная воронка и допустимые переходы
───────────────────────────────────────────────────────

new
  → contacted (только вперёд, кнопка [Контакт])

contacted
  → enrolled    "Записан на занятие"
  → no_show     "Не пришёл" (если договорились, но не пришёл без записи)

enrolled
  → attended    "Пришёл на занятие"
  → no_show     "Не пришёл на занятие"

attended
  → purchased      "Оплатил / Купил курс"
  → not_purchased  "Не купил после занятия"

no_show
  → contacted   "Перезвонили, договорились снова"
  → not_purchased  "Не дозвонились, закрываем"

purchased     — финальный статус, изменение не доступно
not_purchased — финальный статус, изменение не доступно

───────────────────────────────────────────────────────
ШАГ 1 — Открытие дропдауна
───────────────────────────────────────────────────────
→ Нажатие на [▾ Статус] открывает выпадающий список
→ В списке только допустимые переходы (недопустимые не показываются)
→ Каждый пункт: цветной кружок + название статуса

───────────────────────────────────────────────────────
ШАГ 2 — Выбор нового статуса
───────────────────────────────────────────────────────
→ Продавец выбирает статус
→ Для переходов в финальные статусы (purchased / not_purchased) — диалог подтверждения:
    "Вы отмечаете заявку как «{статус}». Это завершит работу с ней."
    [Отмена] [Подтвердить]
→ Для остальных переходов — немедленное применение без диалога

───────────────────────────────────────────────────────
ШАГ 3 — Применение статуса
───────────────────────────────────────────────────────
→ PATCH /api/v1/seller/leads/:lead_id/status { status: 'новый_статус' }
→ Бейдж строки обновляется
→ Toast: "Статус изменён на «{название}»"
→ Если финальный статус: строка визуально приглушается (opacity 0.7) для отличия от активных
```

---

### UC-03 — Альтернативные потоки

**3a. Продавец пытается изменить финальный статус:**
```
UI-реакция:
→ Дропдаун [▾ Статус] не отображается для финальных статусов purchased / not_purchased
→ Вместо него: статичный бейдж без интерактивности
→ При наведении tooltip: "Это завершённая заявка. Статус нельзя изменить."
```

**3b. Сервер вернул 400 INVALID_STATUS_TRANSITION:**
```
UI-реакция:
→ Toast (красный): "Недопустимый переход статуса. Обновите страницу."
→ Строка обновляется до актуального статуса из ответа сервера
```

---

### UC-04: Продавец фильтрует лиды по статусу

**Актор:** Seller (Owner) или Seller Staff
**Предусловие:** Продавец находится на /seller/leads
**Триггер:** Продавец хочет сфокусироваться на конкретном этапе воронки

**Полный поток:**

```
[Точка входа]
→ Продавец на /seller/leads, видит список всех лидов
→ Над таблицей: панель фильтров

───────────────────────────────────────────────────────
ШАГ 1 — Панель фильтров
───────────────────────────────────────────────────────
→ Фильтры отображаются в виде горизонтальных таб-чипов:
    [Все] [Новые (N)] [Контакт (N)] [Записан (N)] [Посетил (N)]
          [Не пришёл (N)] [Оплатил (N)] [Не купил (N)]
→ В скобках — количество лидов в каждом статусе
→ По умолчанию активен чип "Все"

───────────────────────────────────────────────────────
ШАГ 2 — Выбор фильтра
───────────────────────────────────────────────────────
→ Продавец нажимает чип "Новые"
→ Чип подсвечивается (активный)
→ URL обновляется: /seller/leads?status=new
→ Таблица перезагружается: GET /api/v1/seller/leads?status=new
→ Показываются только лиды с status = new

───────────────────────────────────────────────────────
ШАГ 3 — Комбинированная фильтрация
───────────────────────────────────────────────────────
→ Дополнительные фильтры (раскрываемая панель "Ещё фильтры"):
    - По курсу: дропдаун со списком айтемов продавца
    - По типу: Все / Пробное / Запись
    - По дате: date-picker "от" / "до"
→ Фильтры применяются немедленно при изменении
→ Активные фильтры показываются как теги над таблицей с кнопкой [×] для сброса каждого

───────────────────────────────────────────────────────
ШАГ 4 — Пустой результат фильтра
───────────────────────────────────────────────────────
→ Если фильтр не находит лидов:
    Иконка + текст: "По выбранным фильтрам заявок нет."
    Кнопка: "Сбросить фильтры"
```

---

### UC-04 — Альтернативные потоки

**4a. Сеть пропала при смене фильтра:**
```
UI-реакция:
→ Предыдущий список остаётся на экране (не очищается)
→ Toast (красный): "Не удалось применить фильтр. Проверьте соединение."
→ Чип возвращается в предыдущее выбранное состояние
```

---

### UC-05: Продавец просматривает детали лида

**Актор:** Seller (Owner) или Seller Staff
**Предусловие:** Продавец на /seller/leads
**Триггер:** Продавец хочет видеть полные контактные данные и детали заявки

**Полный поток:**

```
[Точка входа]
→ Продавец на /seller/leads, видит строку лида
→ Нажимает на строку (не на кнопку действия)

───────────────────────────────────────────────────────
ШАГ 1 — Разворачивание деталей
───────────────────────────────────────────────────────
→ Строка раскрывается вниз (accordion) ИЛИ открывается сайд-панель справа
→ Блок деталей содержит:

    ┌─────────────────────────────────────────────────────┐
    │  ЗАЯВКА #lead_id (сокращённый)        [×] Закрыть  │
    │─────────────────────────────────────────────────────│
    │  Покупатель:  {lead_name}                           │
    │  Телефон:     {lead_phone}   [📋 Скопировать]       │
    │  Email:       {lead_email или "—"}                  │
    │                                                     │
    │  Курс:        {item_name}    [→ Перейти к курсу]    │
    │  Тип заявки:  Пробное занятие / Запись              │
    │  Акция:       {special_offer_name или "—"}          │
    │  Дата заявки: 25 марта 2026, 14:35                  │
    │                                                     │
    │  Комментарий: {lead_comment или "—"}                │
    │                                                     │
    │  Текущий статус: [Контакт ▾]  (меняется здесь тоже)│
    └─────────────────────────────────────────────────────┘

───────────────────────────────────────────────────────
ШАГ 2 — Копирование телефона
───────────────────────────────────────────────────────
→ Продавец нажимает [📋 Скопировать] рядом с телефоном
→ Номер копируется в буфер обмена
→ Иконка меняется на ✓ на 2 секунды
→ Tooltip: "Скопировано!"

───────────────────────────────────────────────────────
ШАГ 3 — Изменение статуса из детального вида
───────────────────────────────────────────────────────
→ Работает идентично UC-02 и UC-03
→ После смены статуса: бейдж в деталях и в строке таблицы обновляются синхронно
```

---

### UC-05 — Альтернативные потоки

**5a. Сеть пропала при открытии деталей (если детали загружаются отдельным запросом):**
```
UI-реакция:
→ Внутри блока деталей: "Не удалось загрузить данные заявки."
→ Кнопка "Попробовать снова"
```

**5b. Лид удалён/не найден (маловероятно при правильной архитектуре):**
```
UI-реакция:
→ Toast: "Заявка не найдена."
→ Строка удаляется из списка
```

---

### UC-06: Экспорт лидов

**Актор:** Seller (Owner)
**Статус:** TBD — запланировано в v1.0

```
Функциональность экспорта лидов в CSV/XLSX не входит в MVP.
Упоминается здесь как зарезервированная точка расширения.

Планируемое поведение (v1.0):
→ Кнопка [Экспорт] над таблицей
→ Экспортирует текущий отфильтрованный список
→ Поля: Имя, Телефон, Email, Курс, Тип, Статус, Дата, Комментарий
→ Форматы: CSV (UTF-8 with BOM для Excel) и XLSX
→ Лимит: не более 10 000 строк за раз
```

---

### UC-07: Администратор просматривает все лиды в системе

**Актор:** Admin
**Предусловие:** Admin авторизован на /admin
**Триггер:** Admin хочет проверить лиды для модерации, аналитики или поддержки

**Полный поток:**

```
[Точка входа]
→ Admin авторизован, открывает /admin/leads

───────────────────────────────────────────────────────
ШАГ 1 — Таблица всех лидов
───────────────────────────────────────────────────────
→ GET /api/admin/leads
→ Таблица с дополнительными колонками:
    Покупатель | Телефон | Продавец (seller_name) | Курс | Тип | Статус | Дата | Акция

→ Поиск по телефону и имени покупателя
→ Фильтры: по статусу, по продавцу, по дате, по типу

───────────────────────────────────────────────────────
ШАГ 2 — Детальный просмотр
───────────────────────────────────────────────────────
→ Клик на строку → открывается детальный вид (идентичен UC-05)
→ Дополнительно для Admin: отображается seller_id и buyer_account_id

───────────────────────────────────────────────────────
Ограничения Admin
───────────────────────────────────────────────────────
→ Admin НЕ может менять статус лида от имени продавца
→ Admin видит данные только в режиме read-only для лидов
→ Если нужно — может обратиться к продавцу через другие каналы
```

---

### UC-07 — Альтернативные потоки

**7a. Admin пытается изменить статус:**
```
→ Кнопки изменения статуса не отображаются в Admin-виде
→ Попытка через прямой API: 403 { error: 'FORBIDDEN', message: 'Администратор не может изменять статусы лидов.' }
```

---

## 4. Бизнес-правила и валидации

### Таблица правил смены статусов

| Текущий статус | Допустимые переходы | Недопустимые переходы |
|---------------|--------------------|-----------------------|
| `new` | `contacted` | Все остальные |
| `contacted` | `enrolled`, `no_show` | `new`, финальные напрямую |
| `enrolled` | `attended`, `no_show` | `new`, `contacted`, финальные напрямую |
| `attended` | `purchased`, `not_purchased` | Все кроме финальных |
| `no_show` | `contacted`, `not_purchased` | `new`, `enrolled`, `attended`, `purchased` |
| `purchased` | — (финальный) | Все |
| `not_purchased` | — (финальный) | Все |

### Таблица бизнес-правил

| Правило | Описание | Обработка нарушения |
|---------|---------|-------------------|
| Статус только вперёд (кроме no_show→contacted) | Нельзя откатить статус произвольно | 400 INVALID_STATUS_TRANSITION |
| Финальный статус неизменяем | purchased и not_purchased — терминальные | Кнопка не отображается, 400 если через API |
| Смена статуса только своих лидов | Продавец не может менять чужие лиды | 403 FORBIDDEN |
| Лид привязан к айтему | Нельзя создать лид без item_id | 400 ITEM_NOT_FOUND |
| Только авторизованный продавец | Staff тоже может менять статусы | 401/403 |
| Admin только read-only | Admin не меняет статусы | 403 FORBIDDEN |
| Бейдж новых лидов в навигации | Считается по статусу new для данного seller_id | Обновляется real-time после изменения статуса |
| Гость-лид (buyer_account_id = null) | Лид создан без регистрации покупателя — отображается нормально | — |

### Таблица валидаций API

| Поле | Правило | Ошибка |
|------|---------|--------|
| `status` | Одно из: new, contacted, enrolled, attended, no_show, purchased, not_purchased | 422 INVALID_ENUM_VALUE |
| `lead_id` | Существующий UUID, принадлежит seller_id из токена | 404 LEAD_NOT_FOUND / 403 FORBIDDEN |
| Переход статуса | Соответствует матрице переходов | 400 INVALID_STATUS_TRANSITION |
| Pagination: `page` | Integer ≥ 1, default: 1 | 422 |
| Pagination: `limit` | Integer 1–100, default: 20 | 422 |
| Filter: `status` | Один из допустимых статусов или `all` | 422 |
| Filter: `item_id` | UUID, принадлежит данному seller_id | 403 |

---

## 5. Модель данных

> Lead — основная сущность этого спека. Seller и Item определены в Spec 01 и Spec 02 соответственно.

### Lead

| Атрибут | Тип | Описание |
|---------|-----|---------|
| lead_id | UUID | PK |
| item_id | UUID FK | → Item (Spec 02), курс на который оставлена заявка |
| seller_id | UUID FK | → Seller (Spec 01), денормализованный для быстрых запросов |
| buyer_account_id | UUID FK? | → Account, nullable (гостевые заявки) |
| lead_name | string | Имя покупателя как он ввёл |
| lead_phone | string | Телефон в формате +998XXXXXXXXX |
| lead_email | string? | Email, опциональный |
| lead_comment | string? | Комментарий покупателя, max 500 символов |
| lead_type | LeadType | trial / buy |
| lead_status | LeadStatus | new / contacted / enrolled / attended / no_show / purchased / not_purchased |
| special_offer_id | UUID FK? | → SpecialOffer (Spec 07), если заявка через акцию |
| created_at | DateTime | Время создания заявки |
| updated_at | DateTime | Время последнего обновления (для статуса) |

### LeadStatusHistory (для аудита — MVP опционально, v1.0 обязательно)

| Атрибут | Тип | Описание |
|---------|-----|---------|
| id | UUID | PK |
| lead_id | UUID FK | → Lead |
| from_status | LeadStatus? | Предыдущий статус (null для первого) |
| to_status | LeadStatus | Новый статус |
| changed_by_account_id | UUID FK | Кто изменил (seller owner или staff) |
| changed_at | DateTime | Время изменения |

---

## 6. Технические контракты

### 6.1 Prisma Schema

```prisma
enum LeadType {
  trial
  buy
}

enum LeadStatus {
  new
  contacted
  enrolled
  attended
  no_show
  purchased
  not_purchased
}

model Lead {
  lead_id           String      @id @default(uuid())
  item_id           String
  seller_id         String
  buyer_account_id  String?
  lead_name         String      @db.VarChar(100)
  lead_phone        String
  lead_email        String?
  lead_comment      String?     @db.VarChar(500)
  lead_type         LeadType
  lead_status       LeadStatus  @default(new)
  special_offer_id  String?
  created_at        DateTime    @default(now())
  updated_at        DateTime    @updatedAt

  item              Item        @relation(fields: [item_id], references: [item_id])
  seller            Seller      @relation(fields: [seller_id], references: [seller_id])
  buyer_account     Account?    @relation(fields: [buyer_account_id], references: [account_id])
  status_history    LeadStatusHistory[]

  @@index([seller_id, lead_status])
  @@index([seller_id, created_at])
  @@index([item_id])
}

model LeadStatusHistory {
  id                      String      @id @default(uuid())
  lead_id                 String
  from_status             LeadStatus?
  to_status               LeadStatus
  changed_by_account_id   String
  changed_at              DateTime    @default(now())

  lead Lead @relation(fields: [lead_id], references: [lead_id])

  @@index([lead_id])
}
```

### 6.2 TypeScript DTO

```typescript
// ─── Запросы ──────────────────────────────────────────────────────────────

export class UpdateLeadStatusDto {
  @IsEnum(LeadStatus, { message: 'Недопустимый статус заявки' })
  status: LeadStatus
}

export class LeadListQueryDto {
  @IsOptional() @IsEnum(LeadStatus)
  status?: LeadStatus

  @IsOptional() @IsUUID()
  item_id?: string

  @IsOptional() @IsEnum(LeadType)
  lead_type?: LeadType

  @IsOptional() @IsDateString()
  date_from?: string   // ISO 8601

  @IsOptional() @IsDateString()
  date_to?: string     // ISO 8601

  @IsOptional() @IsInt() @Min(1)
  @Type(() => Number)
  page?: number = 1

  @IsOptional() @IsInt() @Min(1) @Max(100)
  @Type(() => Number)
  limit?: number = 20
}

// ─── Ответы ───────────────────────────────────────────────────────────────

export interface LeadListItemDto {
  lead_id: string
  lead_name: string
  lead_phone: string
  lead_email: string | null
  lead_comment: string | null
  lead_type: LeadType
  lead_status: LeadStatus
  item_id: string
  item_name: string
  special_offer_name: string | null
  created_at: string  // ISO 8601
}

export interface LeadDetailDto extends LeadListItemDto {
  buyer_account_id: string | null
  updated_at: string
  status_history?: LeadStatusHistoryItemDto[]  // только для admin view
}

export interface LeadStatusHistoryItemDto {
  from_status: LeadStatus | null
  to_status: LeadStatus
  changed_at: string
}

export interface LeadListResponseDto {
  leads: LeadListItemDto[]
  total: number
  page: number
  limit: number
  status_counts: Record<LeadStatus | 'all', number>
}
```

### 6.3 API Endpoints

```
────────────────────────────────────────────────────────────────
SELLER: СПИСОК ЛИДОВ
────────────────────────────────────────────────────────────────

GET /api/v1/seller/leads
Auth: Bearer (seller | seller_staff)
Query: LeadListQueryDto
→ 200: LeadListResponseDto
→ 401: { error: 'UNAUTHORIZED' }
→ 422: { errors: [{ field: string, message: string }] }

────────────────────────────────────────────────────────────────
SELLER: ДЕТАЛИ ЛИДА
────────────────────────────────────────────────────────────────

GET /api/v1/seller/leads/:lead_id
Auth: Bearer (seller | seller_staff)
→ 200: LeadDetailDto
→ 403: { error: 'FORBIDDEN', message: 'Эта заявка принадлежит другому продавцу.' }
→ 404: { error: 'LEAD_NOT_FOUND', message: 'Заявка не найдена.' }

────────────────────────────────────────────────────────────────
SELLER: СМЕНА СТАТУСА
────────────────────────────────────────────────────────────────

PATCH /api/v1/seller/leads/:lead_id/status
Auth: Bearer (seller | seller_staff)
Body: UpdateLeadStatusDto
→ 200: LeadDetailDto  // обновлённый объект лида
→ 400: { error: 'INVALID_STATUS_TRANSITION', message: 'Переход из «{from}» в «{to}» недопустим.' }
→ 400: { error: 'LEAD_FINALIZED', message: 'Статус завершённой заявки нельзя изменить.' }
→ 403: { error: 'FORBIDDEN' }
→ 404: { error: 'LEAD_NOT_FOUND' }
→ 409: { error: 'CONCURRENT_UPDATE', message: 'Статус был изменён другим сотрудником.', current_status: LeadStatus }
→ 500: { error: 'INTERNAL_ERROR', message: 'Что-то пошло не так. Попробуйте позже.' }

────────────────────────────────────────────────────────────────
ADMIN: СПИСОК ВСЕХ ЛИДОВ
────────────────────────────────────────────────────────────────

GET /api/admin/leads
Auth: Bearer (admin)
Query: LeadListQueryDto + { seller_id?: string }
→ 200: LeadListResponseDto (с дополнительным полем seller_name в каждом элементе)
→ 401: { error: 'UNAUTHORIZED' }
→ 403: { error: 'FORBIDDEN' }

────────────────────────────────────────────────────────────────
ADMIN: ДЕТАЛИ ЛИДА
────────────────────────────────────────────────────────────────

GET /api/admin/leads/:lead_id
Auth: Bearer (admin)
→ 200: LeadDetailDto (включая status_history и buyer_account_id)
→ 404: { error: 'LEAD_NOT_FOUND' }
```

---

## 7. Edge Cases

| Сценарий | Поведение |
|----------|----------|
| Два сотрудника одновременно меняют статус одного лида | Второй запрос получает 409 CONCURRENT_UPDATE с актуальным статусом; фронтенд обновляет строку |
| Лид создан до публикации айтема (гипотетически) | lead_status остаётся new, item_name показывается как есть; айтем может быть draft |
| buyer_account_id = null (гостевой лид) | Отображается нормально, поле покупателя "Гость"; вся функциональность смены статуса работает |
| Продавец заблокирован (account_status = blocked) | 403 FORBIDDEN на все /api/v1/seller/* эндпоинты; фронтенд показывает страницу блокировки |
| Фильтр по item_id, который не принадлежит продавцу | 403 FORBIDDEN; не возвращаем данные чужих айтемов |
| Лид на айтем, который был удалён | item_name хранится денормализованно или JOIN с fallback "Курс удалён"; lead_status можно продолжать обновлять |
| Очень длинный lead_comment (> 500 символов) | 422 при создании лида (Spec 06); в списке показываются первые 60 символов + "..." |
| Продавец без лидов открывает /seller/leads с фильтром status=purchased | Пустое состояние: "По выбранным фильтрам заявок нет." + кнопка "Сбросить фильтры" |
| Admin смотрит лиды продавца, у которого > 10 000 лидов | Pagination работает корректно; limit max 100 на запрос |
| special_offer_id указывает на удалённую акцию | special_offer_name возвращается как null; UI показывает "—" |
| Продавец-STAFF пытается создать лид (не его роль) | 403 FORBIDDEN — создание лидов только через публичный buyer flow |

---

## 8. TBD / Сознательно опущено

| Тема | Статус | Примечание |
|------|--------|-----------|
| Экспорт лидов (CSV/XLSX) | Запланировано в v1.0 | UC-06 описан как заглушка |
| История изменений статусов (LeadStatusHistory) | TBD для MVP | Таблица описана, заполнение опционально в MVP; обязательно в v1.0 |
| Bulk actions (массовая смена статусов) | Вне скоупа MVP | Сложная UX-задача; отложено в v1.5 |
| Комментарии продавца к лиду (заметки) | Вне скоупа MVP | Продавец не может оставлять свои заметки к заявке в MVP |
| Напоминания и задачи по лиду (CRM-фичи) | Вне скоупа | MVP не является CRM-системой |
| Real-time обновления (WebSocket/SSE) | TBD | В MVP список обновляется при ручном рефреше или переходе на страницу; push-обновления — v1.0 |
| Поиск по имени/телефону покупателя | TBD для MVP | Если таблица большая — нужен full-text search; заглушка на BД ILIKE |
| Аналитика по воронке (конверсия статусов) | Вне скоупа MVP | Будет в разделе аналитики v1.5 |
| Уведомление покупателя при смене статуса | TBD | Продукт не определился: нужно ли уведомлять buyer о смене статуса → Spec 10 |
| Разграничение прав staff vs owner на лиды | Упрощено для MVP | В MVP staff = те же права что owner на лиды; детальные ACL в v1.0 |

---

## 9. Зависимости

| Модуль | Связь |
|--------|-------|
| **Spec 01** (Seller Onboarding) | Seller.seller_id — владелец лида; Seller.telegram_chat_id — для уведомлений |
| **Spec 02** (Items) | Item.item_id, Item.item_name — к какому курсу привязан лид |
| **Spec 03** (Staff) | SellerStaff.account_id — сотрудники видят и меняют лиды своей организации |
| **Spec 06** (Buyer Flow) | Создание лида покупателем → Lead.lead_status = new |
| **Spec 07** (Special Offers) | Lead.special_offer_id → SpecialOffer — какая акция привлекла покупателя |
| **Spec 10** (Notifications) | При lead_status = new → уведомление продавцу через Telegram/Email |
| **Spec 15** (Billing / CPL) | Подсчёт лидов для reconciliation; каждый лид со статусом new = $30 к счёту |
