/**
* @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 */}
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 && (
)}
{/* Intervals and attempts */}
{isManualCampaign ? "⚙️ Настройки" : "📊 Интервалы и попытки"}
{!isManualCampaign && (
<>
updateSetting("wait_between_checks_seconds", parseInt(v) || 25)} />
updateSetting("max_check_duration_minutes", parseInt(v) || 90)} />
updateSetting("max_attempts", parseInt(v) || 2)} />
updateSetting("send_interval_seconds", parseInt(v) || 15)} />
{!isPaidStorageCampaign && (
updateSetting("second_sms_delay_hours", parseInt(v) || 3)} />
)}
>
)}
updateSetting("auto_manual_after_hours", parseInt(v) || 3)} />
updateSetting("telegram_chat_id", v)} />
{/* Work hours / days */}
⏰ Время отправки SMS
—
по Москве
{DAYS.map(d => {
const activeDays = parseWorkDays(settings.work_days);
const isOn = activeDays.has(d.num);
return (
);
})}
Проверка статусов работает круглосуточно. Отправка — только в выбранные часы и дни.
{/* Footer: enable toggle + save */}
updateSetting("enabled", !(settings.enabled ?? true))}
size="md"
/>
{settings.enabled ? "Кампания включена" : "Кампания выключена"}
)}
)}
{/* Stats summary */}
Всего: {logs.length}
{Object.entries(STATUS_LABELS).map(([status, label]) => {
const count = stats[status] || 0;
if (count === 0) return null;
return {label}: {count};
})}
{/* Filter + auto-refresh */}
setFilter("all")}>Все ({logs.length})
{Object.entries(STATUS_LABELS).map(([status, label]) => {
const count = stats[status] || 0;
if (count === 0) return null;
return setFilter(status)}>{label} ({count});
})}
{/* 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}
))}
{/* ── Delete toolbar ── */}
{(() => {
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));
return allSelected ? "✓ Снять выделение" : "☐ Выбрать все";
})()}
{selectedIds.size > 0 && (
)}
{logs.some(l => l.was_test_mode === true) && (
)}
{selectedIds.size > 0 && (
Выбрано: {selectedIds.size}
)}
{/* ── Log table ── */}
{/* Desktop table */}
{logs.length === 0 ? (
Нет записей
) : (
logs.filter((entry) => {
const isOpened = openedGroupIds.has(entry.order_group_id);
return passesDateFilter(entry.created_at, dateFilter) && passesLinkFilter(isOpened, linkFilter);
}).slice((logPage - 1) * PAGE_SIZE, logPage * PAGE_SIZE).map((entry) => {
const canRecheck = entry.status === "sent" || entry.status === "checking";
const isChecking = checkingIds.has(entry.id);
const wasTest = entry.was_test_mode === true;
const isReplaced = entry.sent_to && entry.sent_to !== entry.customer_phone;
const isOpened = openedGroupIds.has(entry.order_group_id);
return (
handleOpenGroup(entry.order_group_id)}
>
e.stopPropagation()}>
toggleSelect(entry.id)}
className="h-4 w-4 cursor-pointer rounded border-[var(--color-border)] accent-[var(--color-accent)]"
/>
{fmtPhone(entry.customer_phone)}
{wasTest
?
🧪 тестовая
:
🚀 боевая
}
{isReplaced &&
→ {fmtPhone(entry.sent_to)}
}
{STATUS_ICONS[entry.status] || ""} {STATUS_LABELS[entry.status] || entry.status}
{entry.attempts > 1 &&
попытка {entry.attempts}
}
{entry.sms_code && SMS_CODE_LABELS[entry.sms_code] && (
{entry.sms_code}: {SMS_CODE_LABELS[entry.sms_code]}
)}
{(() => { const a = fmtAgreement(entry._group); return (
{a.text}
); })()}
{fmtTime(entry.created_at)}
{fmtElapsed(entry.created_at)}
{entry.checked_at ? (
{fmtTime(entry.checked_at)}
{fmtElapsed(entry.checked_at)}
) : (
— не проверено
)}
{entry.error_message &&
⚠ {entry.error_message.slice(0, 80)}
}
{isOpened
? ✅ Открыта
: — нет
}
e.stopPropagation()}>
{canRecheck ? (
) : (
→ к доставке
)}
);
})
)}
{/* Mobile cards */}
{logs.length === 0 ? (
Нет записей
) : (
logs.filter((entry) => {
const isOpened = openedGroupIds.has(entry.order_group_id);
return passesDateFilter(entry.created_at, dateFilter) && passesLinkFilter(isOpened, linkFilter);
}).slice((logPage - 1) * PAGE_SIZE, logPage * PAGE_SIZE).map((entry) => {
const canRecheck = entry.status === "sent" || entry.status === "checking";
const isChecking = checkingIds.has(entry.id);
const wasTest = entry.was_test_mode === true;
const isReplaced = entry.sent_to && entry.sent_to !== entry.customer_phone;
const isOpened = openedGroupIds.has(entry.order_group_id);
return (
handleOpenGroup(entry.order_group_id)}>
{/* Header: checkbox + phone + status badge */}
{STATUS_ICONS[entry.status] || ""} {STATUS_LABELS[entry.status] || entry.status}
{/* Test/real badge */}
{wasTest
? 🧪 тестовая
: 🚀 боевая
}
{isReplaced && → {fmtPhone(entry.sent_to)}}
{/* Info rows */}
{(() => { const a = fmtAgreement(entry._group); return (
); })()}
{entry.sms_code && SMS_CODE_LABELS[entry.sms_code] && (
)}
{entry.error_message && (
⚠ {entry.error_message.slice(0, 80)}
)}
{/* Action button */}
{canRecheck && (
e.stopPropagation()}>
)}
);
})
)}
{(() => {
const filteredLogs = logs.filter((entry) => {
const isOpened = openedGroupIds.has(entry.order_group_id);
return passesDateFilter(entry.created_at, dateFilter) && passesLinkFilter(isOpened, linkFilter);
});
const totalPages = Math.ceil(filteredLogs.length / PAGE_SIZE);
if (totalPages <= 1) return null;
return
;
})()}
>
)}
{/* ── Footer ─────────────────────────────────────────────────────────── */}
);
};