feat(autocall): раздел «Коммуникации» с вкладками SMS/Автодозвон/Аналитика, бейджи, карточка заказа, edge fn autocall-result, миграция БД
This commit is contained in:
parent
7ce06873ce
commit
6435679161
|
|
@ -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-включения.
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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}
|
||||
</span>
|
||||
)}
|
||||
<CallStatusBadge group={g} />
|
||||
{stale && (
|
||||
<span title="Обновлён >24ч назад" className="inline-block h-2 w-2 flex-shrink-0 rounded-full bg-[var(--color-warning)]"></span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 (
|
||||
<AutocallStatusCard order={order} userRole={userRole} />
|
||||
);
|
||||
}
|
||||
|
||||
if (blockKey === "status_actions") {
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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 <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 === "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 (isLoading) {
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
@ -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;
|
||||
Loading…
Reference in New Issue