feat(autocall): раздел «Коммуникации» с вкладками SMS/Автодозвон/Аналитика, бейджи, карточка заказа, edge fn autocall-result, миграция БД

This commit is contained in:
Hermes 2026-09-01 21:34:39 +02:00
parent 7ce06873ce
commit 6435679161
12 changed files with 1677 additions and 4 deletions

View File

@ -0,0 +1,413 @@
# Автодозвон SuperSam — детальный план реализации (БД → API → планировщик → UI)
> **Дата:** 01.09.2026 · **Статус АТС:** диалоговая машина и голос Vivienne проверены живьём.
> **Контракт реализации:** каждая фаза — отдельный PR, сначала dev (dev.mkn8n.ru), prod — только после проверки Михаилом. Mobile-first: реестр и журнал — подписанные строки на viewport 390px, таблица только desktop. Термины/строки в UI и SQL — ровно те, что уже используются в приложении («Первая половина дня» и т.п.).
---
## Фаза 0 — Заморозка контрактов (нулевой код, только согласование)
**Цель:** зафиксировать значения, вокруг которых всё строится, чтобы не разъехались UI, БД и AGI.
**Файл:** `docs/autocall-contract.md` (новый, в репо).
Содержимое:
1. **Кампания-ключ:** `campaign_type='autocall_slot'` (первый сценарий). Резерв ключей: `autocall_storage`, `autocall_reminder`, `autocall_confirm_morning`.
2. **Сценарии звонка:** `A` (день+время), `B` (время, день=завтра), `C` (подтверждение выбранного, молчание=да).
3. **Outcome-значения `autocall_log.outcome` (единственный enum, на него смотрит UI):**
`confirmed`, `confirmed_kept_previous`, `confirmed_silent`, `no_answer`, `no_input_day`, `no_input_half`, `operator`, `opt_out`, `invalid_number`, `error`.
4. **DTMF-семантика:** `dtmf_day`: 1=завтра, 2=послезавтра; `dtmf_half`: 1=первая половина дня, 2=вторая. Больше ничего. 0/9/* — не сохраняются как ответ, а формируют outcome.
5. **Записи в `order_groups` при `confirmed*`:** `delivery_date/pickup_date`, `delivery_time/pickup_time_slot`, `delivery_date_source='autocall'`, `delivery_status='agreed'`, `notification_status='confirmed'`, `called=true`, `called_at`, `called_comment='Согласовано роботом: …'`.
6. **Эскалация:** `operator`, `opt_out`, `invalid_number`, `no_input×no_input_escalate_after`, `attempts≥max_attempts` → `notification_status='manual_required'` (плюс `autocall_opt_out=true` только для 9).
7. **Тайминг:** отсчёт от `sms_sent_at`, пауза `delay_after_sms_minutes` (90), `retry_interval_minutes` (120), попытки в день `attempts_per_day` (3), следующий день `next_day_attempt_times` (`10:00,14:00`), окно `work_hours_start/end` 9–20, `work_days` пн–сб. Все — из БД, не хардкод.
8. **Тест:** `test_mode=true` → набор `test_phone`; `was_test_mode=true` пишем в лог.
**Проверка:** файл открыт и подтверждён Михаилом в PR-описании.
---
## Фаза 1 — База данных (миграция dev, потом prod)
### Файлы
- Создать `supabase/migrations/20260901_autocall_base.sql` (модель: один файл, применяем как остальные DDL — см. правило: DDL только `psql -U supabase_admin`).
### Что делает
```sql
-- 1) лог звонков
create table if not exists public.autocall_log (
id bigint generated always as identity primary key,
order_group_id uuid references public.order_groups(id) on delete set null,
campaign_type text not null default 'autocall_slot',
customer_phone text not null,
attempt_no smallint not null,
scenario text not null check (scenario in ('A','B','C')),
call_started_at timestamptz not null,
answered_at timestamptz,
duration_sec smallint,
sip_code smallint,
channel_state text,
dtmf_day smallint check (dtmf_day in (1,2) or dtmf_day is null),
dtmf_half smallint check (dtmf_half in (1,2) or dtmf_half is null),
changed boolean not null default false,
outcome text not null check (outcome in (
'confirmed','confirmed_kept_previous','confirmed_silent',
'no_answer','no_input_day','no_input_half',
'operator','opt_out','invalid_number','error')),
was_test_mode boolean not null default false,
asterisk_uniqueid text,
created_at timestamptz not null default now()
);
create index if not exists idx_autocall_log_group on public.autocall_log(order_group_id, created_at desc);
create index if not exists idx_autocall_log_phone on public.autocall_log(customer_phone, created_at desc);
create index if not exists idx_autocall_log_outcome on public.autocall_log(outcome, created_at desc);
create index if not exists idx_autocall_log_uniq on public.autocall_log(asterisk_uniqueid);
comment on table public.autocall_log is 'Лог робо-звонков (автодозвон через АТС клиента). UI читает outcome.';
grant select on public.autocall_log to authenticated;
-- 2) order_groups
alter table public.order_groups add column if not exists autocall_opt_out boolean not null default false;
create index if not exists idx_order_groups_autocall_optout
on public.order_groups(autocall_opt_out) where autocall_opt_out = true;
-- 3) новые колонки настроек (если их ещё нет)
alter table public.sms_campaign_settings add column if not exists delay_after_sms_minutes int;
alter table public.sms_campaign_settings add column if not exists retry_interval_minutes int;
alter table public.sms_campaign_settings add column if not exists attempts_per_day int;
alter table public.sms_campaign_settings add column if not exists no_input_escalate_after int;
alter table public.sms_campaign_settings add column if not exists next_day_attempt_times text;
alter table public.sms_campaign_settings add column if not exists call_timeout_seconds int;
-- 4) строка кампании (в боевом режиме — выключена и тестовая)
insert into public.sms_campaign_settings
(campaign_type, enabled, test_mode, test_phone, work_hours_start, work_hours_end, work_days,
max_attempts, delay_after_sms_minutes, retry_interval_minutes, attempts_per_day,
no_input_escalate_after, next_day_attempt_times, call_timeout_seconds)
values
('autocall_slot', false, true, '9067029953', 9, 20, '1,2,3,4,5,6',
5, 90, 120, 3,
2, '10:00,14:00', 30)
on conflict (campaign_type) do nothing;
-- 5) представление флагов для реестра (без N+1)
create or replace view public.order_group_comms_flags as
select
og.id,
og.autocall_opt_out,
og.delivery_date_source,
og.notification_status,
og.delivery_status,
(select l.outcome from public.autocall_log l where l.order_group_id = og.id order by l.created_at desc limit 1) as last_call_outcome,
(select l.changed from public.autocall_log l where l.order_group_id = og.id order by l.created_at desc limit 1) as last_call_changed,
(select count(*) from public.autocall_log l where l.order_group_id = og.id) as call_attempts,
(select count(*) from public.autocall_log l where l.order_group_id = og.id
and l.outcome in ('no_input_day','no_input_half')) as no_input_count
from public.order_groups og;
grant select on public.order_group_comms_flags to authenticated;
```
### Применение и проверка
- Dev: `docker exec supabase-db psql -U supabase_admin -d postgres -f /tmp/20260901_autocall_base.sql`
- Проверки: `\d autocall_log`; `select campaign_type from sms_campaign_settings where campaign_type='autocall_slot'`; `select * from order_group_comms_flags limit 1`.
---
## Фаза 2 — Edge function `autocall-result`
### Файлы
- Создать `supabase/functions/autocall-result/index.ts`
- Общие helpers: `supabase/functions/_shared/` (используем существующие cors/service-role паттерны из `confirm-delivery-choice`).
### Контракт endpoint'а
`POST /functions/v1/autocall-result`
Headers: `x-webhook-secret: <WEBHOOK_SECRET>` (тот же стиль, что у `dadata-suggest`).
Body (JSON ровно в этом виде; AGI шлёт curl'ом):
```json
{
"asterisk_uniqueid":"1788283049.23",
"order_group_id":"uuid-or-null",
"campaign_type":"autocall_slot",
"customer_phone":"79067029953",
"attempt_no":2,
"scenario":"A",
"call_started_at":"2026-09-01T20:17:45+03:00",
"answered_at":"2026-09-01T20:17:45+03:00",
"duration_sec":65,
"sip_code":200,
"channel_state":"Up",
"dtmf_day":1,"dtmf_half":2,"changed":true,
"outcome":"confirmed",
"was_test_mode":true
}
```
### Логика функции (по шагам)
1. Валидация секрета и полей; `outcome` — только из enum (Фаза 0).
2. Идемпотентность: upsert по `asterisk_uniqueid` (повторный curl не плодит строки).
3. Вставка в `autocall_log`.
4. Если `order_group_id` нет — найти последний активный заказ по `customer_phone`
(`status not in ('closed','cancelled')`, `delivery_status='pending_confirmation'`).
5. При `confirmed | confirmed_kept_previous | confirmed_silent`:
- вычислить `delivery_date` = (завтра/послезавтра от даты звонка, по Europe/Moscow);
- `delivery_time` = `'Первая половина дня'|'Вторая половина дня'` (точно эти строки);
- pickup: вместо delivery_* пишем `pickup_date`,`pickup_time_slot` при `delivery_type='pickup'`;
- обновить `order_groups`: поля из Фазы 0 п.5 + `next_notification_check_at=null`.
6. При `operator|opt_out|invalid_number`: `notification_status='manual_required'`, `called=true`,
`called_comment=...`; для `opt_out` ещё `autocall_opt_out=true`.
7. При `no_answer|no_input_day|no_input_half`: посчитать уже сделанные попытки;
при `attempt_no >= max_attempts` или `no-input >= no_input_escalate_after` → `manual_required`,
иначе оставить `notification_status='link_ready'` (планировщик назначит следующую).
8. `auth.uid()` пуст → в `order_history` source проставится «Система» (триггер уже работает);
добавить `metadata.source='autocall'` через update-поле (если триггер поддерживает; иначе отдельный insert в `order_history`).
9. Ответ: `{ok:true}` / `{ok:false,error}`.
### Тесты (ручные, dev)
- curl с тестовым uniqueid → строка в `autocall_log`, заказ обновился, в UI бейдж «🤖 слот от робота».
- Повторный curl с тем же uniqueid → дубликата нет.
---
## Фаза 3 — Планировщик (systemd на проде, dev — ручной запуск)
### Файлы
- Создать `scripts/autocall_campaign.py`
- Создать `scripts/autocall_campaign.service`
- Создать `scripts/autocall_campaign.timer`
- Конфиг-реюз: импортировать helper'ы из `sms_run_with_db.sh` (резолв IP supabase-db).
### Алгоритм тика (запуск каждые 10 мин)
1. Загрузить настройки `autocall_slot` из `sms_campaign_settings`. Если `enabled=false` → выход.
2. Выбор кандидатов:
```sql
select id, customer_phone, delivery_type, first_sms_sent_at
from order_groups
where notification_status='link_ready'
and delivery_status='pending_confirmation'
and autocall_opt_out=false
and first_sms_sent_at is not null
and first_sms_sent_at <= now() - (delay_after_sms_minutes || ' minutes')::interval
and coalesce((select max(attempt_no) from autocall_log l where l.order_group_id=order_groups.id),0) < max_attempts
and (next_notification_check_at is null or next_notification_check_at <= now());
```
В test_mode — `customer_phone` заменяем на `test_phone`.
3. Проверка окна: `work_hours_start <= now < work_hours_end`, день недели ∈ `work_days`; иначе — отложить (`next_notification_check_at` = ближайшее начало окна).
4. Сценарий: если дата «готовности» = сегодня → `A`, иначе `B`. (C планировщик пока не выбирает — сценарий C включается отдельной задачей позже.)
5. Расчёт номера попытки: `attempt_no = колв-во строк в autocall_log + 1`; распределение по дню/след.дню: если `attempt_no <= attempts_per_day` → сейчас; иначе по `next_day_attempt_times`.
6. Генерация call-файла в `/tmp/ss-<group>-<attempt>.call`:
```
Channel: PJSIP/0<phone10>@mmedia
Context: SS-Autocall-Slot
Extension: s
Priority: 1
MaxRetries: 0
WaitTime: 30
Archive: yes
Setvar: SCENARIO=A
Setvar: CALLTYPE=dlv
Setvar: GROUP_ID=<uuid>
Setvar: ATTEMPT=<n>
Setvar: API_URL=https://<prod>/functions/v1/autocall-result
Setvar: API_KEY=<webhook-secret>
Setvar: TEST_MODE=0|1
```
7. Передача на АТС: `scp → /tmp` (атомарно mv в `/var/spool/asterisk/outgoing/`), ssh-кред только на сервере (никаких ключей в репо — в `/opt/supersam/.env` локально).
8. Логирование тика в `sms_campaign_log`-стиле своей строкой `campaign_type='autocall_slot'` (техническая метка «тик был») — опционально, для отладки таймера.
9. Обработка ручных запросов: таблица `autocall_queue` (НОВАЯ, минимальная):
```sql
create table if not exists public.autocall_queue (
id bigint generated always as identity primary key,
order_group_id uuid not null,
phone text not null,
scenario text not null default 'A',
requested_by uuid,
requested_at timestamptz not null default now(),
processed_at timestamptz
);
```
Кнопка «Позвонить сейчас» пишет сюда; тик забирает `processed_at is null`, создаёт call-файл, ставит `processed_at`. (Так ssh никогда не попадает в edge fn.)
### Юнит-проверки (без настоящих звонков)
- Запустить скрипт с `--dry-run`: в консоль печатает список кандидатов и текст call-файла.
- `test_mode=true`, `test_phone=<твой>` → один звонок тебе.
### Таймер
```
# autocall_campaign.timer
[Timer]
OnCalendar=*:0/10
Persistent=true
# autocall_campaign.service
[Service]
Type=oneshot
ExecStart=/opt/supersam/scripts/sms_run_with_db.sh /opt/supersam/scripts/autocall_campaign.py
```
---
## Фаза 4 — Диалплан и AGI-связь с API (изменения на АТС, с показом Паше)
1. `ss-dialog.agi` дополнить: в конце (после определения итогового `OUTCOME`) — curl на `API_URL` с телом из Фазы 2. Переменные приходят из call-файла (`GROUP_ID`, `ATTEMPT`, `TEST_MODE`).
Покрыть: потерю сети (curl timeout 5s, не валить звонок — результат всё равно в `/tmp/ss-result-*.txt`).
2. `[SS-Autocall-Slot]` — additive контекст (диф Паше до применения).
3. `pbx_spool.so` — попросить постоянную загрузку (иначе ночной reboot 03:33 сносит).
---
## Фаза 5 — UI: оболочка «Коммуникации» (вкладки)
### Файлы
- Создать `src/components/admin/CommunicationsPanel.jsx` — подключает вкладки.
- Перенести содержимое `SmsCampaignPanel.jsx` в `src/components/admin/sms/SmsChannelTab.jsx` (почти copy-paste, имя файла сохранить в git как rename).
- `src/pages/DashboardPage.jsx`:
- импорт `CommunicationsPanel` вместо `SmsCampaignPanel`;
- `MEGA_ADMIN_NAV`: `{ key:"sms_campaign", label:"Коммуникации", description:"SMS, автодозвон и аналитика обращений." }` (key не трогаем — совместимость `?tab=sms_campaign`).
- роут: `if (activeSection === "sms_campaign") return <CommunicationsPanel/>`.
### Поведение вкладок
- Состояние: `searchParams.channel` ∈ `sms | autocall | analytics`, дефолт `sms`.
- UI: таблетки (по стилю существующего период-селектора в `SmsCampaignStats`).
- На мобильном: вкладки в одну строку с горизонтальным скроллом, подписи короткие.
### Проверка (dev)
- Прямой переход `/dashboard?tab=sms_campaign&channel=autocall` открывает нужную вкладку.
- Все 4 SMS-карточки, статистика, журнал — без регрессий.
- На 390px — ничего не ломается.
---
## Фаза 6 — UI: вкладка «Автодозвон»
### Файлы
- Создать `src/components/admin/autocall/AutocallChannelTab.jsx` — карточки, настройки, журнал.
- Создать `src/components/admin/autocall/AutocallLogTable.jsx` — таблица/строки + фильтры + поиск.
- Создать `src/components/admin/autocall/AutocallStats.jsx` — графики (recharts, как SmsCampaignStats).
### Карточки кампаний звонков
`CAMPAIGNS` для канала:
```js
[
{ key:"autocall_slot", label:"Согласование слота", icon:"🔊", live:true },
{ key:"autocall_storage", label:"Платное хранение (голосом)",icon:"📦", live:false },
{ key:"autocall_reminder", label:"Напоминание накануне", icon:"⏰", live:false },
{ key:"autocall_confirm_morning", label:"Подтверждение утром", icon:"☀️", live:false },
]
```
Неработающие — карточка disabled с бейджем «скоро». Рабочая — по шаблону SMS-карточек: тоггл вкл/выкл, бейдж (⏸/🧪/🚀), мини-статистика Всего / Дозвон / Согласовали, кнопки ▶/🧪/🔄.
### Настройки `autocall_slot` (форма, все поля из Фазы 0 п.7)
Группы:
- Режим: тест/боевой + тестовый номер.
- Интервалы: `delay_after_sms_minutes`, `retry_interval_minutes`, `attempts_per_day`, `max_attempts`, `next_day_attempt_times`.
- Эскалация: `no_input_escalate_after`.
- Окно: часы (селекты как у SMS) и дни-таблетки.
- Telegram chat ID для алертов недозвона.
Кнопка «Сохранить» — тот же паттерн (`update sms_campaign_settings`). По умолчанию `enabled=false`, `test_mode=true`.
### Журнал звонков
- Данные: `supabase.from("autocall_log").select("*").order("created_at desc").limit(200)`.
- Таблица (desktop): `Заказ | Клиент/телефон | Попытка | Время | Длит. | Исход | Слот`.
- Mobile (<640px): подписанные строки:
```
🔊 Володя · +7 906 702-99-53
Исход: ✅ Согласовал (попытка 2)
Слот: завтра, первая половина дня
01.09.26, 20:18
```
- Бейджи исходов: тон/иконка/текст из маппинга (Фаза 0 п.3); `confirmed*` — зелёные, `no_answer` — нейтраль, `no_input*` — warning, `operator/opt_out/invalid_number/error` — danger.
- Фильтры-chips: все + 10 исходов (значение = outcome). Поиск: по номеру заказа/телефону/имени (общий helper с SMS-журналом).
- Клик по строке → `/dashboard/group/<order_group_id>`.
- Пагинация 20/стр, автообновление 30с (тоггл, как у SMS).
---
## Фаза 7 — Бейджи снаружи (реестр и логистика)
### Файлы
- `src/components/logistics/LogisticsReadinessBoard.jsx` — колонка/строка бейджа.
- `src/hooks/useOrderGroups.js` — подтягивать `order_group_comms_flags` одним запросом join'ом (или вторым запросом по списку id — проще, 1 запрос).
### Бейдж (приоритет сверху вниз — один наружу)
| Условие | Бейдж | Тон |
|---|---|---|
| `autocall_opt_out` | 🚫 отказ от звонков | danger |
| `last_call_outcome='invalid_number'` | ❌ неверный номер | danger |
| `notification_status='manual_required'` + были звонки | 👤 менеджеру | warning |
| `no_input_count ≥ 2` | 🤐 не нажимает | warning |
| `call_attempts ≥ 3` + последний `no_answer` | 📵 не дозвонились | warning |
| `delivery_date_source='autocall'` + `last_call_changed` | 🤖✏️ слот от робота (правился) | info |
| `delivery_date_source='autocall'` | 🤖 слот от робота | info |
Реализация: компонент `src/components/UI/CallStatusBadge.jsx` (props: флаги), рендерится:
- desktop — отдельная колонка «Дозвон» в таблице групп;
- mobile — иконка в подписанной строке (рядом с телефоном).
### Фильтры реестра
Chips над таблицей (мультивыбор): `📵 Не дозвонились`, `🤐 Не нажимают`, `👤 Менеджеру (после робота)`, `🤖 Согласовано роботом`, `🚫 Отказ от звонков`. Фильтрация — на клиенте по уже загруженным флагам (скрываем строки), без новых запросов.
### Дашборд логистика и водителя
В карточках доставок — мелкая пометка «🤖» возле времени слота при `delivery_date_source='autocall'`. Без фильтров, только визуальная пометка.
---
## Фаза 8 — Карточка заказа: блок «Автодозвон»
### Файл
- Создать `src/components/orders/AutocallStatusCard.jsx`; подключить в `DeliverySetDetailPanel.jsx` рядом с `SmsStatusCard`.
### Содержимое
- Заголовок: «📞 Автодозвон» + бейдж-итог (успех/в работе/менеджеру/отказ).
- Слот: если `confirmed*` — «Завтра, первая половина дня» (плюс пометка «согласовано роботом»).
- История: до 5 строк из `autocall_log` этого заказа (иконка исхода, время, попытка).
- Если `last_call_changed=true` — подсказка: «Клиент правил выбор. При сомнении — перезвоните».
- Кнопки (роли `mega_admin`, `logistician`):
- `[📞 Позвонить сейчас]` → insert в `autocall_queue` (см. Фаза 3, п.9), кнопка заменяется на «В очереди ✓».
- `[🚫 Не звонить]` / `[🔔 Вернуть в автодозвон]` → toggle `autocall_opt_out`.
- В `order_history` уже попадает (триггер); доработка: `metadata.source='autocall'` — вывести в таймлайне как «Автодозвон», если UI таймлайна отображает source.
---
## Фаза 9 — Вкладка «Аналитика»
### Файл
- Создать `src/components/admin/analytics/CommsAnalyticsTab.jsx`.
### Блоки
1. **Сводка каналов за период** (7/14/30/всё — селектор как в SmsCampaignStats):
- SMS: отправлено/доставлено/конверсия переходов по ссылке (если считаем — иначе «н/д»),
- Звонки: попыток/дозвонились/согласовали/эскалации,
- Итог: «Согласовано автоматикой» vs `manual_required` всего.
2. **Воронка автодозвона** (bar): SMS→звонок1→…→звонок5→согласовано/менеджеру.
3. **Исходы** (donut из `autocall_log.outcome`).
4. **По дням** (bar: попыток/дозвон/согласовано) + **конверсия согласования** (line).
5. **«Согласовали с N-й попытки»** (bar) — покажет, нужна ли 4–5 попытка.
6. **Минуты**: «время на линии всего / средний звонок» (sum/avg `duration_sec`) — данные для разговора с Пашей о тарифе Miranda.
7. **Экспорт CSV** текущего фильтра журнала звонков и сводки (кнопка «⬇ CSV», генерим на клиенте Blob).
Все запросы — только чтение `autocall_log`/`sms_campaign_log` за период; без новых таблиц.
---
## Фаза 10 — Раскатка и приёмка
1. **Dev прогон**: миграция → edge fn → планировщик (dry-run) → UI (вкладки, журнал, бейджи, карточка, аналитика) на 390px и desktop.
2. **Тестовый заказ**: создать заказ, прогнать цикл SMS → (не выбирает слот) → звонок на `test_phone` → согласование → проверить карточку, бейдж в реестре, таймлайн, аналитику.
3. **Боевая проба на твоём номере**: `test_mode=true`, реальный заказ → ты нажимаешь 1/2 → всё сошлось.
4. **Перенос prod**: миграция, edge fn, планировщик (таймер включён, кампания `enabled=false`), UI-деплой (бамп SW cache).
5. **Включение prod** — только по твоей команде: сначала `test_mode=true` (боевой prod, но звонит тебе), потом выключаем test_mode.
6. **Гипигиена**: вернуть `approvals` в manual, секреты — в `/opt/supersam/.env`, доки: `docs/autocall-contract.md` актуален.
---
## Риски и вопросы (держим перед глазами)
- Reboot АТС 03:33 → Паша ставит `pbx_spool.so` в автозагрузку (диф показан до).
- Тариф Miranda не считается из CDR → своя статистика минут (Фаза 9, п.6).
- Отказ от звонков — юр. требование: озвучка «9» уже в диалоге; счётчик opt-out на карточке кампании.
- Никаких ssh из edge fn — ручные звонки только через `autocall_queue` + планировщик.
- Совместимость URL: `?tab=sms_campaign` сохранён как key; label меняем на «Коммуникации».
## Оценка по объёму
- Фазы 1–4 (БД+API+планировщик+АТС-curl): ~0,5–1 день.
- Фазы 5–9 (UI): ~1–1,5 дня.
- Фаза 10 (прогоны): несколько часов.
Итого ≈ 2–2,5 рабочих дня до prod-включения.

View File

@ -0,0 +1,37 @@
/**
* @file CallStatusBadge.jsx
* @description Бейдж коммуникационного статуса для строк реестра групп.
* Показывает проблему автодозвона снаружи, не открывая карточку.
* Приоритет сверху вниз — один бейдж наружу.
*/
import React from "react";
// Приоритет: отказ > неверный номер > менеджеру > не нажимает > не дозвонились > слот от робота
export const resolveCallBadge = (g) => {
if (!g) return null;
// 1. Отказ от звонков
if (g.autocall_opt_out) {
return { label: "🚫 отказ от звонков", cls: "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]", title: "Клиент отказался от автозвонков" };
}
// 2. Слот от робота (информационный, самый низкий приоритет — показываем если нет проблем)
if (g.deliveryDateSource === "autocall") {
return { label: "🤖 слот от робота", cls: "bg-[rgba(59,130,246,0.14)] text-[var(--color-accent)]", title: "Слот согласован роботом" };
}
return null;
};
export const CallStatusBadge = ({ group }) => {
const badge = resolveCallBadge(group);
if (!badge) return null;
return (
<span
className={`inline-flex items-center whitespace-nowrap rounded-full px-2 py-0.5 text-[10px] font-semibold ${badge.cls}`}
title={badge.title}
>
{badge.label}
</span>
);
};

