/** * @file TestCallPanel.jsx * @description Блок «Тестовый звонок» во вкладке Автодозвон (только для мегаадмина). * Мегаадмин вводит произвольный номер → пишем в autocall_queue (is_test_call=true, * без привязки к заказу) → планировщик создаёт call-файл на АТС клиента. * Показываем живой статус: в очереди → дозвон → результат выбора (окно результата). * Результат читаем из autocall_log по номеру с момента запуска (поллинг). */ import React, { useState, useEffect, useRef, useCallback } from "react"; import { supabase } from "../../../supabaseClient"; import { Panel } from "../../UI/Panel"; import { Badge } from "../../UI/Badge"; const OUTCOME_META = { in_progress: { label: "Идёт звонок…", tone: "neutral", icon: "🔔" }, queued: { label: "В очереди…", tone: "neutral", icon: "⏳" }, 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: "🤐" }, invalid_number: { label: "Неверный номер", tone: "danger", icon: "❌" }, error: { label: "Ошибка", tone: "danger", icon: "⚠️" }, }; const DAY_LABEL = { 1: "завтра", 2: "послезавтра" }; const HALF_LABEL = { 1: "первая половина дня", 2: "вторая половина дня" }; 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 fmtTime = (ts) => { if (!ts) return ""; try { return new Date(ts).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit", }); } catch { return ""; } }; export const TestCallPanel = () => { const [phone, setPhone] = useState(""); const [callType, setCallType] = useState("dlv"); // dlv = доставка, pkp = самовывоз const [status, setStatus] = useState("idle"); // idle | queueing | queued | dialing | done | error const [result, setResult] = useState(null); // последняя строка autocall_log для этого теста const [startedAt, setStartedAt] = useState(null); const [lastTests, setLastTests] = useState([]); // последние тест-звонки (для контекста) const pollRef = useRef(null); // При монтировании показываем последние тестовые звонки, // чтобы было видно, что система живая и что было в прошлый раз. const loadLastTests = useCallback(async () => { try { const { data, error } = await supabase .from("autocall_log") .select("*") .eq("was_test_mode", true) .order("created_at", { ascending: false }) .limit(3); if (!error && data) setLastTests(data); } catch { /* глушим */ } }, []); useEffect(() => { loadLastTests(); }, [loadLastTests]); const stopPolling = useCallback(() => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } }, []); const startCall = async () => { const digits = String(phone || "").replace(/\D/g, ""); if (digits.length < 10) { setStatus("error"); setResult({ outcome: "error", note: "Введите корректный номер (не менее 10 цифр)" }); return; } stopPolling(); setStatus("queueing"); setResult({ outcome: "queued" }); const now = new Date().toISOString(); setStartedAt(now); try { // Ручной тест-звонок: без привязки к заказу, is_test_call=true. // Мегаадмин-запрос — прямой insert (RLS на autocall_queue выключен). const { error } = await supabase.from("autocall_queue").insert({ order_group_id: null, phone: "7" + (digits.length === 10 ? digits : digits.slice(digits.length - 10)), scenario: "A", call_type: callType, is_test_call: true, }); if (error) throw error; setStatus("queued"); setResult({ outcome: "queued", note: "Запрос в очереди. Планировщик обрабатывает её раз в ~5 минут — ожидайте звонок." }); // Начинаем поллить результат по нашему номеру с момента запуска pollRef.current = setInterval(() => pollResult(now), 4000); } catch (e) { console.error("Test call queue error:", e); setStatus("error"); setResult({ outcome: "error", note: `Не удалось поставить в очередь: ${e.message}` }); } }; const pollResult = useCallback(async (since) => { const norm = "7" + String(phone || "").replace(/\D/g, "").slice(-10); try { const { data, error } = await supabase .from("autocall_log") .select("*") .eq("was_test_mode", true) .eq("customer_phone", norm) .gte("created_at", since) .order("created_at", { ascending: false }) .limit(1); if (error) throw error; if (data && data.length) { const row = data[0]; setResult(row); setStatus(row.outcome === "confirmed" || row.outcome === "confirmed_kept_previous" || row.outcome === "confirmed_silent" ? "done" : (["no_answer","no_input_day","no_input_half","operator","opt_out","invalid_number","error"].includes(row.outcome) ? "done" : "dialing")); // Как только есть финальный исход (не in_progress) — стоп if (["confirmed","confirmed_kept_previous","confirmed_silent","no_answer","no_input_day","no_input_half","operator","opt_out","invalid_number","error"].includes(row.outcome)) { stopPolling(); loadLastTests(); } } else { setStatus((s) => (s === "dialing" ? "dialing" : "queued")); } } catch (e) { // глуши, поллинг продолжит } // eslint-disable-next-line react-hooks/exhaustive-deps }, [phone, stopPolling, loadLastTests]); useEffect(() => () => stopPolling(), [stopPolling]); const m = OUTCOME_META[result?.outcome] || OUTCOME_META.in_progress; const slotText = result?.dtmf_day || result?.dtmf_half ? `${DAY_LABEL[result.dtmf_day] || ""}${result.dtmf_day && result.dtmf_half ? ", " : ""}${HALF_LABEL[result.dtmf_half] || ""}`.trim() || "—" : null; return (

📞 Тестовый звонок

🧪 мегаадмин

Позвонит на введённый номер через АТС клиента и покажет, какой выбор сделал человек после голосового меню.

{/* Тип сценария: доставка или самовывоз — от него зависит озвучка робота */}
Сценарий: {[ { key: "dlv", label: "🚚 Доставка" }, { key: "pkp", label: "🏪 Самовывоз" }, ].map((t) => ( ))}
setPhone(e.target.value)} placeholder="+7 9xx xxx-xx-xx" disabled={status === "queueing" || status === "queued" || status === "dialing"} className="flex-1 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 disabled:opacity-50" /> {(status === "queued" || status === "dialing" || status === "done") && ( )}
{/* ── Окно результата ─────────────────────────────────────────────── */} {(status !== "idle" || result) && (
{m.icon} {m.label} {(status === "queued" || status === "dialing") && ( )}
{result?.note &&
{result.note}
} {result?.order_group_id && (
Заказ: {String(result.order_group_id).slice(0, 8)}…
)} {(result?.dtmf_day || result?.dtmf_half) && (
Выбранный слот: {slotText}
)} {result?.changed && (
Клиент правил выбор (сначала подтвердил, потом поменял)
)} {result?.created_at && (
{fmtTime(result.created_at)}
)}
)} {/* ── Последние тест-звонки (контекст: что система уже делала) ───────── */} {lastTests.length > 0 && (
Последние тест-звонки
{lastTests.map((t) => { const tm = OUTCOME_META[t.outcome] || OUTCOME_META.error; const slot = t.dtmf_day || t.dtmf_half ? `${DAY_LABEL[t.dtmf_day] || ""}${t.dtmf_day && t.dtmf_half ? ", " : ""}${HALF_LABEL[t.dtmf_half] || ""}`.trim() || "—" : null; return (
{fmtPhone(t.customer_phone)} {t.call_type === "pkp" ? "🏪 самовывоз" : "🚚 доставка"} {tm.icon} {tm.label} {slot && · {slot}} · {fmtTime(t.created_at)}
); })}
)}
); };