268 lines
14 KiB
JavaScript
268 lines
14 KiB
JavaScript
/**
|
||
* @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 [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>
|
||
);
|
||
};
|