/** * @file AutocallStatusCard.jsx * @description Блок «Автодозвон» в карточке заказа. * Показывает попытки звонков, слот, кнопки «Позвонить сейчас» / «Не звонить». */ import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Badge } from "../UI/Badge"; import { supabase } from "../../supabaseClient"; import { resolveAutocallStatus, AUTOCALL_STATUS_META } from "../../services/orderGroupViews"; import { describeCallProcess } from "../../services/autocallProcess"; const OUTCOME_META = { 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 fmtTime = (ts) => { if (!ts) return "—"; try { return new Date(ts).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", }); } catch { return ts; } }; const slotText = (l) => { if (!l) return null; const day = DAY_LABEL[l.dtmf_day]; const half = HALF_LABEL[l.dtmf_half]; if (day && half) return `${day}, ${half}`; if (day) return day; return null; }; export const AutocallStatusCard = ({ order, userRole }) => { const [calls, setCalls] = useState([]); const [queueItem, setQueueItem] = useState(null); const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(false); const [toggling, setToggling] = useState(false); const [sending, setSending] = useState(false); const canManage = ["mega_admin", "logistician"].includes(userRole); const optOut = order.autocall_opt_out ?? order.autocallOptOut ?? false; const isAutocall = order.delivery_date_source === "autocall" || order.deliveryDateSource === "autocall"; const exclusionReason = order.autocall_exclusion_reason || order.autocallExclusionReason || null; const loadCalls = useCallback(async () => { if (!order?.id) return; setLoading(true); try { const { data, error } = await supabase .from("autocall_log") .select("*") .eq("order_group_id", order.id) .order("created_at", { ascending: false }) .limit(10); if (error) throw error; setCalls(data || []); } catch (e) { // silent } finally { setLoading(false); } }, [order?.id]); // Последняя заявка «Позвонить сейчас» по этому заказу: без неё в карточке было // не видно, ушёл ли запрос и что с ним стало. const loadQueue = useCallback(async () => { if (!order?.id) return; try { const { data } = await supabase .from("autocall_queue") .select("id, requested_at, processed_at, is_test_call, call_type, phone") .eq("order_group_id", order.id) .order("requested_at", { ascending: false }) .limit(1); setQueueItem(data?.[0] || null); } catch { // silent } }, [order?.id]); // Настройки кампании: рабочие часы/дни и тестовый режим — из них понятно, // когда именно робот возьмёт заявку. const loadSettings = useCallback(async () => { try { const { data } = await supabase .from("sms_campaign_settings") .select("enabled, test_mode, test_phone, work_hours_start, work_hours_end, work_days") .eq("campaign_type", "autocall_slot") .maybeSingle(); setSettings(data || null); } catch { // silent } }, []); useEffect(() => { loadCalls(); loadQueue(); loadSettings(); }, [loadCalls, loadQueue, loadSettings]); // Звонок, который робот сделал по нашей заявке (в пределах 15 минут после неё). const callAfter = useMemo(() => { if (!queueItem?.requested_at) return null; const from = new Date(queueItem.requested_at).getTime() - 1000; const to = from + 15 * 60 * 1000; return ( calls.find((c) => { const at = new Date(c.created_at).getTime(); return at >= from && at <= to; }) || null ); }, [calls, queueItem]); // Заявка свежая (сутки) — иначе в карточке висел бы вчерашний процесс. const recentQueue = useMemo(() => { if (!queueItem?.requested_at) return null; return Date.now() - new Date(queueItem.requested_at).getTime() <= 24 * 60 * 60 * 1000 ? queueItem : null; }, [queueItem]); // Заявка висит непринятой больше часа в рабочее время — планировщик молча // пропускает её (окно звонка закрыто: по графику роботу нечего предлагать). // Показываем честную причину вместо «в очереди». const callUnreachable = useMemo(() => { if (!recentQueue || recentQueue.processed_at || callAfter) return false; const age = Date.now() - new Date(recentQueue.requested_at).getTime(); if (age < 60 * 60 * 1000) return false; // Рабочее время сейчас? const workDays = (settings?.work_days || "").toString().split(",").map((s) => parseInt(s, 10)).filter(Number.isFinite); const startHour = Number(settings?.work_hours_start ?? 9); const endHour = Number(settings?.work_hours_end ?? 20); const msk = new Date(Date.now() + (3 * 60 + new Date().getTimezoneOffset()) * 60000); const dayOk = workDays.length === 0 || workDays.includes(msk.getDay()); const hourOk = msk.getHours() >= startHour && msk.getHours() < endHour; return dayOk && hourOk; }, [recentQueue, callAfter, settings]); const process = useMemo( () => describeCallProcess({ queueItem: recentQueue, callAfter, settings: { ...(settings || {}), call_unreachable: callUnreachable } }), [recentQueue, callAfter, settings, callUnreachable], ); const processing = process?.phase === "pending" || process?.phase === "pending_later" || process?.phase === "dialing"; // Пока заявку не отработали (и в пределах 15 минут) — подтягиваем состояние каждые 7 секунд. useEffect(() => { if (!processing || !recentQueue) return undefined; const age = Date.now() - new Date(recentQueue.requested_at).getTime(); if (age > 15 * 60 * 1000) return undefined; const timer = setInterval(() => { loadQueue(); loadCalls(); }, 7000); return () => clearInterval(timer); }, [processing, recentQueue, loadQueue, loadCalls]); const handleCallNow = async () => { if (!order?.id) return; setSending(true); try { // Пишем в очередь — планировщик подхватит и создаст call-файл (без ssh из edge fn). // Сценарий планировщик определит сам по дню добавления группы // (день добавления → A: завтра/послезавтра; позже → B: только завтра). const { error } = await supabase.from("autocall_queue").insert({ order_group_id: order.id, phone: order.customer_phone || order.customerPhone || "", // Тип озвучки: по самовывозу робот говорит «забрать», по доставке — «доставить». // Если тип в объекте не пришёл, планировщик определит его по заказу сам. ...((order.delivery_type || order.deliveryType) ? { call_type: (order.delivery_type || order.deliveryType) === "pickup" ? "pkp" : "dlv" } : {}), }); if (error) throw error; await loadQueue(); } catch (e) { console.error("Queue call error:", e); } finally { setSending(false); } }; const handleToggleOptOut = async () => { if (!order?.id) return; setToggling(true); try { const { error } = await supabase .from("order_groups") .update({ autocall_opt_out: !optOut }) .eq("id", order.id); if (error) throw error; // Обновим локально order.autocall_opt_out = !optOut; setToggling(false); } catch (e) { console.error("Toggle opt-out error:", e); setToggling(false); } }; const lastCall = calls[0]; const lastMeta = lastCall ? OUTCOME_META[lastCall.outcome] : null; const lastSlot = lastCall ? slotText(lastCall) : null; // Единый человеческий статус (по аналогии со статусом SMS) const statusKey = resolveAutocallStatus(calls, { optOut, exclusionReason }); const statusMeta = AUTOCALL_STATUS_META[statusKey] || AUTOCALL_STATUS_META.not_called; return (