/** * @file SmsCampaignPanel.jsx * @description SMS Campaign management for mega_admin. * Campaign selector cards + settings + log table + "Проверить снова" button */ 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 } from "../../supabaseClient"; import { SmsCampaignStats } from "./SmsCampaignStats"; import { SmsCityProfilesPanel } from "./SmsCityProfilesPanel"; import { resolveSmsBalance } from "../../services/smsBalance"; // ── Campaigns ─────────────────────────────────────────────────────────────── const CAMPAIGNS = [ { key: "first_sms", label: "Первая отправка", icon: "📤", desc: "Первое SMS клиенту: ссылка на согласование доставки" }, { key: "second_sms", label: "Второе сообщение", icon: "📨", desc: "Повторное SMS через 3ч, если клиент не согласовал" }, { key: "manual", label: "Ручное управление", icon: "🔧", desc: "Переход к ручному согласованию" }, { key: "paid_storage", label: "Платное хранение", icon: "📦", desc: "Уведомление о платном хранении" }, ]; const HAS_SETTINGS = ["first_sms", "second_sms", "manual", "paid_storage"]; // ── SMS text templates (доставка / самовывоз) ──────────────────────────────── const SMS_LINK_PREVIEW = "https://dost.supersamsev.ru/d/XXXXXX"; // Образцы значений для предпросмотра (как будет выглядеть SMS у клиента) const SMS_PREVIEW_VALUES = [ ["{номер_доставки}", "010796"], ["{номер}", "010796"], ["{order}", "010796"], ["{код_выдачи}", "7598"], ["{код}", "7598"], ["{code}", "7598"], ["{ссылка}", SMS_LINK_PREVIEW], ["{link}", SMS_LINK_PREVIEW], ]; const smsPreviewText = (template) => { let text = (template || "").trim(); SMS_PREVIEW_VALUES.forEach(([token, value]) => { text = text.split(token).join(value); }); return text; }; const renderSmsPreview = (template, emptyLabel) => smsPreviewText(template) || emptyLabel; // ── 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: "Отправлено", 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", }; // ── SMS code labels ────────────────────────────────────────────────────────── const SMS_CODE_LABELS = { "100": "В очереди", "101": "Оператору", "102": "В пути", "103": "Доставлено", "104": "Истёкло время", "105": "Удалено оператором", "106": "Сбой телефона", "107": "Неизвестная причина", "108": "Отклонено", "130": "Лимит на номер/день", "131": "Лимит одинаковых/мин", "132": "Лимит одинаковых/день", "200": "Неправильный api_id", "201": "Недостаточно средств", "202": "Неправильный получатель", "230": "Общий лимит/день", "231": "Лимит одинаковых/мин", "232": "Лимит одинаковых/день", }; // ── 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 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}м назад`; }; 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(","); // ── Component ──────────────────────────────────────────────────────────────── export const SmsCampaignPanel = () => { const navigate = useNavigate(); const [activeCampaign, setActiveCampaign] = useState("first_sms"); const [logs, setLogs] = useState([]); const [manualGroups, setManualGroups] = useState([]); const [settings, setSettings] = useState(null); const [allSettings, setAllSettings] = useState({}); const [campaignCounts, setCampaignCounts] = useState({}); const [smsBalance, setSmsBalance] = 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 [showCityRules, setShowCityRules] = useState(false); const [logPage, setLogPage] = useState(1); const [manualPage, setManualPage] = useState(1); const refreshTimer = useRef(null); const PER_PAGE = 20; const handleOpenGroup = useCallback((groupId) => { if (groupId) navigate("/dashboard/group/" + groupId); }, [navigate]); // ── Load data ────────────────────────────────────────────────────────────── const loadData = useCallback(async () => { setError(null); try { // Load all settings for cards 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); // Баланс — тот же живой источник, что на главной странице resolveSmsBalance(settingsMap.first_sms?.last_balance ?? null).then(bal => setSmsBalance(bal)); // Load counts per campaign (for cards) const { data: countsData, error: countsErr } = await supabase .from("sms_campaign_log") .select("campaign_type, status"); if (countsErr) throw countsErr; const counts = {}; (countsData || []).forEach(r => { 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++; }); // Also count manual_required groups for the manual card const { count: manualCount, error: manualCountErr } = await supabase .from("order_groups") .select("id", { count: "exact", head: true }) .eq("notification_status", "manual_required"); if (!manualCountErr && manualCount != null) { counts["manual"] = counts["manual"] || { total: 0, delivered: 0, sent: 0, errors: 0 }; counts["manual"].total = manualCount; } setCampaignCounts(counts); // For "manual" campaign: load order_groups with manual_required status if (activeCampaign === "manual") { const { data: manualData, error: manualErr } = await supabase .from("order_groups") .select("id, customer_name, customer_phone, notification_status, first_sms_sent_at, second_sms_sent_at, next_notification_check_at, sms_attempts, last_sms_error, delivery_status, delivery_link, created_at, updated_at") .eq("notification_status", "manual_required") .order("updated_at", { ascending: false }) .limit(200); if (manualErr) throw manualErr; setManualGroups(manualData || []); setLogs([]); // No SMS logs for manual campaign } else { setManualGroups([]); // Load logs for active campaign 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; setLogs(logData || []); } // Load settings for active campaign if (HAS_SETTINGS.includes(activeCampaign)) { setSettings(settingsMap[activeCampaign] || null); } else { setSettings(null); } } catch (e) { setError(e.message || String(e)); } finally { setIsLoading(false); } }, [activeCampaign, filter]); useEffect(() => { setIsLoading(true); loadData(); }, [loadData]); useEffect(() => { if (autoRefresh) { refreshTimer.current = setInterval(() => loadData(), 30000); 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), 2000); } catch (e) { setError(`Ошибка сохранения: ${e.message}`); } finally { setSavingSettings(false); } }; const updateSetting = (key, value) => { setSettings(prev => prev ? { ...prev, [key]: value } : prev); }; // ── Quick toggle campaign on/off from card ──────────────────────────────── 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; // Update local state 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)); }; // ── Recheck ──────────────────────────────────────────────────────────────── const handleRecheck = async (logId) => { setCheckingIds(prev => new Set([...prev, logId])); try { const { error: updateError } = await supabase .from("sms_campaign_log") .update({ needs_check: true, updated_at: new Date().toISOString() }) .eq("id", logId); if (updateError) throw updateError; setLogs(prev => prev.map(l => l.id === logId ? { ...l, needs_check: true } : l)); } catch (e) { setError(`Ошибка: ${e.message}`); } finally { setTimeout(() => { setCheckingIds(prev => { const n = new Set(prev); n.delete(logId); return n; }); }, 1000); } }; // ── Stats ────────────────────────────────────────────────────────────────── 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"; const isPaidStorageCampaign = activeCampaign === "paid_storage"; const showSmsFields = !isManualCampaign; // ── Render ────────────────────────────────────────────────────────────────── if (isLoading && logs.length === 0) { return (
Загрузка…
); } return (
{/* Balance banner — тот же живой баланс, что на главной (один источник) */} {smsBalance?.balance != null && (
💰
Баланс sms.ru
{smsBalance.balance.toLocaleString("ru-RU")} ₽
{!smsBalance.live && (
показан баланс на момент последней рассылки — живой запрос не прошёл
)}
)} {/* 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 cnt = campaignCounts[c.key] || { total: 0, delivered: 0, sent: 0, errors: 0 }; const hasS = HAS_SETTINGS.includes(c.key); const isToggling = togglingCampaign === c.key; return (
{ setActiveCampaign(c.key); setFilter("all"); setLogPage(1); setManualPage(1); }} > {/* Header: icon + toggle */}
{c.icon} {hasS && ( )}
{/* Title */}
{c.label}
{/* Status badge */} {hasS && (
{!isEnabled ? "⏸ Выключена" : isTest ? "🧪 Тест" : "🚀 Боевой"}
)} {/* Quick stats */} {hasS && cnt.total > 0 && (
Всего
{cnt.total}
Доставл.
{cnt.delivered}
Ошибок
{cnt.errors}
)} {!hasS && (
В разработке
)}
); })}
{/* ── City rules button ──────────────────────────────────────────── */}
{/* ── City profiles panel ────────────────────────────────────────── */} {showCityRules && } {error && (
{error}
)} {/* ── Statistics ────────────────────────────────────────────────────── */} {!showCityRules && } {/* ── Settings ──────────────────────────────────────────────────────── */} {!showCityRules && showSettings && settings && (

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

{settingsSaved && ✓ Сохранено}
{/* Test / Production toggle — только для SMS-кампаний */} {!isManualCampaign && (
{settings.test_mode && (
updateSetting("test_phone", v)} />
)}
)} {/* ── SMS text templates: доставка / самовывоз ───────────────────── */} {!isManualCampaign && (
📝 Текст SMS
📦 Доставка