supersam/src/components/admin/SmsCampaignPanel.jsx

759 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @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";
// ── 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"];
// ── 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 [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 refreshTimer = useRef(null);
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);
// 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 (
<Panel className="p-5">
<div className="animate-pulse text-sm text-[var(--color-text-muted)]">Загрузка</div>
</Panel>
);
}
return (
<div className="space-y-4">
{/* ── Balance + Campaign cards ─────────────────────────────────────────── */}
{/* Balance banner */}
{allSettings.first_sms?.last_balance != null && (
<Panel className="p-3">
<div className="flex items-center gap-3">
<span className="text-2xl">💰</span>
<div>
<div className="text-xs text-[var(--color-text-muted)]">Баланс sms.ru</div>
<div className="text-lg font-bold text-[var(--color-text)]">
{allSettings.first_sms.last_balance.toLocaleString("ru-RU")}
</div>
</div>
</div>
</Panel>
)}
{/* Campaign cards */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
{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 (
<div
key={c.key}
className={`rounded-2xl border p-4 transition cursor-pointer ${
isActive
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
: "border-[var(--color-border)] bg-[var(--color-surface)] hover:bg-[var(--color-surface-strong)]"
}`}
onClick={() => { setActiveCampaign(c.key); setFilter("all"); }}
>
{/* Header: icon + toggle */}
<div className="flex items-center justify-between">
<span className="text-2xl">{c.icon}</span>
{hasS && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); toggleCampaignEnabled(c.key); }}
disabled={isToggling}
className={`relative h-6 w-11 rounded-full transition ${isEnabled ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"}`}
>
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition ${isEnabled ? "left-[20px]" : "left-0.5"}`} />
</button>
)}
</div>
{/* Title */}
<div className="mt-2 text-sm font-semibold text-[var(--color-text)]">{c.label}</div>
{/* Status badge */}
{hasS && (
<div className="mt-1">
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
isEnabled
? (isTest ? "bg-[rgba(191,123,33,0.15)] text-[var(--color-warning)]" : "bg-[rgba(34,197,94,0.15)] text-[#22c55e]")
: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]"
}`}>
{!isEnabled ? "⏸ Выключена" : isTest ? "🧪 Тест" : "🚀 Боевой"}
</span>
</div>
)}
{/* Quick stats */}
{hasS && cnt.total > 0 && (
<div className="mt-3 grid grid-cols-3 gap-1 text-center">
<div className="rounded-lg bg-[var(--color-surface-strong)] py-1">
<div className="text-[10px] text-[var(--color-text-muted)]">Всего</div>
<div className="text-xs font-bold text-[var(--color-text)]">{cnt.total}</div>
</div>
<div className="rounded-lg bg-[var(--color-surface-strong)] py-1">
<div className="text-[10px] text-[var(--color-text-muted)]">Доставл.</div>
<div className="text-xs font-bold text-[#22c55e]">{cnt.delivered}</div>
</div>
<div className="rounded-lg bg-[var(--color-surface-strong)] py-1">
<div className="text-[10px] text-[var(--color-text-muted)]">Ошибок</div>
<div className="text-xs font-bold text-[#ef4444]">{cnt.errors}</div>
</div>
</div>
)}
{!hasS && (
<div className="mt-2 text-[11px] text-[var(--color-text-muted)] leading-snug">В разработке</div>
)}
</div>
);
})}
</div>
{error && (
<Panel className="p-3">
<div className="text-xs text-[var(--color-danger)]">{error}</div>
<button onClick={() => { setError(null); loadData(); }} className="mt-2 text-xs text-[var(--color-accent)]">Повторить</button>
</Panel>
)}
{/* ── Statistics ────────────────────────────────────────────────────── */}
<SmsCampaignStats campaignType={activeCampaign} />
{/* ── Settings ──────────────────────────────────────────────────────── */}
{showSettings && settings && (
<Panel className="p-5">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-semibold text-[var(--color-text)]">
{CAMPAIGNS.find(c => c.key === activeCampaign)?.icon} {CAMPAIGNS.find(c => c.key === activeCampaign)?.label}
</h3>
{settingsSaved && <span className="text-xs text-[var(--color-accent)]"> Сохранено</span>}
</div>
{/* Test / Production toggle — только для SMS-кампаний */}
{!isManualCampaign && (
<div className={`mb-4 rounded-xl border p-3 ${settings.test_mode ? "border-[var(--color-warning)] bg-[rgba(191,123,33,0.08)]" : "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"}`}>
<label className="flex items-center justify-between">
<div>
<div className="text-sm font-semibold text-[var(--color-text)]">
{settings.test_mode ? "🧪 Тестовый режим" : "🚀 Боевой режим"}
</div>
<div className="mt-0.5 text-xs text-[var(--color-text-muted)]">
{settings.test_mode
? `SMS только на: ${settings.test_phone || "—"}`
: "SMS реальным клиентам из базы"}
</div>
</div>
<button
type="button"
onClick={() => updateSetting("test_mode", !settings.test_mode)}
className={`relative h-7 w-12 rounded-full transition ${settings.test_mode ? "bg-[var(--color-warning)]" : "bg-[var(--color-accent)]"}`}
>
<span className={`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition ${settings.test_mode ? "left-0.5" : "left-[22px]"}`} />
</button>
</label>
{settings.test_mode && (
<div className="mt-3">
<SettingField label="Тестовый номер" value={settings.test_phone || ""} onChange={(v) => updateSetting("test_phone", v)} />
</div>
)}
</div>
)}
{/* ── SMS text template ──────────────────────────────────────────── */}
{!isManualCampaign && (
<div className="mb-4 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
<div className="mb-2 text-xs font-semibold text-[var(--color-text)]">📝 Текст SMS</div>
<textarea
value={settings.sms_text_template || ""}
onChange={(e) => updateSetting("sms_text_template", e.target.value)}
rows={3}
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none resize-y"
placeholder="Текст SMS. Используйте {link} для подстановки ссылки"
/>
<div className="mt-1 text-[10px] text-[var(--color-text-muted)]">
{"{link}"} будет заменён на ссылку доставки. Текущая длина: {(settings.sms_text_template || "").replace("{link}", "https://dost.supersamsev.ru/d/XXXXXX").length} символов
</div>
</div>
)}
{/* ── Интервалы ─────────────────────────────────────────────────── */}
<div className="mb-1 text-xs font-semibold text-[var(--color-text)]">
{isManualCampaign ? "⚙️ Настройки" : "📊 Интервалы и попытки"}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{!isManualCampaign && (
<>
<SettingField label="Пауза между проверками (сек)" value={settings.wait_between_checks_seconds} onChange={(v) => updateSetting("wait_between_checks_seconds", parseInt(v) || 25)} />
<SettingField label="Макс. время ожидания (мин)" value={settings.max_check_duration_minutes} onChange={(v) => updateSetting("max_check_duration_minutes", parseInt(v) || 90)} />
<SettingField label="Макс. попыток отправки" value={settings.max_attempts} onChange={(v) => updateSetting("max_attempts", parseInt(v) || 2)} />
{!isPaidStorageCampaign && (
<SettingField label="Вторая SMS через (часов)" value={settings.second_sms_delay_hours} onChange={(v) => updateSetting("second_sms_delay_hours", parseInt(v) || 3)} />
)}
</>
)}
<SettingField label="Ручное согласование через (часов)" value={settings.auto_manual_after_hours} onChange={(v) => updateSetting("auto_manual_after_hours", parseInt(v) || 3)} />
<SettingField label="Telegram chat ID" value={settings.telegram_chat_id || ""} onChange={(v) => updateSetting("telegram_chat_id", v)} />
</div>
{/* ── Время работы ────────────────────────────────────────────── */}
<div className="mt-4 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
<div className="mb-3 text-xs font-semibold text-[var(--color-text)]">
{isManualCampaign ? "⏰ Время проверки" : "⏰ Время отправки SMS"}
</div>
{/* Часы */}
<div className="mb-3 flex items-center gap-2">
<select
value={settings.work_hours_start ?? 8}
onChange={(e) => updateSetting("work_hours_start", parseInt(e.target.value))}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
>
{Array.from({ length: 24 }, (_, h) => <option key={h} value={h}>{h}:00</option>)}
</select>
<span className="text-xs text-[var(--color-text-muted)]"></span>
<select
value={settings.work_hours_end ?? 21}
onChange={(e) => updateSetting("work_hours_end", parseInt(e.target.value))}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
>
{Array.from({ length: 24 }, (_, h) => <option key={h} value={h}>{h}:00</option>)}
</select>
<span className="text-[10px] text-[var(--color-text-muted)]">по Москве</span>
</div>
{/* Дни недели — таблетки */}
<div className="flex flex-wrap gap-2">
{DAYS.map(d => {
const activeDays = parseWorkDays(settings.work_days);
const isOn = activeDays.has(d.num);
return (
<button
key={d.num}
type="button"
onClick={() => toggleWorkDay(d.num)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition ${
isOn
? "bg-[var(--color-accent)] text-white"
: "border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
}`}
>
{d.short}
</button>
);
})}
</div>
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
Проверка статусов работает круглосуточно. Отправка только в выбранные часы и дни.
</div>
</div>
{/* ── Вкл/выкл + сохранить ──────────────────────────────────────── */}
<div className="mt-4 flex items-center gap-3">
<button
type="button"
onClick={() => updateSetting("enabled", !(settings.enabled ?? true))}
className={`relative h-7 w-12 rounded-full transition ${settings.enabled ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"}`}
>
<span className={`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition ${settings.enabled ? "left-[22px]" : "left-0.5"}`} />
</button>
<span className="text-xs font-medium text-[var(--color-text)]">
{settings.enabled ? "Кампания включена" : "Кампания выключена"}
</span>
<button
onClick={handleSaveSettings}
disabled={savingSettings}
className="ml-auto rounded-xl bg-[var(--color-accent)] px-4 py-1.5 text-xs font-semibold text-white hover:opacity-90 disabled:opacity-50"
>
{savingSettings ? "Сохранение…" : "Сохранить"}
</button>
</div>
</Panel>
)}
{/* ── Not implemented ──────────────────────────────────────────────── */}
{!showSettings && (
<Panel className="p-5">
<div className="text-sm text-[var(--color-text-muted)]">
{CAMPAIGNS.find(c => c.key === activeCampaign)?.label} в разработке
</div>
</Panel>
)}
{/* ── Manual campaign: groups table ─────────────────────────────────── */}
{isManualCampaign ? (
<>
<Panel className="p-4">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-[var(--color-text-muted)]">В ручном управлении: {manualGroups.length}</span>
</div>
</Panel>
<Panel className="p-0">
<div className="overflow-x-auto">
<div className="min-w-[800px]">
<div className="grid grid-cols-[minmax(160px,2fr)_minmax(120px,1fr)_minmax(120px,1fr)_minmax(120px,1fr)_minmax(100px,0.8fr)] gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.08em] text-[var(--color-text-muted)]">
<div className="px-3 py-1.5 font-medium">Клиент</div>
<div className="px-3 py-1.5 font-medium">Телефон</div>
<div className="px-3 py-1.5 font-medium">1-я SMS</div>
<div className="px-3 py-1.5 font-medium">2-я SMS</div>
<div className="px-3 py-1.5 font-medium">Обновлён</div>
</div>
{manualGroups.length === 0 ? (
<div className="px-4 py-6 text-xs text-[var(--color-text-muted)]">Нет групп в ручном управлении</div>
) : (
manualGroups.map((group) => (
<div
key={group.id}
onClick={() => handleOpenGroup(group.id)}
className="grid grid-cols-[minmax(160px,2fr)_minmax(120px,1fr)_minmax(120px,1fr)_minmax(120px,1fr)_minmax(100px,0.8fr)] gap-0 border-t border-[var(--color-border)] text-xs hover:bg-[var(--color-accent-soft)] cursor-pointer"
>
<div className="px-3 py-1.5 text-[var(--color-text)]">
<div className="font-medium">{group.customer_name || "—"}</div>
{group.last_sms_error && <div className="mt-0.5 text-[10px] text-[var(--color-danger)]">{group.last_sms_error.slice(0, 60)}</div>}
</div>
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">{fmtPhone(group.customer_phone)}</div>
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">
{group.first_sms_sent_at ? fmtTime(group.first_sms_sent_at) : "—"}
</div>
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">
{group.second_sms_sent_at ? fmtTime(group.second_sms_sent_at) : <span className="text-[var(--color-danger)]">не отправлена</span>}
</div>
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">
{fmtTime(group.updated_at)}
<div className="text-[10px]">{fmtElapsed(group.updated_at)}</div>
</div>
</div>
))
)}
</div>
</div>
</Panel>
</>
) : (
<>
{/* ── Stats ─────────────────────────────────────────────────────────── */}
<Panel className="p-4">
<div className="flex flex-wrap items-center gap-2">
<span className="mr-2 text-xs font-semibold text-[var(--color-text-muted)]">Всего: {logs.length}</span>
{Object.entries(STATUS_LABELS).map(([status, label]) => {
const count = stats[status] || 0;
if (count === 0) return null;
return <Badge key={status} tone={STATUS_TONES[status] || "neutral"}>{label}: {count}</Badge>;
})}
</div>
</Panel>
{/* ── Filter + auto-refresh ─────────────────────────────────────────── */}
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap gap-2">
<FilterButton active={filter === "all"} onClick={() => setFilter("all")}>Все ({logs.length})</FilterButton>
{Object.entries(STATUS_LABELS).map(([status, label]) => {
const count = stats[status] || 0;
if (count === 0) return null;
return <FilterButton key={status} active={filter === status} onClick={() => setFilter(status)}>{label} ({count})</FilterButton>;
})}
</div>
<label className="flex items-center gap-1.5 text-xs text-[var(--color-text-muted)]">
<input type="checkbox" checked={autoRefresh} onChange={(e) => setAutoRefresh(e.target.checked)} className="h-3.5 w-3.5 rounded border-[var(--color-border)]" />
Авто-обновление (30с)
</label>
</div>
{/* ── Log table ──────────────────────────────────────────────────────── */}
<Panel className="p-0">
<div className="overflow-x-auto">
<div className="min-w-[900px]">
<div className="grid grid-cols-[minmax(140px,1.5fr)_minmax(100px,1fr)_minmax(80px,0.8fr)_minmax(70px,0.6fr)_minmax(50px,0.4fr)_minmax(100px,1fr)_minmax(90px,0.8fr)] gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.08em] text-[var(--color-text-muted)]">
<div className="px-3 py-1.5 font-medium">Телефон</div>
<div className="px-3 py-1.5 font-medium">SMS ID</div>
<div className="px-3 py-1.5 font-medium">Статус</div>
<div className="px-3 py-1.5 font-medium">Код</div>
<div className="px-3 py-1.5 font-medium">Поп.</div>
<div className="px-3 py-1.5 font-medium">Создано</div>
<div className="px-3 py-1.5 font-medium">Действие</div>
</div>
{logs.length === 0 ? (
<div className="px-4 py-6 text-xs text-[var(--color-text-muted)]">Нет записей</div>
) : (
logs.map((entry) => {
const canRecheck = entry.status === "sent" || entry.status === "checking";
const isChecking = checkingIds.has(entry.id);
const isTestMode = settings?.test_mode;
return (
<div
key={entry.id}
onClick={() => handleOpenGroup(entry.order_group_id)}
className="grid grid-cols-[minmax(140px,1.5fr)_minmax(100px,1fr)_minmax(80px,0.8fr)_minmax(70px,0.6fr)_minmax(50px,0.4fr)_minmax(100px,1fr)_minmax(90px,0.8fr)] gap-0 border-t border-[var(--color-border)] text-xs hover:bg-[var(--color-accent-soft)] cursor-pointer"
>
<div className="px-3 py-1.5 text-[var(--color-text)]">
{fmtPhone(entry.customer_phone)}
{isTestMode && <div className="text-[10px] text-[var(--color-warning)]">🧪 на тест. номер</div>}
</div>
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">{entry.sms_id || "—"}</div>
<div className="px-3 py-1.5">
<Badge tone={STATUS_TONES[entry.status] || "neutral"}>{STATUS_LABELS[entry.status] || entry.status}</Badge>
{entry.needs_check && <div className="mt-0.5 text-[10px] text-[var(--color-accent)]"> в очереди</div>}
</div>
<div className="px-3 py-1.5 text-[var(--color-text-muted)]" title={SMS_CODE_LABELS[entry.sms_code] || ""}>
{entry.sms_code || "—"}
{entry.sms_code && SMS_CODE_LABELS[entry.sms_code] && <div className="text-[10px]">{SMS_CODE_LABELS[entry.sms_code]}</div>}
</div>
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">{entry.attempts || 0}</div>
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">
{fmtTime(entry.created_at)}
<div className="text-[10px]">{fmtElapsed(entry.created_at)}</div>
{entry.error_message && <div className="mt-0.5 text-[10px] text-[var(--color-danger)]">{entry.error_message.slice(0, 80)}</div>}
</div>
<div className="px-3 py-1.5" onClick={(e) => e.stopPropagation()}>
{canRecheck ? (
<button
onClick={() => handleRecheck(entry.id)}
disabled={isChecking || entry.needs_check}
className="rounded-lg border border-[var(--color-border)] px-2 py-1 text-[11px] font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-40"
>
{isChecking ? "…" : entry.needs_check ? "⟳ В очереди" : "↻ Проверить"}
</button>
) : (
<span className="text-[10px] text-[var(--color-text-muted)]"> к доставке</span>
)}
</div>
</div>
);
})
)}
</div>
</div>
</Panel>
<div className="flex justify-end">
<button onClick={loadData} className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)]">
Обновить
</button>
</div>
</>
)}
</div>
);
};
// ── Helpers ──────────────────────────────────────────────────────────────────
const SettingField = ({ label, value, onChange }) => (
<label className="block">
<span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">{label}</span>
<input
type="text"
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
/>
</label>
);
const FilterButton = ({ active, onClick, children }) => (
<button
onClick={onClick}
className={`rounded-full px-3 py-1 text-xs font-medium transition ${
active
? "bg-[var(--color-accent)] text-white"
: "border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
}`}
>
{children}
</button>
);