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 { SkeletonPage } from "../UI/Loading";
|
||||||
import { Pagination } from "../UI/Pagination";
|
import { Pagination } from "../UI/Pagination";
|
||||||
import { OrderFilters } from "../orders/OrderFilters";
|
import { OrderFilters } from "../orders/OrderFilters";
|
||||||
|
import { CallStatusBadge } from "../UI/CallStatusBadge";
|
||||||
import { formatDate, formatDateTime } from "../../utils/formatters";
|
import { formatDate, formatDateTime } from "../../utils/formatters";
|
||||||
|
|
||||||
const fmtDate = (d) => {
|
const fmtDate = (d) => {
|
||||||
|
|
@ -206,6 +207,7 @@ const renderRow = (g, onSelectSet) => {
|
||||||
{smsTag.label}
|
{smsTag.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<CallStatusBadge group={g} />
|
||||||
{stale && (
|
{stale && (
|
||||||
<span title="Обновлён >24ч назад" className="inline-block h-2 w-2 flex-shrink-0 rounded-full bg-[var(--color-warning)]"></span>
|
<span title="Обновлён >24ч назад" className="inline-block h-2 w-2 flex-shrink-0 rounded-full bg-[var(--color-warning)]"></span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
normalizeNom,
|
||||||
} from "../../utils/deliveryUtils";
|
} from "../../utils/deliveryUtils";
|
||||||
import { SmsStatusCard } from "./SmsStatusCard";
|
import { SmsStatusCard } from "./SmsStatusCard";
|
||||||
|
import { AutocallStatusCard } from "./AutocallStatusCard";
|
||||||
import { OrderHistoryTimeline } from "./OrderHistoryTimeline";
|
import { OrderHistoryTimeline } from "./OrderHistoryTimeline";
|
||||||
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
|
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
|
||||||
import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||||
|
|
@ -140,6 +141,7 @@ const DEFAULT_BLOCK_ORDER = [
|
||||||
"driver_assignment",
|
"driver_assignment",
|
||||||
"shipment",
|
"shipment",
|
||||||
"sms_status",
|
"sms_status",
|
||||||
|
"autocall_status",
|
||||||
"status_actions",
|
"status_actions",
|
||||||
"paid_storage",
|
"paid_storage",
|
||||||
"delivery_link",
|
"delivery_link",
|
||||||
|
|
@ -152,6 +154,7 @@ const BLOCK_TITLES = {
|
||||||
driver_assignment: "Назначение водителя",
|
driver_assignment: "Назначение водителя",
|
||||||
shipment: "Отгрузка",
|
shipment: "Отгрузка",
|
||||||
sms_status: "Статус SMS",
|
sms_status: "Статус SMS",
|
||||||
|
autocall_status: "Автодозвон",
|
||||||
status_actions: "Действия по статусу",
|
status_actions: "Действия по статусу",
|
||||||
paid_storage: "Платное хранение",
|
paid_storage: "Платное хранение",
|
||||||
delivery_link: "Ссылка на согласование",
|
delivery_link: "Ссылка на согласование",
|
||||||
|
|
@ -1083,6 +1086,12 @@ export const OrderDetailPanel = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (blockKey === "autocall_status") {
|
||||||
|
return (
|
||||||
|
<AutocallStatusCard order={order} userRole={userRole} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (blockKey === "status_actions") {
|
if (blockKey === "status_actions") {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { StopWordsPanel } from "../components/admin/StopWordsPanel";
|
||||||
import { ActionLogPanel } from "../components/admin/ActionLogPanel";
|
import { ActionLogPanel } from "../components/admin/ActionLogPanel";
|
||||||
import { SuggestionsPanel } from "../components/admin/SuggestionsPanel";
|
import { SuggestionsPanel } from "../components/admin/SuggestionsPanel";
|
||||||
import { SmsCampaignPanel } from "../components/admin/SmsCampaignPanel";
|
import { SmsCampaignPanel } from "../components/admin/SmsCampaignPanel";
|
||||||
|
import { CommunicationsPanel } from "../components/admin/CommunicationsPanel";
|
||||||
import { BusinessSchedulePanel } from "../components/admin/BusinessSchedulePanel";
|
import { BusinessSchedulePanel } from "../components/admin/BusinessSchedulePanel";
|
||||||
import { Panel } from "../components/UI/Panel";
|
import { Panel } from "../components/UI/Panel";
|
||||||
import { SkeletonPage, SkeletonTable } from "../components/UI/Loading";
|
import { SkeletonPage, SkeletonTable } from "../components/UI/Loading";
|
||||||
|
|
@ -36,7 +37,7 @@ const MEGA_ADMIN_NAV = [
|
||||||
{ key: "stop_words", label: "Стоп-слова", description: "Слова, исключаемые из клиентской карточки.", badge: null },
|
{ key: "stop_words", label: "Стоп-слова", description: "Слова, исключаемые из клиентской карточки.", badge: null },
|
||||||
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
|
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
|
||||||
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
|
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
|
||||||
{ key: "sms_campaign", label: "SMS-кампании", description: "Логи и настройки SMS-рассылок.", badge: null },
|
{ key: "sms_campaign", label: "Коммуникации", description: "SMS, автодозвон и аналитика обращений.", badge: null },
|
||||||
{ key: "schedule", label: "Расписание", description: "Рабочие дни доставки, самовывоза и SMS.", badge: null },
|
{ key: "schedule", label: "Расписание", description: "Рабочие дни доставки, самовывоза и SMS.", badge: null },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -189,7 +190,7 @@ const ALLOWED_DASHBOARD_ROLES = ["admin", "mega_admin", "manager", "logistician"
|
||||||
if (activeSection === "errors") return <div className="space-y-6 xl:space-y-8"><ErrorLogPanel /></div>;
|
if (activeSection === "errors") return <div className="space-y-6 xl:space-y-8"><ErrorLogPanel /></div>;
|
||||||
if (activeSection === "action_log") return <div className="space-y-6 xl:space-y-8"><ActionLogPanel /></div>;
|
if (activeSection === "action_log") return <div className="space-y-6 xl:space-y-8"><ActionLogPanel /></div>;
|
||||||
if (activeSection === "suggestions") return <div className="space-y-6 xl:space-y-8"><SuggestionsPanel /></div>;
|
if (activeSection === "suggestions") return <div className="space-y-6 xl:space-y-8"><SuggestionsPanel /></div>;
|
||||||
if (activeSection === "sms_campaign") return <div className="space-y-6 xl:space-y-8"><SmsCampaignPanel /></div>;
|
if (activeSection === "sms_campaign") return <div className="space-y-6 xl:space-y-8"><CommunicationsPanel /></div>;
|
||||||
if (activeSection === "schedule") return <div className="space-y-6 xl:space-y-8"><BusinessSchedulePanel /></div>;
|
if (activeSection === "schedule") return <div className="space-y-6 xl:space-y-8"><BusinessSchedulePanel /></div>;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|
|
||||||
|
|
@ -293,8 +293,8 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const ORDER_GROUP_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, delivery_link_code, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, delivery_invitation:delivery_invitations!order_groups_delivery_invitation_id_fkey(opened_at, access_count, last_accessed_at), called, called_at, called_comment, order_history(id, action, old_status, new_status, user_id, metadata, created_at)`;
|
const ORDER_GROUP_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, delivery_link_code, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, autocall_opt_out, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, delivery_invitation:delivery_invitations!order_groups_delivery_invitation_id_fkey(opened_at, access_count, last_accessed_at), called, called_at, called_comment, order_history(id, action, old_status, new_status, user_id, metadata, created_at)`;
|
||||||
export const ORDER_GROUP_LIST_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, notification_status, sms_attempts, last_sms_error, first_sms_sent_at, second_sms_sent_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, called, called_at, called_comment`;
|
export const ORDER_GROUP_LIST_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, notification_status, sms_attempts, last_sms_error, first_sms_sent_at, second_sms_sent_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, autocall_opt_out, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, called, called_at, called_comment`;
|
||||||
|
|
||||||
// Delivery statuses treated as "finished" — hidden by default in the board.
|
// Delivery statuses treated as "finished" — hidden by default in the board.
|
||||||
const COMPLETED_DELIVERY_STATUSES = ["delivered", "picked_up", "cancelled"];
|
const COMPLETED_DELIVERY_STATUSES = ["delivered", "picked_up", "cancelled"];
|
||||||
|
|
|
||||||
|
|
@ -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