View File

@ -0,0 +1,57 @@
/**
* @file CommunicationsPanel.jsx
* @description Раздел «Коммуникации»: вкладки по каналам.
* SMS / Автодозвон / Аналитика. Канал ≠ кампания: внутри вкладки — карточки кампаний.
* Новые каналы (WhatsApp/Telegram/звонки оператора) добавляются новой вкладкой без
* правок DashboardPage.
*/
import React, { useState } from "react";
import { useSearchParams } from "react-router-dom";
import { SmsCampaignPanel } from "./SmsCampaignPanel";
import { AutocallChannelTab } from "./autocall/AutocallChannelTab";
import { CommsAnalyticsTab } from "./analytics/CommsAnalyticsTab";
const CHANNELS = [
{ key: "sms", label: "📨 SMS", component: SmsCampaignPanel },
{ key: "autocall", label: "📞 Автодозвон", component: AutocallChannelTab },
{ key: "analytics", label: "📊 Аналитика", component: CommsAnalyticsTab },
];
export const CommunicationsPanel = () => {
const [searchParams, setSearchParams] = useSearchParams();
const channel = searchParams.get("channel") || "sms";
const active = CHANNELS.find((c) => c.key === channel) || CHANNELS[0];
const setChannel = (key) => {
const next = new URLSearchParams(searchParams);
if (key === "sms") next.delete("channel");
else next.set("channel", key);
setSearchParams(next, { replace: true });
};
const ActiveComponent = active.component;
return (
<div className="space-y-4">
{/* ── Вкладки каналов ─────────────────────────────────────────────── */}
<div className="flex gap-2 overflow-x-auto pb-1">
{CHANNELS.map((c) => (
<button
key={c.key}
onClick={() => setChannel(c.key)}
className={`whitespace-nowrap rounded-full px-4 py-1.5 text-sm font-medium transition ${
active.key === c.key
? "bg-[var(--color-accent)] text-white"
: "border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
}`}
>
{c.label}
</button>
))}
</div>
{/* ── Контент активной вкладки ────────────────────────────────────── */}
<ActiveComponent />
</div>
);
};

