diff --git a/src/components/admin/autocall/TestCallPanel.jsx b/src/components/admin/autocall/TestCallPanel.jsx
new file mode 100644
index 0000000..4a8faf3
--- /dev/null
+++ b/src/components/admin/autocall/TestCallPanel.jsx
@@ -0,0 +1,198 @@
+/**
+ * @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 (
+
+
+
📞 Тестовый звонок
+ 🧪 мегаадмин
+
+
+ Позвонит на введённый номер через АТС клиента и покажет, какой выбор сделал человек после голосового меню.
+
+
+
+ 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 === "queueing" ? "Отправка…" : status === "queued" || status === "dialing" ? "Звоним…" : "🔊 Позвонить"}
+
+ {(status === "queued" || status === "dialing" || status === "done") && (
+ { 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)]"
+ >
+ Сбросить
+
+ )}
+
+
+ {/* ── Окно результата ─────────────────────────────────────────────── */}
+ {(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)}
+ )}
+
+ )}
+
+ );
+};
diff --git a/supabase/migrations/20260902_autocall_queue_test.sql b/supabase/migrations/20260902_autocall_queue_test.sql
new file mode 100644
index 0000000..6a6c5d2
--- /dev/null
+++ b/supabase/migrations/20260902_autocall_queue_test.sql
@@ -0,0 +1,14 @@
+-- ============================================================
+-- SuperSam: Автодозвон — тестовый звонок из UI (мегаадмин)
+-- 02.09.2026. Аддитивная.
+-- order_group_id становится nullable: тестовый звонок на произвольный
+-- номер не привязан к заказу. При обработке номер берётся из q.phone.
+-- Добавляем колонку is_test_call, чтобы отличать ручную тест-проверку.
+-- ============================================================
+
+alter table public.autocall_queue alter column order_group_id drop not null;
+
+alter table public.autocall_queue add column if not exists is_test_call boolean not null default false;
+
+comment on column public.autocall_queue.is_test_call
+ is 'true — тест звонка из UI (мегаадмин), order_group_id может быть NULL.';