diff --git a/docs/superpowers/plans/2026-09-01-autocall-integration-detailed.md b/docs/superpowers/plans/2026-09-01-autocall-integration-detailed.md new file mode 100644 index 0000000..5b51e88 --- /dev/null +++ b/docs/superpowers/plans/2026-09-01-autocall-integration-detailed.md @@ -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: ` (тот же стиль, что у `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--.call`: + ``` + Channel: PJSIP/0@mmedia + Context: SS-Autocall-Slot + Extension: s + Priority: 1 + MaxRetries: 0 + WaitTime: 30 + Archive: yes + Setvar: SCENARIO=A + Setvar: CALLTYPE=dlv + Setvar: GROUP_ID= + Setvar: ATTEMPT= + Setvar: API_URL=https:///functions/v1/autocall-result + Setvar: API_KEY= + 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 `. + +### Поведение вкладок +- Состояние: `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/`. +- Пагинация 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-включения. diff --git a/src/components/UI/CallStatusBadge.jsx b/src/components/UI/CallStatusBadge.jsx new file mode 100644 index 0000000..9307ae9 --- /dev/null +++ b/src/components/UI/CallStatusBadge.jsx @@ -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 ( + + {badge.label} + + ); +}; diff --git a/src/components/admin/CommunicationsPanel.jsx b/src/components/admin/CommunicationsPanel.jsx new file mode 100644 index 0000000..a4c3039 --- /dev/null +++ b/src/components/admin/CommunicationsPanel.jsx @@ -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 ( +
+ {/* ── Вкладки каналов ─────────────────────────────────────────────── */} +
+ {CHANNELS.map((c) => ( + + ))} +
+ + {/* ── Контент активной вкладки ────────────────────────────────────── */} + +
+ ); +}; diff --git a/src/components/admin/analytics/CommsAnalyticsTab.jsx b/src/components/admin/analytics/CommsAnalyticsTab.jsx new file mode 100644 index 0000000..717ff82 --- /dev/null +++ b/src/components/admin/analytics/CommsAnalyticsTab.jsx @@ -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 }) => ( +
+
{label}
+
{value}
+
+); + +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 ( + +
Загрузка аналитики…
+
+ ); + } + + if (!callStats || callStats.total === 0) { + return ( + +
Нет данных для аналитики
+
+ ); + } + + return ( +
+ {/* ── Период ── */} +
+ {[ + { key: "7d", label: "7 дней" }, + { key: "14d", label: "14 дней" }, + { key: "30d", label: "30 дней" }, + { key: "all", label: "Всё время" }, + ].map((p) => ( + + ))} +
+ + {/* ── Сводка каналов ── */} +
+ + + + + + +
+ + {/* ── Графики ── */} +
+ +
📈 Звонки по дням
+ + + + + + + + + + + +
+ + +
🍩 По исходам
+ + + + {statusData.map((e, i) => )} + + + + + +
+
+ + {/* ── Согласовали с N-й попытки ── */} + {attemptData.length > 1 && ( + +
📊 Согласовали с N-й попытки
+ + + + + + + + + + + +
+ )} + + {/* ── Минуты ── */} + +
+ + + +
+
+ Минуты на линии — для оценки тарифа Miranda (в CDR стоимость не видна). +
+
+
+ ); +}; diff --git a/src/components/admin/autocall/AutocallChannelTab.jsx b/src/components/admin/autocall/AutocallChannelTab.jsx new file mode 100644 index 0000000..551eaa1 --- /dev/null +++ b/src/components/admin/autocall/AutocallChannelTab.jsx @@ -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 }) => ( + +); + +const FilterButton = ({ active, onClick, children }) => ( + +); + +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 ( + +
Загрузка…
+
+ ); + } + + return ( +
+ {/* ── Карточки кампаний звонков ─────────────────────────────────── */} +
+ {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 ( +
{ 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)]" + }`} + > +
+ {c.icon} + {c.live && ( + + )} +
+
{c.label}
+ {c.live ? ( +
+ + {!isEnabled ? "⏸ Выключена" : isTest ? "🧪 Тест" : "🚀 Боевой"} + +
+ ) : ( +
Скоро
+ )} +
+ ); + })} +
+ + {error && ( + +
{error}
+ +
+ )} + + {/* ── Настройки активной кампании ───────────────────────────────── */} + {settings && ( + +
+

+ {CAMPAIGNS.find((c) => c.key === activeCampaign)?.icon} {CAMPAIGNS.find((c) => c.key === activeCampaign)?.label} +

+ {saved && ✓ Сохранено} +
+ + {/* Режим тест/боевой */} +
+ + {settings.test_mode && ( +
+ updateSetting("test_phone", v)} /> +
+ )} +
+ + {/* Интервалы и попытки */} +
📊 Интервалы и попытки
+
+ updateSetting("delay_after_sms_minutes", parseInt(v) || 90)} /> + updateSetting("retry_interval_minutes", parseInt(v) || 120)} /> + updateSetting("attempts_per_day", parseInt(v) || 3)} /> + updateSetting("max_attempts", parseInt(v) || 5)} /> + updateSetting("no_input_escalate_after", parseInt(v) || 2)} /> + updateSetting("next_day_attempt_times", v)} /> +
+ + {/* Время работы */} +
+
⏰ Окно звонков
+
+ + — + + по Москве +
+
+ Звонки — только в выбранные часы и дни. Попытка за окном переносится на 09:00 следующего рабочего дня. +
+
+ +
+ + + {settings.enabled ? "Кампания включена" : "Кампания выключена"} + + +
+
+ )} + + {/* ── Журнал звонков ─────────────────────────────────────────────── */} + +
+ Всего: {logs.length} + {Object.entries(OUTCOME_META).map(([k, m]) => { + const count = stats[k] || 0; + if (count === 0) return null; + return {m.icon} {m.label}: {count}; + })} +
+
+ +
+
+ { setFilter("all"); setLogPage(1); }}>Все ({logs.length}) + {Object.entries(OUTCOME_META).map(([k, m]) => { + const count = stats[k] || 0; + if (count === 0) return null; + return { setFilter(k); setLogPage(1); }}>{m.icon} {m.label} ({count}); + })} +
+ +
+ + +
+
+
+
Телефон
+
Попытка
+
Длит.
+
Исход
+
Слот
+
Создано
+
Сценарий
+
+ {logs.length === 0 ? ( +
Нет записей
+ ) : ( + (() => { + 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 ( +
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" : ""}`} + > +
+ {fmtPhone(l.customer_phone)} + {l.was_test_mode &&
🧪 тест
} +
+
{l.attempt_no}
+
{l.duration_sec ? `${l.duration_sec}с` : "—"}
+
+ {m.icon} {m.label} +
+
{slot || "—"}
+
+ {fmtTime(l.created_at)} + {l.sip_code &&
SIP {l.sip_code}
} +
+
{l.scenario}
+
+ ); + }); + })() + )} +
+
+
+ + {logs.length > PER_PAGE && ( +
+ + + Стр. {logPage} / {Math.ceil(logs.length / PER_PAGE)} · {logs.length} записей + + +
+ )} +
+ ); +}; diff --git a/src/components/logistics/LogisticsReadinessBoard.jsx b/src/components/logistics/LogisticsReadinessBoard.jsx index 5328752..6424f38 100644 --- a/src/components/logistics/LogisticsReadinessBoard.jsx +++ b/src/components/logistics/LogisticsReadinessBoard.jsx @@ -26,6 +26,7 @@ import { Panel } from "../UI/Panel"; import { SkeletonPage } from "../UI/Loading"; import { Pagination } from "../UI/Pagination"; import { OrderFilters } from "../orders/OrderFilters"; +import { CallStatusBadge } from "../UI/CallStatusBadge"; import { formatDate, formatDateTime } from "../../utils/formatters"; const fmtDate = (d) => { @@ -206,6 +207,7 @@ const renderRow = (g, onSelectSet) => { {smsTag.label} )} + {stale && ( )} diff --git a/src/components/orders/AutocallStatusCard.jsx b/src/components/orders/AutocallStatusCard.jsx new file mode 100644 index 0000000..a853bc7 --- /dev/null +++ b/src/components/orders/AutocallStatusCard.jsx @@ -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 ( +
+
+

📞 Автодозвон

+
+ + {/* Итог */} +
+ {optOut ? ( + 🚫 Отказ от звонков + ) : isAutocall ? ( + 🤖 Согласовано роботом + ) : calls.length > 0 ? ( + {lastMeta?.icon} {lastMeta?.label} + ) : ( + Не звонили + )} + {lastSlot && {lastSlot}} +
+ + {/* Попытки */} + {calls.length > 0 ? ( +
+ {calls.map((c) => { + const m = OUTCOME_META[c.outcome] || { label: c.outcome, tone: "neutral", icon: "•" }; + const slot = slotText(c); + return ( +
+ {fmtTime(c.created_at)} + {m.icon} {m.label} + {slot && {slot}} + {c.was_test_mode && 🧪 тест} +
+ ); + })} +
+ ) : ( +
Звонков не было
+ )} + + {/* Подсказка о правке */} + {lastCall?.changed && ( +
+ ⚠ Клиент правил выбор в звонке. При сомнении — перезвоните. +
+ )} + + {/* Кнопки */} + {canManage && ( +
+ + +
+ )} +
+ ); +}; diff --git a/src/components/orders/OrderDetailPanel.jsx b/src/components/orders/OrderDetailPanel.jsx index 9074fbd..9e8eb6d 100644 --- a/src/components/orders/OrderDetailPanel.jsx +++ b/src/components/orders/OrderDetailPanel.jsx @@ -77,6 +77,7 @@ import { normalizeNom, } from "../../utils/deliveryUtils"; import { SmsStatusCard } from "./SmsStatusCard"; +import { AutocallStatusCard } from "./AutocallStatusCard"; import { OrderHistoryTimeline } from "./OrderHistoryTimeline"; import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; @@ -140,6 +141,7 @@ const DEFAULT_BLOCK_ORDER = [ "driver_assignment", "shipment", "sms_status", + "autocall_status", "status_actions", "paid_storage", "delivery_link", @@ -152,6 +154,7 @@ const BLOCK_TITLES = { driver_assignment: "Назначение водителя", shipment: "Отгрузка", sms_status: "Статус SMS", + autocall_status: "Автодозвон", status_actions: "Действия по статусу", paid_storage: "Платное хранение", delivery_link: "Ссылка на согласование", @@ -1083,6 +1086,12 @@ export const OrderDetailPanel = ({ ); } + if (blockKey === "autocall_status") { + return ( + + ); + } + if (blockKey === "status_actions") { return ( <> diff --git a/src/pages/DashboardPage.jsx b/src/pages/DashboardPage.jsx index bd653d0..401ab83 100644 --- a/src/pages/DashboardPage.jsx +++ b/src/pages/DashboardPage.jsx @@ -17,6 +17,7 @@ import { StopWordsPanel } from "../components/admin/StopWordsPanel"; import { ActionLogPanel } from "../components/admin/ActionLogPanel"; import { SuggestionsPanel } from "../components/admin/SuggestionsPanel"; import { SmsCampaignPanel } from "../components/admin/SmsCampaignPanel"; +import { CommunicationsPanel } from "../components/admin/CommunicationsPanel"; import { BusinessSchedulePanel } from "../components/admin/BusinessSchedulePanel"; import { Panel } from "../components/UI/Panel"; import { SkeletonPage, SkeletonTable } from "../components/UI/Loading"; @@ -36,7 +37,7 @@ const MEGA_ADMIN_NAV = [ { key: "stop_words", label: "Стоп-слова", description: "Слова, исключаемые из клиентской карточки.", badge: null }, { key: "action_log", 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 }, ]; @@ -189,7 +190,7 @@ const ALLOWED_DASHBOARD_ROLES = ["admin", "mega_admin", "manager", "logistician" if (activeSection === "errors") return
; if (activeSection === "action_log") return
; if (activeSection === "suggestions") return
; - if (activeSection === "sms_campaign") return
; + if (activeSection === "sms_campaign") return
; if (activeSection === "schedule") return
; if (isLoading) { diff --git a/src/services/supabase/orderGroupRepository.js b/src/services/supabase/orderGroupRepository.js index 821bc6e..64dd5dd 100644 --- a/src/services/supabase/orderGroupRepository.js +++ b/src/services/supabase/orderGroupRepository.js @@ -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)`; -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`; +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, 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. const COMPLETED_DELIVERY_STATUSES = ["delivered", "picked_up", "cancelled"]; diff --git a/supabase/functions/autocall-result/index.ts b/supabase/functions/autocall-result/index.ts new file mode 100644 index 0000000..7fb1702 --- /dev/null +++ b/supabase/functions/autocall-result/index.ts @@ -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 = { 1: "завтра", 2: "послезавтра" }; +const HALF_LABEL: Record = { 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(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 = { + 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 = { + 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); + } +}); diff --git a/supabase/migrations/20260901_autocall_base.sql b/supabase/migrations/20260901_autocall_base.sql new file mode 100644 index 0000000..3b48830 --- /dev/null +++ b/supabase/migrations/20260901_autocall_base.sql @@ -0,0 +1,76 @@ +-- ============================================================ +-- SuperSam: Автодозвон — базовая миграция (Фаза 1) +-- 01.09.2026. Аддитивная, безопасна для прод-базы (общий Supabase). +-- Применять: docker exec supabase-db psql -U supabase_admin -d postgres -f +-- ============================================================ + +-- 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;