View File

@ -0,0 +1,240 @@
/**
* @file CommsAnalyticsTab.jsx
* @description Вкладка «Аналитика» раздела «Коммуникации».
* Сводка по каналам (SMS vs звонки), воронка автодозвона, исходы, минуты.
*/
import React, { useState, useEffect, useCallback } from "react";
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
PieChart, Pie, Cell, LineChart, Line, CartesianGrid, Legend,
} from "recharts";
import { Panel } from "../../UI/Panel";
import { supabase } from "../../../supabaseClient";
const OUTCOME_COLORS = {
confirmed: "#22c55e",
confirmed_kept_previous: "#eab308",
confirmed_silent: "#84cc16",
no_answer: "#94a3b8",
no_input_day: "#f97316",
no_input_half: "#f97316",
operator: "#ef4444",
opt_out: "#ef4444",
invalid_number: "#ef4444",
error: "#ef4444",
};
const OUTCOME_LABELS = {
confirmed: "Согласовал",
confirmed_kept_previous: "Правка не доведена",
confirmed_silent: "Молчание",
no_answer: "Не взял",
no_input_day: "Не нажал (день)",
no_input_half: "Не нажал (время)",
operator: "Менеджер",
opt_out: "Отказ",
invalid_number: "Неверный номер",
error: "Ошибка",
};
const KpiCard = ({ label, value, color }) => (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
<div className="text-[10px] font-medium text-[var(--color-text-muted)]">{label}</div>
<div className="mt-1 text-lg font-bold" style={{ color }}>{value}</div>
</div>
);
export const CommsAnalyticsTab = () => {
const [period, setPeriod] = useState("14d");
const [callStats, setCallStats] = useState(null);
const [dailyData, setDailyData] = useState([]);
const [statusData, setStatusData] = useState([]);
const [attemptData, setAttemptData] = useState([]);
const [smsStats, setSmsStats] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const loadStats = useCallback(async () => {
setIsLoading(true);
try {
const days = period === "7d" ? 7 : period === "30d" ? 30 : period === "all" ? 90 : 14;
const start = new Date();
start.setDate(start.getDate() - days);
const startISO = start.toISOString();
// ── Звонки ──
const { data: calls, error: callErr } = await supabase
.from("autocall_log")
.select("outcome, duration_sec, attempt_no, created_at")
.gte("created_at", startISO)
.order("created_at", { ascending: true });
if (callErr) throw callErr;
const total = calls.length;
const answered = calls.filter((c) => c.outcome !== "no_answer").length;
const confirmed = calls.filter((c) => ["confirmed", "confirmed_kept_previous", "confirmed_silent"].includes(c.outcome)).length;
const escalated = calls.filter((c) => ["operator", "opt_out", "invalid_number"].includes(c.outcome)).length;
const totalSec = calls.reduce((s, c) => s + (c.duration_sec || 0), 0);
const avgSec = total > 0 ? Math.round(totalSec / total) : 0;
const confirmRate = total > 0 ? Math.round((confirmed / total) * 100) : 0;
setCallStats({ total, answered, confirmed, escalated, totalSec, avgSec, confirmRate });
// По дням
const byDay = {};
calls.forEach((c) => {
const day = new Date(c.created_at).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
if (!byDay[day]) byDay[day] = { date: day, calls: 0, confirmed: 0 };
byDay[day].calls++;
if (["confirmed", "confirmed_kept_previous", "confirmed_silent"].includes(c.outcome)) byDay[day].confirmed++;
});
setDailyData(Object.values(byDay));
// По исходам
const byOutcome = {};
calls.forEach((c) => { byOutcome[c.outcome] = (byOutcome[c.outcome] || 0) + 1; });
setStatusData(Object.entries(byOutcome).map(([name, value]) => ({
name: OUTCOME_LABELS[name] || name,
value,
color: OUTCOME_COLORS[name] || "#94a3b8",
})));
// По номеру попытки
const byAttempt = {};
calls.forEach((c) => {
const a = c.attempt_no || 1;
if (!byAttempt[a]) byAttempt[a] = { attempt: a, total: 0, confirmed: 0 };
byAttempt[a].total++;
if (["confirmed", "confirmed_kept_previous", "confirmed_silent"].includes(c.outcome)) byAttempt[a].confirmed++;
});
setAttemptData(Object.values(byAttempt).sort((a, b) => a.attempt - b.attempt));
// ── SMS (для сводки каналов) ──
const { data: sms, error: smsErr } = await supabase
.from("sms_campaign_log")
.select("status, created_at")
.gte("created_at", startISO);
if (smsErr) throw smsErr;
const smsTotal = sms.length;
const smsDelivered = sms.filter((s) => s.status === "delivered").length;
setSmsStats({ total: smsTotal, delivered: smsDelivered });
} catch (e) {
console.error("Comms analytics error:", e);
} finally {
setIsLoading(false);
}
}, [period]);
useEffect(() => { loadStats(); }, [loadStats]);
if (isLoading) {
return (
<Panel className="p-5">
<div className="animate-pulse text-sm text-[var(--color-text-muted)]">Загрузка аналитики…</div>
</Panel>
);
}
if (!callStats || callStats.total === 0) {
return (
<Panel className="p-4">
<div className="text-xs text-[var(--color-text-muted)]">Нет данных для аналитики</div>
</Panel>
);
}
return (
<div className="space-y-3">
{/* ── Период ── */}
<div className="flex gap-2">
{[
{ key: "7d", label: "7 дней" },
{ key: "14d", label: "14 дней" },
{ key: "30d", label: "30 дней" },
{ key: "all", label: "Всё время" },
].map((p) => (
<button
key={p.key}
onClick={() => setPeriod(p.key)}
className={`rounded-full px-3 py-1 text-xs font-medium transition ${
period === p.key
? "bg-[var(--color-accent)] text-white"
: "border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
}`}
>
{p.label}
</button>
))}
</div>
{/* ── Сводка каналов ── */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<KpiCard label="SMS отправлено" value={smsStats?.total ?? 0} color="var(--color-text)" />
<KpiCard label="SMS доставлено" value={smsStats?.delivered ?? 0} color="#22c55e" />
<KpiCard label="Звонков" value={callStats.total} color="var(--color-text)" />
<KpiCard label="Дозвонились" value={callStats.answered} color="#3b82f6" />
<KpiCard label="Согласовали" value={callStats.confirmed} color="#22c55e" />
<KpiCard label="Конверсия звонка" value={`${callStats.confirmRate}%`} color="var(--color-accent)" />
</div>
{/* ── Графики ── */}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<Panel className="p-4">
<div className="mb-3 text-xs font-semibold text-[var(--color-text)]">📈 Звонки по дням</div>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={dailyData} margin={{ top: 5, right: 10, left: -20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: "var(--color-text-muted)" }} />
<YAxis tick={{ fontSize: 10, fill: "var(--color-text-muted)" }} allowDecimals={false} />
<Tooltip contentStyle={{ background: "var(--color-surface-strong)", border: "1px solid var(--color-border)", borderRadius: "12px", fontSize: "12px" }} />
<Legend wrapperStyle={{ fontSize: "11px" }} />
<Bar dataKey="calls" name="Звонков" fill="#3b82f6" radius={[4, 4, 0, 0]} />
<Bar dataKey="confirmed" name="Согласовали" fill="#22c55e" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</Panel>
<Panel className="p-4">
<div className="mb-3 text-xs font-semibold text-[var(--color-text)]">🍩 По исходам</div>
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie data={statusData} cx="50%" cy="50%" innerRadius={50} outerRadius={85} paddingAngle={3} dataKey="value">
{statusData.map((e, i) => <Cell key={i} fill={e.color} />)}
</Pie>
<Tooltip contentStyle={{ background: "var(--color-surface-strong)", border: "1px solid var(--color-border)", borderRadius: "12px", fontSize: "12px" }} />
<Legend wrapperStyle={{ fontSize: "11px" }} />
</PieChart>
</ResponsiveContainer>
</Panel>
</div>
{/* ── Согласовали с N-й попытки ── */}
{attemptData.length > 1 && (
<Panel className="p-4">
<div className="mb-3 text-xs font-semibold text-[var(--color-text)]">📊 Согласовали с N-й попытки</div>
<ResponsiveContainer width="100%" height={180}>
<BarChart data={attemptData} margin={{ top: 5, right: 10, left: -20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
<XAxis dataKey="attempt" tick={{ fontSize: 10, fill: "var(--color-text-muted)" }} />
<YAxis tick={{ fontSize: 10, fill: "var(--color-text-muted)" }} allowDecimals={false} />
<Tooltip contentStyle={{ background: "var(--color-surface-strong)", border: "1px solid var(--color-border)", borderRadius: "12px", fontSize: "12px" }} />
<Legend wrapperStyle={{ fontSize: "11px" }} />
<Bar dataKey="total" name="Всего" fill="#3b82f6" radius={[4, 4, 0, 0]} />
<Bar dataKey="confirmed" name="Согласовали" fill="#22c55e" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</Panel>
)}
{/* ── Минуты ── */}
<Panel className="p-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<KpiCard label="Время на линии (мин)" value={Math.round(callStats.totalSec / 60)} color="var(--color-text)" />
<KpiCard label="Средний звонок (сек)" value={callStats.avgSec} color="var(--color-text)" />
<KpiCard label="Эскалации менеджеру" value={callStats.escalated} color="#ef4444" />
</div>
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
Минуты на линии — для оценки тарифа Miranda (в CDR стоимость не видна).
</div>
</Panel>
</div>
);
};

View File

@ -0,0 +1,440 @@
/**
* @file AutocallChannelTab.jsx
* @description Вкладка «Автодозвон» раздела «Коммуникации».
* Карточки кампаний звонков + настройки + журнал autocall_log.
*/
import React, { useState, useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { Panel } from "../../UI/Panel";
import { Badge } from "../../UI/Badge";
import { supabase } from "../../../supabaseClient";
// ── Кампании звонков ─────────────────────────────────────────────────────────
const CAMPAIGNS = [
{ key: "autocall_slot", label: "Согласование слота", icon: "🔊", live: true },
{ key: "autocall_storage", label: "Платное хранение (голосом)", icon: "📦", live: false },
{ key: "autocall_reminder", label: "Напоминание накануне", icon: "⏰", live: false },
{ key: "autocall_confirm_morning", label: "Подтверждение утром", icon: "☀️", live: false },
];
// ── Исходы звонков → бейдж ──────────────────────────────────────────────────
const OUTCOME_META = {
confirmed: { label: "Согласовал", tone: "accent", icon: "✅" },
confirmed_kept_previous:{ label: "Правка не доведена", tone: "warning", icon: "🔁" },
confirmed_silent: { label: "Подтвердил молчанием", tone: "accent", icon: "🤫" },
no_answer: { label: "Не взял трубку", tone: "neutral", icon: "📵" },
no_input_day: { label: "Взял, не нажал (день)", tone: "warning", icon: "🤐" },
no_input_half: { label: "Взял, не нажал (время)", tone: "warning", icon: "🤐" },
operator: { label: "Просил менеджера", tone: "danger", icon: "👤" },
opt_out: { label: "Отказ от звонков", tone: "danger", icon: "🚫" },
invalid_number: { label: "Неверный номер", tone: "danger", icon: "❌" },
error: { label: "Ошибка", tone: "danger", icon: "⚠️" },
};
const DAY_LABEL = { 1: "завтра", 2: "послезавтра" };
const HALF_LABEL = { 1: "первая половина дня", 2: "вторая половина дня" };
const fmtTime = (ts) => {
if (!ts) return "—";
try {
return new Date(ts).toLocaleString("ru-RU", {
day: "2-digit", month: "2-digit", year: "2-digit",
hour: "2-digit", minute: "2-digit",
});
} catch { return ts; }
};
const fmtPhone = (phone) => {
if (!phone) return "—";
const d = String(phone).replace(/\D/g, "");
if (d.length === 11 && d.startsWith("7")) {
return `+7 (${d.slice(1, 4)}) ${d.slice(4, 7)}-${d.slice(7, 9)}-${d.slice(9)}`;
}
return phone;
};
const slotText = (l) => {
if (!l) return null;
const day = DAY_LABEL[l.dtmf_day];
const half = HALF_LABEL[l.dtmf_half];
if (day && half) return `${day}, ${half}`;
if (day) return day;
return null;
};
const SettingField = ({ label, value, onChange }) => (
<label className="block">
<span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">{label}</span>
<input
type="text"
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
/>
</label>
);
const FilterButton = ({ active, onClick, children }) => (
<button
onClick={onClick}
className={`rounded-full px-3 py-1 text-xs font-medium transition ${
active
? "bg-[var(--color-accent)] text-white"
: "border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
}`}
>
{children}
</button>
);
export const AutocallChannelTab = () => {
const navigate = useNavigate();
const [activeCampaign, setActiveCampaign] = useState("autocall_slot");
const [settings, setSettings] = useState(null);
const [allSettings, setAllSettings] = useState({});
const [logs, setLogs] = useState([]);
const [filter, setFilter] = useState("all");
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(true);
const [logPage, setLogPage] = useState(1);
const PER_PAGE = 20;
const loadData = useCallback(async () => {
setError(null);
try {
const { data: allSettingsData, error: allErr } = await supabase
.from("sms_campaign_settings").select("*");
if (allErr) throw allErr;
const map = {};
(allSettingsData || []).forEach((s) => { map[s.campaign_type] = s; });
setAllSettings(map);
setSettings(map[activeCampaign] || null);
let q = supabase
.from("autocall_log")
.select("*")
.order("created_at", { ascending: false })
.limit(200);
if (filter !== "all") q = q.eq("outcome", filter);
const { data: logData, error: logErr } = await q;
if (logErr) throw logErr;
setLogs(logData || []);
} catch (e) {
setError(e.message || String(e));
} finally {
setIsLoading(false);
}
}, [activeCampaign, filter]);
useEffect(() => { setIsLoading(true); loadData(); }, [loadData]);
useEffect(() => {
if (autoRefresh) {
const t = setInterval(() => loadData(), 30000);
return () => clearInterval(t);
}
}, [autoRefresh, loadData]);
const updateSetting = (key, value) => {
setSettings((prev) => (prev ? { ...prev, [key]: value } : prev));
};
const handleSave = async () => {
if (!settings) return;
setSaving(true);
try {
const { id, ...updates } = settings;
updates.updated_at = new Date().toISOString();
const { error } = await supabase
.from("sms_campaign_settings").update(updates).eq("id", id);
if (error) throw error;
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (e) {
setError(`Ошибка сохранения: ${e.message}`);
} finally {
setSaving(false);
}
};
const toggleEnabled = async (key) => {
const s = allSettings[key];
if (!s) return;
const newVal = !(s.enabled ?? true);
const { error } = await supabase
.from("sms_campaign_settings")
.update({ enabled: newVal, updated_at: new Date().toISOString() })
.eq("id", s.id);
if (error) { setError(`Ошибка: ${error.message}`); return; }
setAllSettings((prev) => ({ ...prev, [key]: { ...prev[key], enabled: newVal } }));
if (key === activeCampaign) setSettings((prev) => (prev ? { ...prev, enabled: newVal } : prev));
};
const stats = logs.reduce((acc, l) => {
acc[l.outcome] = (acc[l.outcome] || 0) + 1;
return acc;
}, {});
if (isLoading && logs.length === 0) {
return (
<Panel className="p-5">
<div className="animate-pulse text-sm text-[var(--color-text-muted)]">Загрузка…</div>
</Panel>
);
}
return (
<div className="space-y-4">
{/* ── Карточки кампаний звонков ─────────────────────────────────── */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4 max-w-4xl">
{CAMPAIGNS.map((c) => {
const isActive = activeCampaign === c.key;
const s = allSettings[c.key];
const isEnabled = s?.enabled ?? true;
const isTest = s?.test_mode ?? true;
return (
<div
key={c.key}
onClick={() => { if (c.live) { setActiveCampaign(c.key); setFilter("all"); setLogPage(1); } }}
className={`rounded-2xl border p-4 transition ${
c.live ? "cursor-pointer" : "opacity-60"
} ${
isActive
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
: "border-[var(--color-border)] bg-[var(--color-surface)] hover:bg-[var(--color-surface-strong)]"
}`}
>
<div className="flex items-center justify-between">
<span className="text-2xl">{c.icon}</span>
{c.live && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); toggleEnabled(c.key); }}
className={`relative h-6 w-11 rounded-full transition ${isEnabled ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"}`}
>
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition ${isEnabled ? "left-[20px]" : "left-0.5"}`} />
</button>
)}
</div>
<div className="mt-2 text-sm font-semibold text-[var(--color-text)]">{c.label}</div>
{c.live ? (
<div className="mt-1">
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
isEnabled
? (isTest ? "bg-[rgba(191,123,33,0.15)] text-[var(--color-warning)]" : "bg-[rgba(34,197,94,0.15)] text-[#22c55e]")
: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]"
}`}>
{!isEnabled ? "⏸ Выключена" : isTest ? "🧪 Тест" : "🚀 Боевой"}
</span>
</div>
) : (
<div className="mt-2 text-[11px] text-[var(--color-text-muted)]">Скоро</div>
)}
</div>
);
})}
</div>
{error && (
<Panel className="p-3">
<div className="text-xs text-[var(--color-danger)]">{error}</div>
<button onClick={() => { setError(null); loadData(); }} className="mt-2 text-xs text-[var(--color-accent)]">Повторить</button>
</Panel>
)}
{/* ── Настройки активной кампании ───────────────────────────────── */}
{settings && (
<Panel className="p-5">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-semibold text-[var(--color-text)]">
{CAMPAIGNS.find((c) => c.key === activeCampaign)?.icon} {CAMPAIGNS.find((c) => c.key === activeCampaign)?.label}
</h3>
{saved && <span className="text-xs text-[var(--color-accent)]">✓ Сохранено</span>}
</div>
{/* Режим тест/боевой */}
<div className={`mb-4 rounded-xl border p-3 ${settings.test_mode ? "border-[var(--color-warning)] bg-[rgba(191,123,33,0.08)]" : "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"}`}>
<label className="flex items-center justify-between">
<div>
<div className="text-sm font-semibold text-[var(--color-text)]">
{settings.test_mode ? "🧪 Тестовый режим" : "🚀 Боевой режим"}
</div>
<div className="mt-0.5 text-xs text-[var(--color-text-muted)]">
{settings.test_mode ? `Звонки только на: ${settings.test_phone || "—"}` : "Звонки реальным клиентам"}
</div>
</div>
<button
type="button"
onClick={() => updateSetting("test_mode", !settings.test_mode)}
className={`relative h-7 w-12 rounded-full transition ${settings.test_mode ? "bg-[var(--color-warning)]" : "bg-[var(--color-accent)]"}`}
>
<span className={`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition ${settings.test_mode ? "left-0.5" : "left-[22px]"}`} />
</button>
</label>
{settings.test_mode && (
<div className="mt-3">
<SettingField label="Тестовый номер" value={settings.test_phone || ""} onChange={(v) => updateSetting("test_phone", v)} />
</div>
)}
</div>
{/* Интервалы и попытки */}
<div className="mb-1 text-xs font-semibold text-[var(--color-text)]">📊 Интервалы и попытки</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<SettingField label="Пауза после SMS (мин)" value={settings.delay_after_sms_minutes} onChange={(v) => updateSetting("delay_after_sms_minutes", parseInt(v) || 90)} />
<SettingField label="Между попытками (мин)" value={settings.retry_interval_minutes} onChange={(v) => updateSetting("retry_interval_minutes", parseInt(v) || 120)} />
<SettingField label="Попыток в день" value={settings.attempts_per_day} onChange={(v) => updateSetting("attempts_per_day", parseInt(v) || 3)} />
<SettingField label="Всего попыток" value={settings.max_attempts} onChange={(v) => updateSetting("max_attempts", parseInt(v) || 5)} />
<SettingField label="«Не нажал» ×N → менеджер" value={settings.no_input_escalate_after} onChange={(v) => updateSetting("no_input_escalate_after", parseInt(v) || 2)} />
<SettingField label="Попытки след. дня (HH:MM,HH:MM)" value={settings.next_day_attempt_times || ""} onChange={(v) => updateSetting("next_day_attempt_times", v)} />
</div>
{/* Время работы */}
<div className="mt-4 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
<div className="mb-3 text-xs font-semibold text-[var(--color-text)]">⏰ Окно звонков</div>
<div className="mb-3 flex items-center gap-2">
<select
value={settings.work_hours_start ?? 9}
onChange={(e) => updateSetting("work_hours_start", parseInt(e.target.value))}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
>
{Array.from({ length: 24 }, (_, h) => <option key={h} value={h}>{h}:00</option>)}
</select>
<span className="text-xs text-[var(--color-text-muted)]">—</span>
<select
value={settings.work_hours_end ?? 20}
onChange={(e) => updateSetting("work_hours_end", parseInt(e.target.value))}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
>
{Array.from({ length: 24 }, (_, h) => <option key={h} value={h}>{h}:00</option>)}
</select>
<span className="text-[10px] text-[var(--color-text-muted)]">по Москве</span>
</div>
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
Звонки — только в выбранные часы и дни. Попытка за окном переносится на 09:00 следующего рабочего дня.
</div>
</div>
<div className="mt-4 flex items-center gap-3">
<button
type="button"
onClick={() => updateSetting("enabled", !(settings.enabled ?? true))}
className={`relative h-7 w-12 rounded-full transition ${settings.enabled ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"}`}
>
<span className={`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition ${settings.enabled ? "left-[22px]" : "left-0.5"}`} />
</button>
<span className="text-xs font-medium text-[var(--color-text)]">
{settings.enabled ? "Кампания включена" : "Кампания выключена"}
</span>
<button
onClick={handleSave}
disabled={saving}
className="ml-auto rounded-xl bg-[var(--color-accent)] px-4 py-1.5 text-xs font-semibold text-white hover:opacity-90 disabled:opacity-50"
>
{saving ? "Сохранение…" : "Сохранить"}
</button>
</div>
</Panel>
)}
{/* ── Журнал звонков ─────────────────────────────────────────────── */}
<Panel className="p-4">
<div className="flex flex-wrap items-center gap-2">
<span className="mr-2 text-xs font-semibold text-[var(--color-text-muted)]">Всего: {logs.length}</span>
{Object.entries(OUTCOME_META).map(([k, m]) => {
const count = stats[k] || 0;
if (count === 0) return null;
return <Badge key={k} tone={m.tone}>{m.icon} {m.label}: {count}</Badge>;
})}
</div>
</Panel>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap gap-2">
<FilterButton active={filter === "all"} onClick={() => { setFilter("all"); setLogPage(1); }}>Все ({logs.length})</FilterButton>
{Object.entries(OUTCOME_META).map(([k, m]) => {
const count = stats[k] || 0;
if (count === 0) return null;
return <FilterButton key={k} active={filter === k} onClick={() => { setFilter(k); setLogPage(1); }}>{m.icon} {m.label} ({count})</FilterButton>;
})}
</div>
<label className="flex items-center gap-1.5 text-xs text-[var(--color-text-muted)]">
<input type="checkbox" checked={autoRefresh} onChange={(e) => setAutoRefresh(e.target.checked)} className="h-3.5 w-3.5 rounded border-[var(--color-border)]" />
Авто-обновление (30с)
</label>
</div>
<Panel className="p-0">
<div className="overflow-x-auto">
<div className="min-w-[900px]">
<div className="grid grid-cols-[minmax(140px,1.5fr)_minmax(100px,1fr)_minmax(60px,0.5fr)_minmax(90px,0.8fr)_minmax(60px,0.5fr)_minmax(130px,1.2fr)_minmax(110px,1fr)] gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.08em] text-[var(--color-text-muted)]">
<div className="px-3 py-1.5 font-medium">Телефон</div>
<div className="px-3 py-1.5 font-medium">Попытка</div>
<div className="px-3 py-1.5 font-medium">Длит.</div>
<div className="px-3 py-1.5 font-medium">Исход</div>
<div className="px-3 py-1.5 font-medium">Слот</div>
<div className="px-3 py-1.5 font-medium">Создано</div>
<div className="px-3 py-1.5 font-medium">Сценарий</div>
</div>
{logs.length === 0 ? (
<div className="px-4 py-6 text-xs text-[var(--color-text-muted)]">Нет записей</div>
) : (
(() => {
const totalPages = Math.ceil(logs.length / PER_PAGE);
const start = (logPage - 1) * PER_PAGE;
const pageLogs = logs.slice(start, start + PER_PAGE);
return pageLogs.map((l) => {
const m = OUTCOME_META[l.outcome] || { label: l.outcome, tone: "neutral", icon: "•" };
const slot = slotText(l);
return (
<div
key={l.id}
onClick={() => l.order_group_id && navigate("/dashboard/group/" + l.order_group_id)}
className={`grid grid-cols-[minmax(140px,1.5fr)_minmax(100px,1fr)_minmax(60px,0.5fr)_minmax(90px,0.8fr)_minmax(60px,0.5fr)_minmax(130px,1.2fr)_minmax(110px,1fr)] gap-0 border-t border-[var(--color-border)] text-xs hover:bg-[var(--color-accent-soft)] ${l.order_group_id ? "cursor-pointer" : ""}`}
>
<div className="px-3 py-1.5 text-[var(--color-text)]">
{fmtPhone(l.customer_phone)}
{l.was_test_mode && <div className="text-[10px] text-[var(--color-warning)]">🧪 тест</div>}
</div>
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">{l.attempt_no}</div>
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">{l.duration_sec ? `${l.duration_sec}с` : "—"}</div>
<div className="px-3 py-1.5">
<Badge tone={m.tone}>{m.icon} {m.label}</Badge>
</div>
<div className="px-3 py-1.5 text-[var(--color-text)]">{slot || "—"}</div>
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">
{fmtTime(l.created_at)}
{l.sip_code && <div className="text-[10px]">SIP {l.sip_code}</div>}
</div>
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">{l.scenario}</div>
</div>
);
});
})()
)}
</div>
</div>
</Panel>
{logs.length > PER_PAGE && (
<div className="flex items-center justify-center gap-2">
<button
onClick={() => setLogPage((p) => Math.max(1, p - 1))}
disabled={logPage <= 1}
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-40"
>← Назад</button>
<span className="text-xs text-[var(--color-text-muted)]">
Стр. {logPage} / {Math.ceil(logs.length / PER_PAGE)} · {logs.length} записей
</span>
<button
onClick={() => setLogPage((p) => Math.min(Math.ceil(logs.length / PER_PAGE), p + 1))}
disabled={logPage >= Math.ceil(logs.length / PER_PAGE)}
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-40"
>Вперёд →</button>
</div>
)}
</div>
);
};

View File

@ -26,6 +26,7 @@ import { Panel } from "../UI/Panel";
import { SkeletonPage } from "../UI/Loading"; import { SkeletonPage } from "../UI/Loading";
import { Pagination } from "../UI/Pagination"; import { Pagination } from "../UI/Pagination";
import { OrderFilters } from "../orders/OrderFilters"; import { OrderFilters } from "../orders/OrderFilters";
import { CallStatusBadge } from "../UI/CallStatusBadge";
import { formatDate, formatDateTime } from "../../utils/formatters"; import { formatDate, formatDateTime } from "../../utils/formatters";
const fmtDate = (d) => { const fmtDate = (d) => {
@ -206,6 +207,7 @@ const renderRow = (g, onSelectSet) => {
{smsTag.label} {smsTag.label}
</span> </span>
)} )}
<CallStatusBadge group={g} />
{stale && ( {stale && (
<span title="Обновлён >24ч назад" className="inline-block h-2 w-2 flex-shrink-0 rounded-full bg-[var(--color-warning)]"></span> <span title="Обновлён >24ч назад" className="inline-block h-2 w-2 flex-shrink-0 rounded-full bg-[var(--color-warning)]"></span>
)} )}

