supersam/src/components/admin/autocall/TestCallPanel.jsx

266 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @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 (
<Panel className="p-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold text-[var(--color-text)]">📞 Тестовый звонок</h3>
<Badge tone="warning">🧪 мегаадмин</Badge>
</div>
<p className="mb-3 text-xs text-[var(--color-text-muted)]">
Позвонит на введённый номер через АТС клиента и покажет, какой выбор сделал человек после голосового меню.
</p>
{/* Тип сценария: доставка или самовывоз — от него зависит озвучка робота */}
<div className="mb-2 flex items-center gap-2">
<span className="text-[10px] font-medium text-[var(--color-text-muted)]">Сценарий:</span>
{[
{ key: "dlv", label: "🚚 Доставка" },
{ key: "pkp", label: "🏪 Самовывоз" },
].map((t) => (
<button
key={t.key}
type="button"
onClick={() => setCallType(t.key)}
disabled={status === "queueing" || status === "queued" || status === "dialing"}
className={`rounded-full px-3 py-1 text-xs font-medium transition disabled:opacity-50 ${
callType === t.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)]"
}`}
>
{t.label}
</button>
))}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<input
type="tel"
value={phone}
onChange={(e) => 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"
/>
<button
onClick={startCall}
disabled={status === "queueing" || status === "queued" || status === "dialing"}
className="rounded-xl bg-[var(--color-accent)] px-4 py-1.5 text-xs font-semibold text-white hover:opacity-90 disabled:opacity-50"
>
{status === "queueing" ? "Отправка…" : status === "queued" || status === "dialing" ? "Звоним…" : "🔊 Позвонить"}
</button>
{(status === "queued" || status === "dialing" || status === "done") && (
<button
onClick={() => { stopPolling(); setStatus("idle"); setResult(null); setStartedAt(null); }}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs font-medium text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
>
Сбросить
</button>
)}
</div>
{/* ── Окно результата ─────────────────────────────────────────────── */}
{(status !== "idle" || result) && (
<div className={`mt-3 rounded-xl border p-3 text-xs ${
m.tone === "accent" ? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
: m.tone === "danger" ? "border-[var(--color-danger)] bg-[rgba(239,68,68,0.06)]"
: m.tone === "warning" ? "border-[var(--color-warning)] bg-[rgba(191,123,33,0.08)]"
: "border-[var(--color-border)] bg-[var(--color-surface)]"
}`}>
<div className="flex items-center gap-2">
<span className="text-base">{m.icon}</span>
<span className="font-semibold text-[var(--color-text)]">{m.label}</span>
{(status === "queued" || status === "dialing") && (
<span className="ml-auto h-3 w-3 animate-spin rounded-full border-2 border-[var(--color-border)] border-t-[var(--color-accent)]" />
)}
</div>
{result?.note && <div className="mt-1 text-[var(--color-text-muted)]">{result.note}</div>}
{result?.order_group_id && (
<div className="mt-1 text-[var(--color-text-muted)]">Заказ: {String(result.order_group_id).slice(0, 8)}…</div>
)}
{(result?.dtmf_day || result?.dtmf_half) && (
<div className="mt-1.5">
<span className="text-[var(--color-text-muted)]">Выбранный слот: </span>
<span className="font-semibold text-[var(--color-text)]">{slotText}</span>
</div>
)}
{result?.changed && (
<div className="mt-1 text-[var(--color-warning)]">Клиент правил выбор (сначала подтвердил, потом поменял)</div>
)}
{result?.created_at && (
<div className="mt-1 text-[10px] text-[var(--color-text-muted)]">{fmtTime(result.created_at)}</div>
)}
</div>
)}
{/* ── Последние тест-звонки (контекст: что система уже делала) ───────── */}
{lastTests.length > 0 && (
<div className="mt-3">
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-[var(--color-text-muted)]">
Последние тест-звонки
</div>
<div className="space-y-1">
{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 (
<div key={t.id} className="flex flex-wrap items-center gap-x-3 gap-y-0.5 rounded-lg bg-[var(--color-surface)] px-2 py-1.5 text-[11px]">
<span className="font-medium text-[var(--color-text)]">{fmtPhone(t.customer_phone)}</span>
<span className="text-[10px] text-[var(--color-text-muted)]">{t.call_type === "pkp" ? "🏪 самовывоз" : "🚚 доставка"}</span>
<span className={`text-[var(--color-text-muted)]`}>{tm.icon} {tm.label}</span>
{slot && <span className="text-[var(--color-text-muted)]">· {slot}</span>}
<span className="text-[10px] text-[var(--color-text-muted)]">· {fmtTime(t.created_at)}</span>
</div>
);
})}
</div>
</div>
)}
</Panel>
);
};