/** * @file SmsCampaignPanel.jsx * @description SMS Campaign management for mega_admin. * Campaign cards + settings + log table + queue with next-send times */ import React, { useState, useEffect, useCallback, useRef } from "react"; import { useNavigate } from "react-router-dom"; import { Panel } from "../UI/Panel"; import { Badge } from "../UI/Badge"; import { supabase, supabaseUrl, supabaseAnonKey } from "../../supabaseClient"; import { Pagination } from "../UI/Pagination"; import { SmsCampaignStats } from "./SmsCampaignStats"; // ── Campaigns ──────────────────────────────────────────────────────────────── const CAMPAIGNS = [ { key: "first_sms", label: "Первая отправка", icon: "📤", desc: "Первое SMS клиенту: ссылка на согласование доставки", queueStatus: "link_ready" }, { key: "second_sms", label: "Второе сообщение", icon: "📨", desc: "Повторное SMS через 3ч, если клиент не согласовал", queueStatus: "first_sms_sent" }, { key: "manual", label: "Ручное управление", icon: "🔧", desc: "Перевод к ручному согласованию после двух SMS", queueStatus: "second_sms_sent" }, { key: "paid_storage", label: "Платное хранение", icon: "📦", desc: "Уведомление о платном хранении", queueStatus: null }, ]; const HAS_SETTINGS = ["first_sms", "second_sms", "manual", "paid_storage"]; const MANUAL_CAMPAIGN = "manual"; // ── Days of week ───────────────────────────────────────────────────────────── const DAYS = [ { num: 1, short: "Пн" }, { num: 2, short: "Вт" }, { num: 3, short: "Ср" }, { num: 4, short: "Чт" }, { num: 5, short: "Пт" }, { num: 6, short: "Сб" }, { num: 7, short: "Вс" }, ]; // ── Status labels ──────────────────────────────────────────────────────────── const STATUS_LABELS = { sent: "SMS отправлено", checking: "В пути к абоненту", delivered: "✓ Доставлено", send_failed: "Не отправлено", error: "Ошибка доставки", expired: "Срок истёк", limit_exceeded: "Лимит превышен", manual_override: "Ручной режим", }; const STATUS_TONES = { sent: "info", checking: "neutral", delivered: "accent", send_failed: "danger", error: "danger", expired: "warning", limit_exceeded: "danger", manual_override: "warning", }; const STATUS_ICONS = { sent: "📤", checking: "📡", delivered: "✅", send_failed: "❌", error: "⛔", expired: "⏰", limit_exceeded: "🚫", manual_override: "✋", }; // ── SMS code labels ────────────────────────────────────────────────────────── const SMS_CODE_LABELS = { "100": "В очереди SMS.ru", "101": "Передано оператору", "102": "В пути", "103": "Доставлено", "104": "Истёкло время", "105": "Удалено оператором", "106": "Сбой телефона", "107": "Неизвестная причина", "108": "Отклонено", "130": "Лимит на номер/день", "131": "Лимит одинаковых/мин", "132": "Лимит одинаковых/день", "200": "Неправильный api_id", "201": "Недостаточно средств", "202": "Неправильный получатель", "230": "Общий лимит/день", "231": "Лимит одинаковых/мин", "232": "Лимит одинаковых/день", }; // ── Notification status labels (for queue) ────────────────────────────────── const NOTIF_LABELS = { "not_started": "⏳ В очереди", "link_ready": "⏳ В очереди", "sms_sending": "📡 Отправляется…", "first_sms_sent": "✓ 1-я SMS отправлена", "second_sms_sending": "📡 2-я SMS отправляется…", "second_sms_sent": "✓ 2-я SMS отправлена", "send_failed": "❌ Ошибка отправки", "manual_required": "🔧 Ручное управление", "paid_storage_sending": "📡 Отправляется…", "paid_storage_sent": "✓ Платное хранение: отправлено", "confirmed": "✅ Согласовано клиентом", "address_required": "⚠ Требуется адрес", "draft": "Черновик", }; const NOTIF_TONES = { "not_started": "neutral", "link_ready": "warning", "sms_sending": "info", "first_sms_sent": "info", "second_sms_sending": "info", "second_sms_sent": "info", "send_failed": "danger", "manual_required": "warning", "paid_storage_sending": "info", "paid_storage_sent": "accent", "confirmed": "accent", "address_required": "danger", "draft": "neutral", }; // ── Helpers ────────────────────────────────────────────────────────────────── const fmtTime = (ts) => { if (!ts) return "—"; try { return new Date(ts).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", year: "2-digit", hour: "2-digit", minute: "2-digit", }); } catch { return ts; } }; const fmtTimeShort = (ts) => { if (!ts) return "—"; try { return new Date(ts).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit", }); } catch { return ts; } }; const fmtPhone = (phone) => { if (!phone) return "—"; const digits = String(phone).replace(/\D/g, ""); if (digits.length === 11 && digits.startsWith("7")) { return `+7 (${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7, 9)}-${digits.slice(9)}`; } return phone; }; const fmtElapsed = (ts) => { if (!ts) return "—"; const diff = Date.now() - new Date(ts).getTime(); const m = Math.floor(diff / 60000); if (m < 1) return "только что"; if (m < 60) return `${m} мин назад`; const h = Math.floor(m / 60); const rest = m % 60; return `${h}ч ${rest}м назад`; }; // ── Date filter helpers ─────────────────────────────────────────────── const isToday = (ts) => { if (!ts) return false; const d = new Date(ts); const now = new Date(); return d.getDate() === now.getDate() && d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear(); }; const isYesterday = (ts) => { if (!ts) return false; const d = new Date(ts); const y = new Date(); y.setDate(y.getDate() - 1); return d.getDate() === y.getDate() && d.getMonth() === y.getMonth() && d.getFullYear() === y.getFullYear(); }; const isWithin7d = (ts) => { if (!ts) return false; return new Date(ts).getTime() >= Date.now() - 7 * 24 * 3600 * 1000; }; const passesDateFilter = (ts, df) => { if (df === "all" || !df) return true; if (df === "today") return isToday(ts); if (df === "yesterday") return isYesterday(ts); if (df === "7d") return isWithin7d(ts); return true; }; const passesLinkFilter = (isOpened, lf) => { if (lf === "all" || !lf) return true; if (lf === "opened") return isOpened === true; if (lf === "not_opened") return isOpened !== true; return true; }; // smsFilter: "no_sms" = group has no sms_sent_at and no first/second_sms_sent_at const passesSmsFilter = (group, sf) => { if (sf === "all" || !sf) return true; if (sf === "no_sms") { return !group.sms_sent_at && !group.first_sms_sent_at && !group.second_sms_sent_at; } return true; }; const fmtCountdown = (ts) => { if (!ts) return "—"; const diff = new Date(ts).getTime() - Date.now(); if (diff <= 0) return "сейчас"; const m = Math.floor(diff / 60000); if (m < 60) return `через ${m} мин`; const h = Math.floor(m / 60); const rest = m % 60; return `через ${h}ч ${rest}м`; }; // ── Agreement formatter (delivery/pickup status) ──────────────────────────── const fmtAgreement = (g) => { if (!g) return { text: "—", tone: "neutral", icon: "⏳" }; const ds = g.delivery_status; // If client already chose date/time — show it regardless of delivery_status if (g.delivery_type === "pickup" || g.pickup_date) { const date = g.pickup_date ? new Date(g.pickup_date).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" }) : ""; const slot = g.pickup_time_slot || ""; const hasAll = date && slot; return { text: `🏪 Самовывоз${date ? " " + date : ""}${slot ? ", " + slot : ""}${!hasAll ? " (неполно)" : ""}`, tone: "accent", icon: "🏪" }; } if (g.delivery_date) { const date = new Date(g.delivery_date).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" }); const time = g.delivery_time || ""; // Use delivery_address, or customer_address if it's NOT 'САМОВЫВОЗ' const rawAddr = g.delivery_address || (g.customer_address && !/^самовывоз/i.test(g.customer_address.trim()) ? g.customer_address : ""); const addrShort = rawAddr ? ` (${rawAddr.slice(0, 30)}${rawAddr.length > 30 ? "…" : ""})` : ""; const missingAddr = !rawAddr && ds === "requires_address"; return { text: `📦 Доставка${date ? " " + date : ""}${time ? ", " + time : ""}${addrShort}${missingAddr ? " ⚠ адрес" : ""}`, tone: missingAddr ? "warning" : "accent", icon: "📦" }; } // No chosen date — status-based messages if (ds === "requires_address" || ds === "address_required") { return { text: "⚠ Адрес нужен", tone: "warning", icon: "⚠" }; } if (ds === "manual_confirmation_required") { return { text: "🔧 Ручное управление", tone: "warning", icon: "🔧" }; } if (ds === "paid_storage") { return { text: "📦 Платное хранение", tone: "warning", icon: "📦" }; } if (ds === "cancelled") { return { text: "❌ Отменён", tone: "danger", icon: "❌" }; } if (ds === "problem") { return { text: "⚠ Проблема", tone: "danger", icon: "⚠" }; } // pending_confirmation — not agreed yet return { text: "⏳ Не согласовано", tone: "neutral", icon: "⏳" }; }; const AGREEMENT_TONES = { accent: "#22c55e", warning: "var(--color-warning)", danger: "var(--color-danger)", neutral: "var(--color-text-muted)", }; const parseWorkDays = (str) => { if (!str) return new Set([1, 2, 3, 4, 5]); return new Set(str.split(",").map(s => parseInt(s.trim())).filter(n => n >= 1 && n <= 7)); }; const serializeWorkDays = (set) => [...set].sort().join(","); // Calculate next send time based on work hours/days const calcNextSendTime = (group, settings, campaignKey) => { if (!settings) return null; const now = new Date(); const workStart = settings.work_hours_start ?? 8; const workEnd = settings.work_hours_end ?? 21; const workDays = parseWorkDays(settings.work_days); const delayHours = settings.second_sms_delay_hours ?? 3; let base; if (campaignKey === "first_sms") { // Next send: next work window base = new Date(now); } else if (campaignKey === "second_sms") { // Second SMS: first_sms_sent_at + delayHours if (!group.first_sms_sent_at) return null; base = new Date(group.first_sms_sent_at); base.setHours(base.getHours() + delayHours); } else if (campaignKey === "manual") { // Manual: second_sms_sent_at + auto_manual_after_hours if (!group.second_sms_sent_at) return null; base = new Date(group.second_sms_sent_at); base.setHours(base.getHours() + (settings.auto_manual_after_hours ?? 3)); } else { base = new Date(now); } // If base is in the past, find next work window from now if (base < now) base = new Date(now); // Adjust to work hours/days let candidate = new Date(base); // Round up to next minute candidate.setSeconds(0, 0); // If outside work hours or not a work day, find next valid slot for (let i = 0; i < 14 * 24; i++) { // max 14 days const day = candidate.getDay() === 0 ? 7 : candidate.getDay(); const hour = candidate.getHours(); if (workDays.has(day) && hour >= workStart && hour < workEnd) { return candidate.toISOString(); } candidate.setHours(candidate.getHours() + 1, 0, 0, 0); } return null; }; // ── Toggle Switch ──────────────────────────────────────────────────────────── const ToggleSwitch = ({ on, onClick, disabled, size = "md" }) => { const dims = size === "sm" ? { w: "w-9", h: "h-5", knob: "h-4 w-4", on: "left-[18px]", off: "left-0.5" } : { w: "w-12", h: "h-7", knob: "h-6 w-6", on: "left-[22px]", off: "left-0.5" }; return ( ); }; // ── Mini Toggle (for timer in card) ────────────────────────────────────────── const MiniToggle = ({ on, onClick, disabled }) => ( ); // ── KPI Tile ───────────────────────────────────────────────────────────────── const KpiTile = ({ icon, value, label, color }) => (
{label}
{icon && {icon}}{value}
); // ── SettingField ───────────────────────────────────────────────────────────── const SettingField = ({ label, value, onChange }) => ( ); // ── FilterButton ───────────────────────────────────────────────────────────── const FilterButton = ({ active, onClick, children }) => ( ); // ── Card Button ────────────────────────────────────────────────────────────── const CardButton = ({ onClick, disabled, tone, children }) => { const tones = { warning: "border-[var(--color-warning)] text-[var(--color-warning)] hover:bg-[var(--color-accent-soft)]", accent: "border-[var(--color-accent)] text-[var(--color-accent)] hover:bg-[var(--color-accent-soft)]", danger: "border-[var(--color-danger)] text-[var(--color-danger)] hover:bg-[rgba(239,68,68,0.08)]", }; return ( ); }; // ── Table helpers (desktop) ────────────────────────────────────────────────── const TableHeader = ({ cols }) => (
c.width).join(" ") }} > {cols.map((c, i) => (
{c.label}
))}
); const TableRow = ({ cols, onClick, children }) => (
c.width).join(" ") }} > {children}
); // ── Mobile card row ─────────────────────────────────────────────────────────── const MobileCard = ({ onClick, children, className = "" }) => (
{children}
); const MobileRow = ({ label, value, color }) => (
{label} {value}
); // ── Component ──────────────────────────────────────────────────────────────── export const SmsCampaignPanel = () => { const navigate = useNavigate(); const [activeCampaign, setActiveCampaign] = useState("first_sms"); const [logs, setLogs] = useState([]); const [settings, setSettings] = useState(null); const [allSettings, setAllSettings] = useState({}); const [campaignCounts, setCampaignCounts] = useState({}); const [queueData, setQueueData] = useState([]); const [queueCounts, setQueueCounts] = useState({}); const [lastSmsByCampaign, setLastSmsByCampaign] = useState({}); const [runningNow, setRunningNow] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [filter, setFilter] = useState("all"); const [savingSettings, setSavingSettings] = useState(false); const [settingsSaved, setSettingsSaved] = useState(false); const [checkingIds, setCheckingIds] = useState(new Set()); const [autoRefresh, setAutoRefresh] = useState(true); const [togglingCampaign, setTogglingCampaign] = useState(null); const [viewMode, setViewMode] = useState("logs"); const [showSettingsPanel, setShowSettingsPanel] = useState(false); const [dateFilter, setDateFilter] = useState("all"); // all|today|yesterday|7d const [linkFilter, setLinkFilter] = useState("all"); // all|opened|not_opened const [smsFilter, setSmsFilter] = useState("all"); // all|no_sms const [selectedIds, setSelectedIds] = useState(new Set()); const [deleting, setDeleting] = useState(false); const [showSmsPreview, setShowSmsPreview] = useState(false); const [logPage, setLogPage] = useState(1); const [queuePage, setQueuePage] = useState(1); const PAGE_SIZE = 25; const [linkStats, setLinkStats] = useState({ opened: 0, total: 0, notOpened: 0 }); const [openedGroupIds, setOpenedGroupIds] = useState(new Set()); const refreshTimer = useRef(null); const handleOpenGroup = useCallback((groupId) => { if (groupId) navigate("/dashboard/group/" + groupId); }, [navigate]); // ── Load data ────────────────────────────────────────────────────────────── const loadData = useCallback(async () => { setError(null); try { const { data: allSettingsData, error: allErr } = await supabase .from("sms_campaign_settings") .select("*"); if (allErr) throw allErr; const settingsMap = {}; (allSettingsData || []).forEach(s => { settingsMap[s.campaign_type] = s; }); setAllSettings(settingsMap); const { data: countsData, error: countsErr } = await supabase .from("sms_campaign_log") .select("campaign_type, status, was_test_mode"); if (countsErr) throw countsErr; const counts = {}; (countsData || []).forEach(r => { if (r.was_test_mode) return; // Skip test sends from KPI if (!counts[r.campaign_type]) counts[r.campaign_type] = { total: 0, delivered: 0, sent: 0, errors: 0 }; counts[r.campaign_type].total++; if (r.status === "delivered") counts[r.campaign_type].delivered++; if (r.status === "sent" || r.status === "checking") counts[r.campaign_type].sent++; if (["send_failed", "error", "limit_exceeded"].includes(r.status)) counts[r.campaign_type].errors++; }); setCampaignCounts(counts); // Load last SMS timestamp per campaign (non-test only) const { data: lastSmsData, error: lastSmsErr } = await supabase .from("sms_campaign_log") .select("campaign_type, created_at") .eq("was_test_mode", false) .order("created_at", { ascending: false }) .limit(200); const lastSmsMap = {}; if (!lastSmsErr && lastSmsData) { lastSmsData.forEach(r => { if (!lastSmsMap[r.campaign_type]) lastSmsMap[r.campaign_type] = r.created_at; }); } setLastSmsByCampaign(lastSmsMap); const { data: queueCountsData, error: qErr } = await supabase .from("order_groups") .select("notification_status, delivery_status") .in("delivery_status", ["pending_confirmation", "paid_storage"]); if (qErr) throw qErr; const qCounts = { first_sms: 0, second_sms: 0, manual: 0, paid_storage: 0 }; (queueCountsData || []).forEach(r => { if (r.delivery_status === "pending_confirmation") { if (r.notification_status === "link_ready" || r.notification_status === "not_started") qCounts.first_sms++; if (r.notification_status === "first_sms_sent") qCounts.second_sms++; if (r.notification_status === "second_sms_sent") qCounts.manual++; } if (r.delivery_status === "paid_storage" && r.notification_status !== "paid_storage_sent" && r.notification_status !== "paid_storage_sending") { qCounts.paid_storage++; } }); setQueueCounts(qCounts); // Load link open statistics from delivery_invitations const { data: invStatsData, error: invErr } = await supabase .from("delivery_invitations") .select("order_group_id, opened_at, access_count") .not("order_group_id", "is", null); if (!invErr && invStatsData) { const opened = invStatsData.filter(i => i.opened_at).length; const total = invStatsData.length; setLinkStats({ opened, total, notOpened: total - opened }); } // Load per-campaign link stats by joining with order_groups notification_status const { data: invWithGroups, error: invGroupErr } = await supabase .from("delivery_invitations") .select("order_group_id, opened_at, access_count") .not("order_group_id", "is", null) .not("opened_at", "is", null); if (!invGroupErr && invWithGroups) { const openedGroupIds = new Set(invWithGroups.map(i => i.order_group_id)); setOpenedGroupIds(openedGroupIds); } // Load queue detail for active campaign (always load — needed for preview + queue view) if (viewMode === "queue" || showSmsPreview) { let qQuery = supabase .from("order_groups") .select("id, group_key, customer_name, customer_phone, notification_status, delivery_status, delivery_link, created_at, next_notification_check_at, sms_sent_at, first_sms_sent_at, second_sms_sent_at, sms_attempts, delivery_type, delivery_date, delivery_time, pickup_date, pickup_time_slot, delivery_address, customer_address, manual_confirmation_at") .order("created_at", { ascending: true }) .limit(200); if (activeCampaign === "first_sms") { qQuery = qQuery.eq("delivery_status", "pending_confirmation").in("notification_status", ["link_ready", "not_started", "sms_sending", "send_failed"]); } else if (activeCampaign === "second_sms") { qQuery = qQuery.eq("delivery_status", "pending_confirmation").in("notification_status", ["first_sms_sent", "second_sms_sending"]); } else if (activeCampaign === "manual") { // Manual: groups in manual_confirmation_required OR pending with second_sms_sent/manual_required qQuery = qQuery.in("delivery_status", ["pending_confirmation", "manual_confirmation_required"]).in("notification_status", ["second_sms_sent", "manual_required"]); } else if (activeCampaign === "paid_storage") { qQuery = qQuery.eq("delivery_status", "paid_storage").in("notification_status", ["not_started", "paid_storage_sending", "link_ready", "first_sms_sent", "second_sms_sent"]); } const { data: qData, error: qErr2 } = await qQuery; if (qErr2) throw qErr2; // Fetch delivery invitations for these groups — to show link open tracking let invitationsMap = {}; if (qData && qData.length > 0) { const groupIds = qData.map(g => g.id); const { data: invData, error: invErr } = await supabase .from("delivery_invitations") .select("order_group_id, opened_at, access_count, last_accessed_at, confirmed_at, state") .in("order_group_id", groupIds); if (!invErr && invData) { invData.forEach(inv => { invitationsMap[inv.order_group_id] = inv; }); } } // Merge invitation data into queue items const qDataWithInv = (qData || []).map(g => ({ ...g, _invitation: invitationsMap[g.id] || null, })); setQueueData(qDataWithInv); } let query = supabase .from("sms_campaign_log") .select("*") .eq("campaign_type", activeCampaign) .order("created_at", { ascending: false }) .limit(200); if (filter !== "all") query = query.eq("status", filter); const { data: logData, error: logError } = await query; if (logError) throw logError; // Fetch order_groups for these logs to show agreement status let groupsMap = {}; if (logData && logData.length > 0) { const groupIds = [...new Set(logData.map(l => l.order_group_id).filter(Boolean))]; if (groupIds.length > 0) { const { data: groupsData, error: groupsErr } = await supabase .from("order_groups") .select("id, delivery_status, delivery_type, delivery_date, delivery_time, pickup_date, pickup_time_slot, delivery_address, customer_address") .in("id", groupIds); if (!groupsErr && groupsData) { groupsData.forEach(og => { groupsMap[og.id] = og; }); } } } // Merge group data into log entries const logsWithGroups = (logData || []).map(l => ({ ...l, _group: groupsMap[l.order_group_id] || null, })); setLogs(logsWithGroups); if (HAS_SETTINGS.includes(activeCampaign)) { setSettings(settingsMap[activeCampaign] || null); } else { setSettings(null); } } catch (e) { setError(e.message || String(e)); } finally { setIsLoading(false); } }, [activeCampaign, filter, viewMode, showSmsPreview]); useEffect(() => { setIsLoading(true); loadData(); }, [loadData]); useEffect(() => { if (autoRefresh) { refreshTimer.current = setInterval(() => loadData(), 15000); return () => clearInterval(refreshTimer.current); } }, [autoRefresh, loadData]); // ── Save settings ────────────────────────────────────────────────────────── const handleSaveSettings = async () => { if (!settings) return; setSavingSettings(true); try { const { id, ...updates } = settings; updates.updated_at = new Date().toISOString(); const { error: updateError } = await supabase .from("sms_campaign_settings") .update(updates) .eq("id", id); if (updateError) throw updateError; setSettingsSaved(true); setTimeout(() => setSettingsSaved(false), 3000); } catch (e) { setError(`Ошибка сохранения: ${e.message}`); } finally { setSavingSettings(false); } }; const updateSetting = (key, value) => { setSettings(prev => prev ? { ...prev, [key]: value } : prev); }; const toggleCampaignEnabled = async (campaignKey) => { const s = allSettings[campaignKey]; if (!s) return; setTogglingCampaign(campaignKey); const newVal = !(s.enabled ?? true); try { const { error: updateError } = await supabase .from("sms_campaign_settings") .update({ enabled: newVal, updated_at: new Date().toISOString() }) .eq("id", s.id); if (updateError) throw updateError; setAllSettings(prev => ({ ...prev, [campaignKey]: { ...prev[campaignKey], enabled: newVal } })); if (campaignKey === activeCampaign) setSettings(prev => prev ? { ...prev, enabled: newVal } : prev); } catch (e) { setError(`Ошибка: ${e.message}`); } finally { setTogglingCampaign(null); } }; const toggleWorkDay = (dayNum) => { const current = parseWorkDays(settings?.work_days); if (current.has(dayNum)) current.delete(dayNum); else current.add(dayNum); updateSetting("work_days", serializeWorkDays(current)); }; const [togglingTimer, setTogglingTimer] = useState(null); const toggleTimer = async (campaignKey) => { const s = allSettings[campaignKey]; if (!s) return; setTogglingTimer(campaignKey); const newVal = !(s.timer_active ?? false); try { const { error: updateError } = await supabase .from("sms_campaign_settings") .update({ timer_active: newVal, updated_at: new Date().toISOString() }) .eq("id", s.id); if (updateError) throw updateError; setAllSettings(prev => ({ ...prev, [campaignKey]: { ...prev[campaignKey], timer_active: newVal } })); if (campaignKey === activeCampaign) setSettings(prev => prev ? { ...prev, timer_active: newVal } : prev); } catch (e) { setError(`Ошибка: ${e.message}`); } finally { setTogglingTimer(null); } }; const [testSending, setTestSending] = useState(null); const handleTestSend = async (campaignKey) => { const s = allSettings[campaignKey]; if (!s) return; if (!s.test_mode) { setError("Тестовая отправка доступна только в тестовом режиме"); return; } setTestSending(campaignKey); try { const { error: updateError } = await supabase .from("sms_campaign_settings") .update({ test_send_requested: true, updated_at: new Date().toISOString() }) .eq("id", s.id); if (updateError) throw updateError; setTimeout(() => loadData(), 5000); } catch (e) { setError(`Ошибка: ${e.message}`); } finally { setTimeout(() => setTestSending(null), 3000); } }; const [restarting, setRestarting] = useState(null); const handleRestart = async (campaignKey) => { // Reset all groups in this campaign to beginning const s = allSettings[campaignKey]; if (!s) return; setRestarting(campaignKey); try { const { error: updateError } = await supabase .from("sms_campaign_settings") .update({ run_requested: true, updated_at: new Date().toISOString() }) .eq("id", s.id); if (updateError) throw updateError; setTimeout(() => loadData(), 5000); setTimeout(() => loadData(), 15000); } catch (e) { setError(`Ошибка: ${e.message}`); } finally { setTimeout(() => setRestarting(null), 3000); } }; const handleRunNow = async (campaignKey) => { // Trigger immediate run via run_requested flag const s = allSettings[campaignKey]; if (!s) return; setRunningNow(campaignKey); try { const { error: updateError } = await supabase .from("sms_campaign_settings") .update({ run_requested: true, updated_at: new Date().toISOString() }) .eq("id", s.id); if (updateError) throw updateError; // Quick refresh to show updated status setTimeout(() => loadData(), 3000); setTimeout(() => loadData(), 10000); } catch (e) { setError(`Ошибка: ${e.message}`); } finally { setTimeout(() => setRunningNow(null), 3000); } }; const handleRecheck = async (logId) => { setCheckingIds(prev => new Set([...prev, logId])); try { const resp = await fetch(`${supabaseUrl}/functions/v1/check-sms-status`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${supabaseAnonKey}`, "apikey": supabaseAnonKey, }, body: JSON.stringify({ log_id: logId }), }); const data = await resp.json(); if (!resp.ok || data.error) throw new Error(data.error || `HTTP ${resp.status}`); setLogs(prev => prev.map(l => { if (l.id !== logId) return l; return { ...l, status: data.status, sms_code: data.code, needs_check: false, checked_at: new Date().toISOString(), updated_at: new Date().toISOString(), }; })); } catch (e) { setError(`Ошибка проверки: ${e.message}`); } finally { setTimeout(() => setCheckingIds(prev => { const n = new Set(prev); n.delete(logId); return n; }), 1000); } }; const handleDeleteLogs = async (ids) => { if (!ids || ids.length === 0) return; setDeleting(true); try { const { error } = await supabase .from("sms_campaign_log") .delete() .in("id", ids); if (error) throw error; setLogs(prev => prev.filter(l => !ids.includes(l.id))); setSelectedIds(new Set()); } catch (e) { setError(`Ошибка удаления: ${e.message}`); } finally { setDeleting(false); } }; const handleDeleteTestLogs = async () => { const testIds = logs.filter(l => l.was_test_mode === true).map(l => l.id); if (testIds.length === 0) return; if (!window.confirm(`Удалить ${testIds.length} тестовых записей?`)) return; await handleDeleteLogs(testIds); }; const handleDeleteSelected = async () => { const ids = [...selectedIds]; if (ids.length === 0) return; if (!window.confirm(`Удалить ${ids.length} записей?`)) return; await handleDeleteLogs(ids); }; const toggleSelect = (id) => { setSelectedIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }; const toggleSelectAll = () => { const filtered = logs.filter((entry) => { const isOpened = openedGroupIds.has(entry.order_group_id); return passesDateFilter(entry.created_at, dateFilter) && passesLinkFilter(isOpened, linkFilter); }); const allIds = filtered.map(l => l.id); const allSelected = allIds.length > 0 && allIds.every(id => selectedIds.has(id)); setSelectedIds(allSelected ? new Set() : new Set(allIds)); }; const stats = logs.reduce((acc, log) => { acc[log.status] = (acc[log.status] || 0) + 1; return acc; }, {}); const showSettings = HAS_SETTINGS.includes(activeCampaign); const isManualCampaign = activeCampaign === MANUAL_CAMPAIGN; const isPaidStorageCampaign = activeCampaign === "paid_storage"; const showSmsFields = !isManualCampaign; const getRunnerStatus = (s, lastSmsAt, queueCnt, timerActive) => { if (!s) return { label: "—", color: "var(--color-text-muted)" }; if (!s.enabled) return { label: "⏸ Остановлена", color: "var(--color-text-muted)" }; // If timer active and queue is 0 — all good, nothing to send if (timerActive && queueCnt === 0) { const lastRunDiff = s.last_run_at ? Math.floor((Date.now() - new Date(s.last_run_at).getTime()) / 60000) : null; if (lastRunDiff !== null && lastRunDiff < 10) return { label: `✅ Очереди нет (тик ${lastRunDiff}м)`, color: "#22c55e" }; if (lastRunDiff !== null && lastRunDiff < 60) return { label: `✅ Очереди нет (тик ${lastRunDiff}м назад)`, color: "#22c55e" }; return { label: "✅ Очереди нет", color: "#22c55e" }; } // If timer active and queue > 0 — waiting for delay, show calm status if (timerActive && queueCnt > 0) { if (lastSmsAt) { const diffMin = Math.floor((Date.now() - new Date(lastSmsAt).getTime()) / 60000); if (diffMin < 10) return { label: `🟢 SMS ${diffMin}м назад · ${queueCnt} в очереди`, color: "#22c55e" }; if (diffMin < 60) return { label: `⏳ SMS ${diffMin}м назад · ${queueCnt} в очереди`, color: "var(--color-warning)" }; if (diffMin < 1440) return { label: `⏳ SMS ${Math.floor(diffMin/60)}ч назад · ${queueCnt} ждут`, color: "var(--color-warning)" }; return { label: `⏳ ${queueCnt} в очереди · SMS ${Math.floor(diffMin/1440)}д назад`, color: "var(--color-warning)" }; } // No SMS in log but queue has items — waiting to send const lastRunDiff = s.last_run_at ? Math.floor((Date.now() - new Date(s.last_run_at).getTime()) / 60000) : null; if (lastRunDiff !== null && lastRunDiff < 10) return { label: `🟢 Запуск ${lastRunDiff}м назад · ${queueCnt} ждут`, color: "#22c55e" }; return { label: `⏳ ${queueCnt} в очереди · ждём задержку`, color: "var(--color-warning)" }; } // Timer NOT active — show warning if (!timerActive) return { label: "⏸ Таймер выключен", color: "var(--color-danger)" }; // Fallback if (lastSmsAt) { const diffMin = Math.floor((Date.now() - new Date(lastSmsAt).getTime()) / 60000); if (diffMin < 10) return { label: `🟢 SMS ${diffMin}м назад`, color: "#22c55e" }; if (diffMin < 60) return { label: `🟡 SMS ${diffMin}м назад`, color: "var(--color-warning)" }; return { label: `🔴 SMS ${Math.floor(diffMin/1440)}д назад`, color: "var(--color-danger)" }; } return { label: "⚠️ Не запускалась", color: "var(--color-danger)" }; }; const balance = allSettings.first_sms?.last_balance ?? allSettings.second_sms?.last_balance ?? allSettings.paid_storage?.last_balance ?? null; if (isLoading && logs.length === 0 && viewMode === "logs") { return (
Загрузка данных кампании…
); } // Queue table column definitions const queueCols = [ { label: "Клиент", width: "minmax(130px,1.5fr)" }, { label: "Телефон", width: "minmax(120px,1fr)" }, { label: "Статус SMS", width: "minmax(110px,1fr)" }, { label: "Согласование", width: "minmax(150px,1.3fr)" }, { label: "Добавлен", width: "minmax(110px,0.9fr)" }, { label: activeCampaign === "manual" ? "⏰ Перевод в ручное" : "Следующая отправка", width: "minmax(120px,1fr)" }, { label: "Следующая проверка", width: "minmax(110px,0.9fr)" }, { label: "Ссылка открыта", width: "minmax(100px,0.9fr)" }, ]; // Log table column definitions const logCols = [ { label: "☐", width: "minmax(40px,0.3fr)" }, { label: "Клиент", width: "minmax(140px,1.5fr)" }, { label: "Статус SMS", width: "minmax(130px,1.2fr)" }, { label: "Согласование", width: "minmax(150px,1.3fr)" }, { label: "Отправлено", width: "minmax(100px,0.9fr)" }, { label: "Проверено", width: "minmax(120px,1fr)" }, { label: "Ссылка", width: "minmax(90px,0.8fr)" }, { label: "Действие", width: "minmax(100px,0.8fr)" }, ]; return (
{/* ── Balance banner ─────────────────────────────────────────────────── */}
💰
Баланс sms.ru
{balance != null ? `${Number(balance).toLocaleString("ru-RU")} ₽` : "—"}
📊
SMS отправлено
{Object.values(campaignCounts).reduce((a, c) => a + (c?.total || 0), 0)}
{linkStats.total > 0 ? (
👁
Открыли ссылку
{linkStats.opened} / {linkStats.total}
) : null}
{/* ── Campaign cards ────────────────────────────────────────────────── */}
{CAMPAIGNS.map(c => { const isActive = activeCampaign === c.key; const s = allSettings[c.key]; const isEnabled = s?.enabled ?? true; const isTest = s?.test_mode ?? true; const timerActive = s?.timer_active ?? false; const cnt = campaignCounts[c.key] || { total: 0, delivered: 0, sent: 0, errors: 0 }; const queueCnt = queueCounts[c.key] || 0; const runner = getRunnerStatus(s, lastSmsByCampaign[c.key], queueCnt, timerActive); const isToggling = togglingCampaign === c.key; const isTimerToggling = togglingTimer === c.key; const isTestSending = testSending === c.key; const isManual = c.key === MANUAL_CAMPAIGN; return (
{ setActiveCampaign(c.key); setFilter("all"); setViewMode(c.key === "manual" ? "queue" : "logs"); }} > {/* Header */}
{c.icon}
{c.label}
{c.desc}
toggleCampaignEnabled(c.key)} disabled={isToggling} size="md" />
{/* Status row */}
{!isEnabled ? "⏸ Выкл" : isManual ? "🔧 Ручное" : isTest ? "🧪 Тест" : "🚀 Боевой"} {runner.label}
{/* Timer status bar */}
0 ? "bg-[rgba(34,197,94,0.06)]" : "bg-[var(--color-surface-strong)]" : "bg-[rgba(239,68,68,0.06)]" }`}> {timerActive ? ( <> 0 ? "bg-[#22c55e] animate-pulse" : "bg-[#22c55e]"} shrink-0`} /> Таймер активен{queueCnt > 0 ? ` · ${queueCnt} в очереди` : " · очереди нет"} ) : ( <> Таймер выключен — SMS не отправляются )}
{/* Action buttons — uniform size, full width row */}
e.stopPropagation()}> {!isManual && isTest && ( handleTestSend(c.key)} disabled={isTestSending} tone="warning" > {isTestSending ? "…" : "🧪 Тест"} )} handleRunNow(c.key)} disabled={runningNow === c.key} tone="accent" > {runningNow === c.key ? "…" : "▶ Запустить"} handleRestart(c.key)} disabled={restarting === c.key} tone="danger" > {restarting === c.key ? "…" : "🔄 Сброс"}
{/* Stats grid — 2×2 KPI tiles (real only, test excluded) */}
0 ? `${Math.round((cnt.delivered / cnt.total) * 100)}%` : "—"} label="% доставки" color={cnt.total > 0 && cnt.delivered / cnt.total >= 0.8 ? "#22c55e" : cnt.total > 0 && cnt.delivered / cnt.total >= 0.5 ? "var(--color-warning)" : "var(--color-danger)"} />
); })}
{error && (
⚠ {error}
)} {/* ── View mode toggle ──────────────────────────────────────────────── */}
setViewMode("logs")}>📋 Журнал отправок setViewMode("queue")}>⏳ Очередь отправки
{/* ── Queue view ────────────────────────────────────────────────────── */} {viewMode === "queue" && ( <> {/* Date + link filters */}
📅 Добавлен: {[ { v: "all", l: "Все" }, { v: "today", l: "Сегодня" }, { v: "yesterday", l: "Вчера" }, { v: "7d", l: "7 дней" }, ].map(opt => ( setDateFilter(opt.v)}>{opt.l} ))} 🔗 Ссылка: {[ { v: "all", l: "Все" }, { v: "opened", l: "Открыта" }, { v: "not_opened", l: "Не открыта" }, ].map(opt => ( setLinkFilter(opt.v)}>{opt.l} ))} 📨 SMS: {[ { v: "all", l: "Все" }, { v: "no_sms", l: "Без SMS" }, ].map(opt => ( setSmsFilter(opt.v)}>{opt.l} ))}
{/* Desktop table */}
{queueData.length === 0 ? (
Очередь пуста — нет групп, ожидающих отправки SMS
) : ( queueData.filter((g) => { const inv = g._invitation; const opened = inv && inv.opened_at; return passesDateFilter(g.created_at, dateFilter) && passesLinkFilter(opened, linkFilter) && passesSmsFilter(g, smsFilter); }).slice((queuePage - 1) * PAGE_SIZE, queuePage * PAGE_SIZE).map((g) => { const nextSend = calcNextSendTime(g, settings, activeCampaign); const inv = g._invitation; const opened = inv && inv.opened_at; const accessCount = inv?.access_count || 0; const manualTransfer = activeCampaign === "manual" && g.second_sms_sent_at ? new Date(new Date(g.second_sms_sent_at).getTime() + (settings?.auto_manual_after_hours ?? 3) * 3600000) : null; const isOverdue = manualTransfer && manualTransfer.getTime() < Date.now(); return ( handleOpenGroup(g.id)} >
{g.customer_name || g.group_key || "—"}
{fmtPhone(g.customer_phone)}
{NOTIF_LABELS[g.notification_status] || g.notification_status || "—"} {g.sms_attempts > 0 &&
попыток: {g.sms_attempts}
}
{(() => { const a = fmtAgreement(g); return ( {a.text} ); })()}
в базу
{fmtTimeShort(g.created_at)}
{fmtElapsed(g.created_at)}
{g.sms_sent_at && ( <>
SMS старт
{fmtTimeShort(g.sms_sent_at)}
{fmtElapsed(g.sms_sent_at)}
)}
{activeCampaign === "manual" ? ( manualTransfer ? (
2-я SMS: {fmtTimeShort(g.second_sms_sent_at)}
{fmtTimeShort(manualTransfer.toISOString())}
{isOverdue ? "⏰ просрочено!" : fmtCountdown(manualTransfer.toISOString())}
{g.manual_confirmation_at && (
🔧 В ручном: {fmtTimeShort(g.manual_confirmation_at)}
)}
) : g.manual_confirmation_at ? (
🔧 В ручном
{fmtTimeShort(g.manual_confirmation_at)}
{fmtElapsed(g.manual_confirmation_at)}
) : ( ) ) : nextSend ? (
{fmtTimeShort(nextSend)}
{fmtCountdown(nextSend)}
) : ( )}
{fmtTimeShort(g.next_notification_check_at)} {g.next_notification_check_at &&
{fmtCountdown(g.next_notification_check_at)}
}
{opened ? (
✅ Открыта {inv.last_accessed_at && (
{accessCount > 0 ? `${accessCount}× ` : ""}{fmtElapsed(inv.last_accessed_at)}
)}
) : inv ? ( — не открыта ) : ( — нет ссылки )}
); }) )}
{/* Mobile cards */}
{queueData.length === 0 ? (
Очередь пуста — нет групп, ожидающих отправки SMS
) : ( queueData.filter((g) => { const inv = g._invitation; const opened = inv && inv.opened_at; return passesDateFilter(g.created_at, dateFilter) && passesLinkFilter(opened, linkFilter) && passesSmsFilter(g, smsFilter); }).map((g) => { const nextSend = calcNextSendTime(g, settings, activeCampaign); const inv = g._invitation; const opened = inv && inv.opened_at; const accessCount = inv?.access_count || 0; const manualTransfer = activeCampaign === "manual" && g.second_sms_sent_at ? new Date(new Date(g.second_sms_sent_at).getTime() + (settings?.auto_manual_after_hours ?? 3) * 3600000) : null; const isOverdue = manualTransfer && manualTransfer.getTime() < Date.now(); return ( handleOpenGroup(g.id)}> {/* Header: name + phone */}
{g.customer_name || g.group_key || "—"}
{NOTIF_LABELS[g.notification_status] || g.notification_status || "—"}
{fmtPhone(g.customer_phone)}
{/* Info rows */}
{(() => { const a = fmtAgreement(g); return ( ); })()} {g.sms_sent_at && ( )} {activeCampaign === "manual" && manualTransfer ? ( ) : nextSend ? ( ) : null} {g.next_notification_check_at && ( )} {g.sms_attempts > 0 && ( )} 0 ? ` ${accessCount}×` : ""}${inv?.last_accessed_at ? ` ${fmtElapsed(inv.last_accessed_at)}` : ""}` : inv ? "— не открыта" : "— нет ссылки"} color={opened ? "#22c55e" : "var(--color-text-muted)"} />
); }) )}
{(() => { const filteredQueue = queueData.filter((g) => { const inv = g._invitation; const opened = inv && inv.opened_at; return passesDateFilter(g.created_at, dateFilter) && passesLinkFilter(opened, linkFilter) && passesSmsFilter(g, smsFilter); }); const totalPages = Math.ceil(filteredQueue.length / PAGE_SIZE); if (totalPages <= 1) return null; return ; })()} )} {/* ── Logs view ─────────────────────────────────────────────────────── */} {viewMode === "logs" && ( <> {showSettings && settings && (
{showSettingsPanel && (

{CAMPAIGNS.find(c => c.key === activeCampaign)?.icon} {CAMPAIGNS.find(c => c.key === activeCampaign)?.label}

{settingsSaved && ( ✓ Сохранено )}
{/* Test/Battle mode toggle */} {!isManualCampaign && (
{settings.test_mode && (
updateSetting("test_phone", v)} />
)}
)} {/* SMS text editor */} {!isManualCampaign && (