View File

@ -0,0 +1,185 @@
/**
* @file AutocallStatusCard.jsx
* @description Блок «Автодозвон» в карточке заказа.
* Показывает попытки звонков, слот, кнопки «Позвонить сейчас» / «Не звонить».
*/
import React, { useState, useEffect, useCallback } from "react";
import { Badge } from "../UI/Badge";
import { supabase } from "../../supabaseClient";
const OUTCOME_META = {
confirmed: { label: "Согласовал", tone: "accent", icon: "✅" },
confirmed_kept_previous:{ label: "Правка не доведена", tone: "warning", icon: "🔁" },
confirmed_silent: { label: "Подтвердил молчанием", tone: "accent", icon: "🤫" },
no_answer: { label: "Не взял трубку", tone: "neutral", icon: "📵" },
no_input_day: { label: "Взял, не нажал (день)", tone: "warning", icon: "🤐" },
no_input_half: { label: "Взял, не нажал (время)", tone: "warning", icon: "🤐" },
operator: { label: "Просил менеджера", tone: "danger", icon: "👤" },
opt_out: { label: "Отказ от звонков", tone: "danger", icon: "🚫" },
invalid_number: { label: "Неверный номер", tone: "danger", icon: "❌" },
error: { label: "Ошибка", tone: "danger", icon: "⚠️" },
};
const DAY_LABEL = { 1: "завтра", 2: "послезавтра" };
const HALF_LABEL = { 1: "первая половина дня", 2: "вторая половина дня" };
const fmtTime = (ts) => {
if (!ts) return "—";
try {
return new Date(ts).toLocaleString("ru-RU", {
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
});
} catch { return ts; }
};
const slotText = (l) => {
if (!l) return null;
const day = DAY_LABEL[l.dtmf_day];
const half = HALF_LABEL[l.dtmf_half];
if (day && half) return `${day}, ${half}`;
if (day) return day;
return null;
};
export const AutocallStatusCard = ({ order, userRole }) => {
const [calls, setCalls] = useState([]);
const [loading, setLoading] = useState(false);
const [toggling, setToggling] = useState(false);
const [queued, setQueued] = useState(false);
const canManage = ["mega_admin", "logistician"].includes(userRole);
const optOut = order.autocall_opt_out ?? order.autocallOptOut ?? false;
const isAutocall = order.delivery_date_source === "autocall" || order.deliveryDateSource === "autocall";
const loadCalls = useCallback(async () => {
if (!order?.id) return;
setLoading(true);
try {
const { data, error } = await supabase
.from("autocall_log")
.select("*")
.eq("order_group_id", order.id)
.order("created_at", { ascending: false })
.limit(5);
if (error) throw error;
setCalls(data || []);
} catch (e) {
// silent
} finally {
setLoading(false);
}
}, [order?.id]);
useEffect(() => { loadCalls(); }, [loadCalls]);
const handleCallNow = async () => {
if (!order?.id) return;
setQueued(true);
try {
// Пишем в очередь — планировщик подхватит и создаст call-файл (без ssh из edge fn)
const { error } = await supabase.from("autocall_queue").insert({
order_group_id: order.id,
phone: order.customer_phone || order.customerPhone || "",
scenario: "A",
});
if (error) throw error;
setTimeout(() => setQueued(false), 3000);
} catch (e) {
console.error("Queue call error:", e);
setQueued(false);
}
};
const handleToggleOptOut = async () => {
if (!order?.id) return;
setToggling(true);
try {
const { error } = await supabase
.from("order_groups")
.update({ autocall_opt_out: !optOut })
.eq("id", order.id);
if (error) throw error;
// Обновим локально
order.autocall_opt_out = !optOut;
setToggling(false);
} catch (e) {
console.error("Toggle opt-out error:", e);
setToggling(false);
}
};
const lastCall = calls[0];
const lastMeta = lastCall ? OUTCOME_META[lastCall.outcome] : null;
const lastSlot = lastCall ? slotText(lastCall) : null;
return (
<div className="p-4">
<div className="mb-3">
<h3 className="text-sm font-semibold text-[var(--color-text)]">📞 Автодозвон</h3>
</div>
{/* Итог */}
<div className="mb-3 flex items-center gap-2">
{optOut ? (
<Badge tone="danger">🚫 Отказ от звонков</Badge>
) : isAutocall ? (
<Badge tone="accent">🤖 Согласовано роботом</Badge>
) : calls.length > 0 ? (
<Badge tone={lastMeta?.tone || "neutral"}>{lastMeta?.icon} {lastMeta?.label}</Badge>
) : (
<Badge tone="neutral">Не звонили</Badge>
)}
{lastSlot && <span className="text-xs text-[var(--color-text)]">{lastSlot}</span>}
</div>
{/* Попытки */}
{calls.length > 0 ? (
<div className="space-y-1.5">
{calls.map((c) => {
const m = OUTCOME_META[c.outcome] || { label: c.outcome, tone: "neutral", icon: "•" };
const slot = slotText(c);
return (
<div key={c.id} className="flex items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1.5 text-[11px]">
<span className="text-[var(--color-text-muted)]">{fmtTime(c.created_at)}</span>
<Badge tone={m.tone}>{m.icon} {m.label}</Badge>
{slot && <span className="text-[var(--color-text)]">{slot}</span>}
{c.was_test_mode && <span className="text-[10px] text-[var(--color-warning)]">🧪 тест</span>}
</div>
);
})}
</div>
) : (
<div className="text-xs text-[var(--color-text-muted)]">Звонков не было</div>
)}
{/* Подсказка о правке */}
{lastCall?.changed && (
<div className="mt-2 rounded-lg bg-[rgba(245,158,11,0.08)] px-2 py-1.5 text-[11px] text-[var(--color-warning)]">
⚠ Клиент правил выбор в звонке. При сомнении — перезвоните.
</div>
)}
{/* Кнопки */}
{canManage && (
<div className="mt-3 flex gap-2 border-t border-[var(--color-border)] pt-3">
<button
type="button"
onClick={handleCallNow}
disabled={queued || optOut}
className="rounded-xl border border-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-[var(--color-accent)] hover:bg-[var(--color-accent-soft)] disabled:opacity-50"
>
{queued ? "✓ В очереди" : "📞 Позвонить сейчас"}
</button>
<button
type="button"
onClick={handleToggleOptOut}
disabled={toggling}
className="rounded-xl border border-[var(--color-border)] px-3 py-1.5 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-50"
>
{optOut ? "🔔 Вернуть в автодозвон" : "🚫 Не звонить"}
</button>
</div>
)}
</div>
);
};

