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

199 lines
10 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: "🤐" },
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 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 [status, setStatus] = useState("idle"); // idle | queueing | queued | dialing | done | error
const [result, setResult] = useState(null); // последняя строка autocall_log для этого теста
const [startedAt, setStartedAt] = useState(null);
const pollRef = useRef(null);
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",
is_test_call: true,
});
if (error) throw error;
setStatus("queued");
setResult({ outcome: "queued", note: "Запрос поставлен в очередь. Планировщик создаст звонок." });
// Начинаем поллить результат по нашему номеру с момента запуска
pollRef.current = setInterval(() => pollResult(now), 3000);
} 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();
}
} else {
setStatus((s) => (s === "dialing" ? "dialing" : "queued"));
}
} catch (e) {
// глуши, поллинг продолжит
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phone, stopPolling]);
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="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>
)}
</Panel>
);
};