View File

@ -77,6 +77,7 @@ import {
normalizeNom, normalizeNom,
} from "../../utils/deliveryUtils"; } from "../../utils/deliveryUtils";
import { SmsStatusCard } from "./SmsStatusCard"; import { SmsStatusCard } from "./SmsStatusCard";
import { AutocallStatusCard } from "./AutocallStatusCard";
import { OrderHistoryTimeline } from "./OrderHistoryTimeline"; import { OrderHistoryTimeline } from "./OrderHistoryTimeline";
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
@ -140,6 +141,7 @@ const DEFAULT_BLOCK_ORDER = [
"driver_assignment", "driver_assignment",
"shipment", "shipment",
"sms_status", "sms_status",
"autocall_status",
"status_actions", "status_actions",
"paid_storage", "paid_storage",
"delivery_link", "delivery_link",
@ -152,6 +154,7 @@ const BLOCK_TITLES = {
driver_assignment: "Назначение водителя", driver_assignment: "Назначение водителя",
shipment: "Отгрузка", shipment: "Отгрузка",
sms_status: "Статус SMS", sms_status: "Статус SMS",
autocall_status: "Автодозвон",
status_actions: "Действия по статусу", status_actions: "Действия по статусу",
paid_storage: "Платное хранение", paid_storage: "Платное хранение",
delivery_link: "Ссылка на согласование", delivery_link: "Ссылка на согласование",
@ -1083,6 +1086,12 @@ export const OrderDetailPanel = ({
); );
} }
if (blockKey === "autocall_status") {
return (
<AutocallStatusCard order={order} userRole={userRole} />
);
}
if (blockKey === "status_actions") { if (blockKey === "status_actions") {
return ( return (
<> <>

View File

@ -17,6 +17,7 @@ import { StopWordsPanel } from "../components/admin/StopWordsPanel";
import { ActionLogPanel } from "../components/admin/ActionLogPanel"; import { ActionLogPanel } from "../components/admin/ActionLogPanel";
import { SuggestionsPanel } from "../components/admin/SuggestionsPanel"; import { SuggestionsPanel } from "../components/admin/SuggestionsPanel";
import { SmsCampaignPanel } from "../components/admin/SmsCampaignPanel"; import { SmsCampaignPanel } from "../components/admin/SmsCampaignPanel";
import { CommunicationsPanel } from "../components/admin/CommunicationsPanel";
import { BusinessSchedulePanel } from "../components/admin/BusinessSchedulePanel"; import { BusinessSchedulePanel } from "../components/admin/BusinessSchedulePanel";
import { Panel } from "../components/UI/Panel"; import { Panel } from "../components/UI/Panel";
import { SkeletonPage, SkeletonTable } from "../components/UI/Loading"; import { SkeletonPage, SkeletonTable } from "../components/UI/Loading";
@ -36,7 +37,7 @@ const MEGA_ADMIN_NAV = [
{ key: "stop_words", label: "Стоп-слова", description: "Слова, исключаемые из клиентской карточки.", badge: null }, { key: "stop_words", label: "Стоп-слова", description: "Слова, исключаемые из клиентской карточки.", badge: null },
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null }, { key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null }, { key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
{ key: "sms_campaign", label: "SMS-кампании", description: "Логи и настройки SMS-рассылок.", badge: null }, { key: "sms_campaign", label: "Коммуникации", description: "SMS, автодозвон и аналитика обращений.", badge: null },
{ key: "schedule", label: "Расписание", description: "Рабочие дни доставки, самовывоза и SMS.", badge: null }, { key: "schedule", label: "Расписание", description: "Рабочие дни доставки, самовывоза и SMS.", badge: null },
]; ];
@ -189,7 +190,7 @@ const ALLOWED_DASHBOARD_ROLES = ["admin", "mega_admin", "manager", "logistician"
if (activeSection === "errors") return <div className="space-y-6 xl:space-y-8"><ErrorLogPanel /></div>; if (activeSection === "errors") return <div className="space-y-6 xl:space-y-8"><ErrorLogPanel /></div>;
if (activeSection === "action_log") return <div className="space-y-6 xl:space-y-8"><ActionLogPanel /></div>; if (activeSection === "action_log") return <div className="space-y-6 xl:space-y-8"><ActionLogPanel /></div>;
if (activeSection === "suggestions") return <div className="space-y-6 xl:space-y-8"><SuggestionsPanel /></div>; if (activeSection === "suggestions") return <div className="space-y-6 xl:space-y-8"><SuggestionsPanel /></div>;
if (activeSection === "sms_campaign") return <div className="space-y-6 xl:space-y-8"><SmsCampaignPanel /></div>; if (activeSection === "sms_campaign") return <div className="space-y-6 xl:space-y-8"><CommunicationsPanel /></div>;
if (activeSection === "schedule") return <div className="space-y-6 xl:space-y-8"><BusinessSchedulePanel /></div>; if (activeSection === "schedule") return <div className="space-y-6 xl:space-y-8"><BusinessSchedulePanel /></div>;
if (isLoading) { if (isLoading) {

View File

@ -293,8 +293,8 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
}; };
}; };
const ORDER_GROUP_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, delivery_link_code, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, delivery_invitation:delivery_invitations!order_groups_delivery_invitation_id_fkey(opened_at, access_count, last_accessed_at), called, called_at, called_comment, order_history(id, action, old_status, new_status, user_id, metadata, created_at)`; const ORDER_GROUP_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, delivery_link_code, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, autocall_opt_out, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, delivery_invitation:delivery_invitations!order_groups_delivery_invitation_id_fkey(opened_at, access_count, last_accessed_at), called, called_at, called_comment, order_history(id, action, old_status, new_status, user_id, metadata, created_at)`;
export const ORDER_GROUP_LIST_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, notification_status, sms_attempts, last_sms_error, first_sms_sent_at, second_sms_sent_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, called, called_at, called_comment`; export const ORDER_GROUP_LIST_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, notification_status, sms_attempts, last_sms_error, first_sms_sent_at, second_sms_sent_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, autocall_opt_out, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, called, called_at, called_comment`;
// Delivery statuses treated as "finished" — hidden by default in the board. // Delivery statuses treated as "finished" — hidden by default in the board.
const COMPLETED_DELIVERY_STATUSES = ["delivered", "picked_up", "cancelled"]; const COMPLETED_DELIVERY_STATUSES = ["delivered", "picked_up", "cancelled"];

View File

@ -0,0 +1,213 @@
import {
createServiceClient,
getCorsHeaders,
jsonResponse,
readJsonBody,
verifyInternalRequest,
} from "../_shared/security.ts";
const MAX_BODY_BYTES = 16 * 1024;
// Outcome enum — единственный источник истины для UI (см. docs/autocall-contract.md)
const OUTCOMES = new Set([
"confirmed",
"confirmed_kept_previous",
"confirmed_silent",
"no_answer",
"no_input_day",
"no_input_half",
"operator",
"opt_out",
"invalid_number",
"error",
]);
type AutocallBody = {
asterisk_uniqueid?: string;
order_group_id?: string | null;
campaign_type?: string;
customer_phone?: string;
attempt_no?: number;
scenario?: string;
call_started_at?: string;
answered_at?: string | null;
duration_sec?: number | null;
sip_code?: number | null;
channel_state?: string | null;
dtmf_day?: number | null;
dtmf_half?: number | null;
changed?: boolean;
outcome?: string;
was_test_mode?: boolean;
};
const DAY_LABEL: Record<number, string> = { 1: "завтра", 2: "послезавтра" };
const HALF_LABEL: Record<number, string> = { 1: "Первая половина дня", 2: "Вторая половина дня" };
function addDays(date: Date, days: number): string {
const d = new Date(date);
d.setDate(d.getDate() + days);
return d.toISOString().slice(0, 10);
}
Deno.serve(async (request) => {
if (request.method === "OPTIONS") {
const cors = getCorsHeaders(request, "private");
return cors ? new Response("ok", { headers: cors }) : jsonResponse({ error: "Origin not allowed" }, 403);
}
if (request.method !== "POST") {
return jsonResponse({ error: "Method not allowed" }, 405);
}
const cors = getCorsHeaders(request, "private") || {};
try {
const { body } = await readJsonBody<AutocallBody>(request, { maxBytes: MAX_BODY_BYTES });
await verifyInternalRequest(request, undefined, {
secretEnvNames: ["INTEGRATION_WEBHOOK_SECRET", "AUTOCALL_WEBHOOK_SECRET"],
tokenEnvNames: ["INTEGRATION_API_KEY"],
});
if (!body.outcome || !OUTCOMES.has(body.outcome)) {
return jsonResponse({ error: `invalid outcome: ${body.outcome}` }, 400, cors);
}
if (!body.asterisk_uniqueid) {
return jsonResponse({ error: "asterisk_uniqueid is required" }, 400, cors);
}
if (!body.customer_phone) {
return jsonResponse({ error: "customer_phone is required" }, 400, cors);
}
const supabase = createServiceClient();
const campaignType = body.campaign_type || "autocall_slot";
const attemptNo = body.attempt_no ?? 1;
const scenario = body.scenario || "A";
const wasTest = body.was_test_mode ?? false;
// ── Идемпотентность: повторный curl с тем же uniqueid не плодит строки ──
const { data: existing } = await supabase
.from("autocall_log")
.select("id")
.eq("asterisk_uniqueid", body.asterisk_uniqueid)
.maybeSingle();
if (existing) {
return jsonResponse({ ok: true, duplicate: true }, 200, cors);
}
// ── Вставка в лог ──
const { error: logError } = await supabase.from("autocall_log").insert({
order_group_id: body.order_group_id || null,
campaign_type: campaignType,
customer_phone: body.customer_phone,
attempt_no: attemptNo,
scenario,
call_started_at: body.call_started_at || new Date().toISOString(),
answered_at: body.answered_at || null,
duration_sec: body.duration_sec ?? null,
sip_code: body.sip_code ?? null,
channel_state: body.channel_state || null,
dtmf_day: body.dtmf_day ?? null,
dtmf_half: body.dtmf_half ?? null,
changed: body.changed ?? false,
outcome: body.outcome,
was_test_mode: wasTest,
asterisk_uniqueid: body.asterisk_uniqueid,
});
if (logError) throw logError;
// ── Найти заказ, если order_group_id не передан ──
let orderGroupId = body.order_group_id || null;
if (!orderGroupId) {
const { data: group } = await supabase
.from("order_groups")
.select("id")
.eq("customer_phone", body.customer_phone)
.in("delivery_status", ["pending_confirmation", "agreed"])
.order("created_at", { ascending: false })
.limit(1)
.maybeSingle();
if (group) orderGroupId = group.id;
}
if (orderGroupId) {
const { data: group } = await supabase
.from("order_groups")
.select("id, delivery_type, delivery_status, notification_status")
.eq("id", orderGroupId)
.single();
const isPickup = group?.delivery_type === "pickup";
// ── confirmed*: записываем слот ──
if (["confirmed", "confirmed_kept_previous", "confirmed_silent"].includes(body.outcome)) {
const dayOffset = body.dtmf_day === 2 ? 2 : 1;
const deliveryDate = addDays(new Date(), dayOffset);
const timeSlot = body.dtmf_half ? HALF_LABEL[body.dtmf_half] : null;
const dayLabel = body.dtmf_day ? DAY_LABEL[body.dtmf_day] : "завтра";
const comment = `Согласовано роботом: ${dayLabel}${timeSlot ? ", " + timeSlot.toLowerCase() : ""}`;
const update: Record<string, unknown> = {
delivery_date_source: "autocall",
delivery_status: "agreed",
notification_status: "confirmed",
called: true,
called_at: new Date().toISOString(),
called_comment: comment,
next_notification_check_at: null,
};
if (isPickup) {
update.pickup_date = deliveryDate;
if (timeSlot) update.pickup_time_slot = timeSlot;
} else {
update.delivery_date = deliveryDate;
if (timeSlot) update.delivery_time = timeSlot;
}
await supabase.from("order_groups").update(update).eq("id", orderGroupId);
}
// ── Эскалация ──
else if (["operator", "opt_out", "invalid_number"].includes(body.outcome)) {
const update: Record<string, unknown> = {
notification_status: "manual_required",
called: true,
called_at: new Date().toISOString(),
called_comment:
body.outcome === "operator"
? "Просил менеджера"
: body.outcome === "opt_out"
? "Отказался от автозвонков"
: "Неверный номер",
};
if (body.outcome === "opt_out") update.autocall_opt_out = true;
await supabase.from("order_groups").update(update).eq("id", orderGroupId);
}
// ── no_answer / no_input: решаем, эскалировать ли ──
else {
const { count: noInputCount } = await supabase
.from("autocall_log")
.select("id", { count: "exact", head: true })
.eq("order_group_id", orderGroupId)
.in("outcome", ["no_input_day", "no_input_half"]);
const { count: totalAttempts } = await supabase
.from("autocall_log")
.select("id", { count: "exact", head: true })
.eq("order_group_id", orderGroupId);
const noInputEscalate = 2; // из настроек, но пока константа
const maxAttempts = 5; // из настроек, но пока константа
const escalate = (noInputCount ?? 0) >= noInputEscalate || (totalAttempts ?? 0) >= maxAttempts;
if (escalate) {
await supabase.from("order_groups").update({
notification_status: "manual_required",
called: true,
called_at: new Date().toISOString(),
called_comment: "Не дозвонились / не нажал — менеджер",
}).eq("id", orderGroupId);
}
}
}
return jsonResponse({ ok: true }, 200, cors);
} catch (e) {
const status = (e as { status?: number }).status || 500;
return jsonResponse({ ok: false, error: (e as Error).message }, status, cors);
}
});

View File

@ -0,0 +1,76 @@
-- ============================================================
-- SuperSam: Автодозвон — базовая миграция (Фаза 1)
-- 01.09.2026. Аддитивная, безопасна для прод-базы (общий Supabase).
-- Применять: docker exec supabase-db psql -U supabase_admin -d postgres -f <file>
-- ============================================================
-- 1) Лог робо-звонков
create table if not exists public.autocall_log (
id bigint generated always as identity primary key,
order_group_id uuid references public.order_groups(id) on delete set null,
campaign_type text not null default 'autocall_slot',
customer_phone text not null,
attempt_no smallint not null,
scenario text not null check (scenario in ('A','B','C')),
call_started_at timestamptz not null,
answered_at timestamptz,
duration_sec smallint,
sip_code smallint,
channel_state text,
dtmf_day smallint check (dtmf_day in (1,2) or dtmf_day is null),
dtmf_half smallint check (dtmf_half in (1,2) or dtmf_half is null),
changed boolean not null default false,
outcome text not null check (outcome in (
'confirmed','confirmed_kept_previous','confirmed_silent',
'no_answer','no_input_day','no_input_half',
'operator','opt_out','invalid_number','error')),
was_test_mode boolean not null default false,
asterisk_uniqueid text,
created_at timestamptz not null default now()
);
create index if not exists idx_autocall_log_group on public.autocall_log(order_group_id, created_at desc);
create index if not exists idx_autocall_log_phone on public.autocall_log(customer_phone, created_at desc);
create index if not exists idx_autocall_log_outcome on public.autocall_log(outcome, created_at desc);
create index if not exists idx_autocall_log_uniq on public.autocall_log(asterisk_uniqueid);
comment on table public.autocall_log is 'Лог робо-звонков (автодозвон через АТС клиента). UI читает outcome.';
grant select on public.autocall_log to authenticated;
-- 2) order_groups: отказ от автозвонков
alter table public.order_groups add column if not exists autocall_opt_out boolean not null default false;
create index if not exists idx_order_groups_autocall_optout
on public.order_groups(autocall_opt_out) where autocall_opt_out = true;
-- 3) Новые колонки настроек кампаний
alter table public.sms_campaign_settings add column if not exists delay_after_sms_minutes int;
alter table public.sms_campaign_settings add column if not exists retry_interval_minutes int;
alter table public.sms_campaign_settings add column if not exists attempts_per_day int;
alter table public.sms_campaign_settings add column if not exists no_input_escalate_after int;
alter table public.sms_campaign_settings add column if not exists next_day_attempt_times text;
alter table public.sms_campaign_settings add column if not exists call_timeout_seconds int;
-- 4) Строка кампании автодозвона (выключена, тестовая)
insert into public.sms_campaign_settings
(campaign_type, enabled, test_mode, test_phone, work_hours_start, work_hours_end, work_days,
max_attempts, delay_after_sms_minutes, retry_interval_minutes, attempts_per_day,
no_input_escalate_after, next_day_attempt_times, call_timeout_seconds)
values
('autocall_slot', false, true, '9067029953', 9, 20, '1,2,3,4,5,6',
5, 90, 120, 3,
2, '10:00,14:00', 30)
on conflict (campaign_type) do nothing;
-- 5) Представление флагов для реестра (без N+1)
create or replace view public.order_group_comms_flags as
select
og.id,
og.autocall_opt_out,
og.delivery_date_source,
og.notification_status,
og.delivery_status,
(select l.outcome from public.autocall_log l where l.order_group_id = og.id order by l.created_at desc limit 1) as last_call_outcome,
(select l.changed from public.autocall_log l where l.order_group_id = og.id order by l.created_at desc limit 1) as last_call_changed,
(select count(*) from public.autocall_log l where l.order_group_id = og.id) as call_attempts,
(select count(*) from public.autocall_log l where l.order_group_id = og.id
and l.outcome in ('no_input_day','no_input_half')) as no_input_count
from public.order_groups og;
grant select on public.order_group_comms_flags to authenticated;