feat: SMS campaign sub-tabs + recheck button + dual send protection

- SmsCampaignPanel: 4 sub-tabs (first_sms/second_sms/manual/paid_storage)
- 'Проверить снова' button sets needs_check=true in sms_campaign_log
- Auto-refresh every 30s with toggle
- Elapsed time display per row
- Script: after send, notification_status='sms_sending' (not 'link_ready')
  → double protection against re-sending to same group
- Script: get_sms_to_check includes needs_check=true records regardless of age
- Script: checked_at timestamp on each status check
- DB: needs_check boolean + checked_at timestamptz columns added
This commit is contained in:
root 2026-06-23 10:35:52 +00:00
parent 4215001ab7
commit d2ac10d849
2 changed files with 247 additions and 90 deletions

View File

@ -9,9 +9,14 @@ SuperSam — SMS First Campaign (State Machine)
2. Проверяет статус ранее отправленных SMS (sent но не delivered, в пределах max_check_duration) 2. Проверяет статус ранее отправленных SMS (sent но не delivered, в пределах max_check_duration)
3. Обновляет статусы в order_groups + sms_campaign_log 3. Обновляет статусы в order_groups + sms_campaign_log
Защита от повторной отправки: Защита от повторной отправки (ДВОЙНАЯ):
- Группа с sms_campaign_log status='sent'/'checking' в последние max_check_duration_minutes skip 1. После отправки SMS notification_status = 'sms_sending' (не 'link_ready')
- Код 231/132 = лимит одинаковых не повторять get_groups_to_send НЕ находит эту группу (фильтр по notification_status = 'link_ready')
2. Дополнительно: EXISTS проверка в sms_campaign_log (status sent/checking за последние 24h)
3. Код 231/132 = лимит одинаковых не повторять
needs_check=true admin нажал "Проверить снова" в UI
скрипт проверяет даже если запись старше max_check_duration
Коды sms.ru: Коды sms.ru:
100 = в очереди, 101 = оператору, 102 = в пути ждём 100 = в очереди, 101 = оператору, 102 = в пути ждём
@ -192,19 +197,26 @@ def get_groups_to_send(conn):
return [dict(r) for r in cur.fetchall()] return [dict(r) for r in cur.fetchall()]
def get_sms_to_check(conn, max_duration_min): def get_sms_to_check(conn, max_duration_min):
"""SMS в логе со status='sent'/'checking', которые ещё не доставлены""" """SMS в логе со status='sent'/'checking', которые ещё не доставлены.
Включает:
- Записи младше max_duration_min (обычная автопроверка)
- Записи с needs_check=true (admin нажал 'Проверить снова') независимо от возраста
"""
with conn.cursor(cursor_factory=RealDictCursor) as cur: with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(""" cur.execute("""
SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone, SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
scl.attempts, scl.created_at, scl.sms_code, scl.attempts, scl.created_at, scl.sms_code, scl.needs_check,
og.customer_name, og.group_key og.customer_name, og.group_key
FROM sms_campaign_log scl FROM sms_campaign_log scl
JOIN order_groups og ON og.id = scl.order_group_id JOIN order_groups og ON og.id = scl.order_group_id
WHERE scl.campaign_type = 'first_sms' WHERE scl.campaign_type = 'first_sms'
AND scl.status IN ('sent', 'checking') AND scl.status IN ('sent', 'checking')
AND scl.sms_id IS NOT NULL AND scl.sms_id IS NOT NULL
AND scl.created_at > NOW() - INTERVAL '%s minutes' AND (
ORDER BY scl.created_at ASC scl.created_at > NOW() - INTERVAL '%s minutes'
OR scl.needs_check = true
)
ORDER BY scl.needs_check DESC, scl.created_at ASC
""" % max_duration_min) """ % max_duration_min)
return [dict(r) for r in cur.fetchall()] return [dict(r) for r in cur.fetchall()]
@ -239,8 +251,11 @@ def update_sms_log(conn, log_id, **kwargs):
set_parts = [] set_parts = []
values = [] values = []
for k, v in kwargs.items(): for k, v in kwargs.items():
set_parts.append(f"{k} = %s") if v == "NOW()":
values.append(v) set_parts.append(f"{k} = NOW()")
else:
set_parts.append(f"{k} = %s")
values.append(v)
values.append(log_id) values.append(log_id)
cur.execute(f"UPDATE sms_campaign_log SET {', '.join(set_parts)}, updated_at = NOW() WHERE id = %s", values) cur.execute(f"UPDATE sms_campaign_log SET {', '.join(set_parts)}, updated_at = NOW() WHERE id = %s", values)
conn.commit() conn.commit()
@ -313,7 +328,13 @@ def step_send_new(conn, settings):
sms_code=code, sms_code=code,
attempts=1, attempts=1,
) )
log.info(f"Group {group_id}: SMS sent, sms_id={sms_id}, log_id={log_id}") # ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status,
# чтобы get_groups_to_send не нашёл эту группу при следующем запуске
update_order_group(conn, group_id, {
"notification_status": "sms_sending",
"sms_sent_at": "NOW()",
})
log.info(f"Group {group_id}: SMS sent, sms_id={sms_id}, log_id={log_id}, notification_status→sms_sending")
sent_count += 1 sent_count += 1
else: else:
# Ошибка отправки # Ошибка отправки
@ -357,9 +378,13 @@ def step_check_status(conn, settings):
name = item.get("customer_name") or item.get("group_key", "") name = item.get("customer_name") or item.get("group_key", "")
phone = item.get("customer_phone", "") phone = item.get("customer_phone", "")
attempts = item.get("attempts", 1) attempts = item.get("attempts", 1)
was_needs_check = item.get("needs_check", False)
code, raw, api_code = check_sms_status(sms_id, api_id) code, raw, api_code = check_sms_status(sms_id, api_id)
# Сбрасываем needs_check + ставим checked_at
update_sms_log(conn, log_id, needs_check=False, checked_at="NOW()")
if code == DELIVERED_CODE: if code == DELIVERED_CODE:
# Доставлено! # Доставлено!
log.info(f"Group {group_id}: SMS delivered (103)!") log.info(f"Group {group_id}: SMS delivered (103)!")

View File

@ -1,22 +1,32 @@
/** /**
* @file SmsCampaignPanel.jsx * @file SmsCampaignPanel.jsx
* @description SMS Campaign log + settings for mega_admin. * @description SMS Campaign management for mega_admin.
* Shows sms_campaign_log entries with filtering and sms_campaign_settings editable. * Sub-tabs: Первая отправка | Второе сообщение | Ручное управление | Платное хранение
* Each tab: settings + log table + "Проверить снова" button + auto-refresh
*/ */
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback, useRef } from "react";
import { Panel } from "../UI/Panel"; import { Panel } from "../UI/Panel";
import { Badge } from "../UI/Badge"; import { Badge } from "../UI/Badge";
import { supabase } from "../../supabaseClient"; import { supabase } from "../../supabaseClient";
// Status labels // Campaign tabs
const CAMPAIGN_TABS = [
{ key: "first_sms", label: "Первая отправка", icon: "📤" },
{ key: "second_sms", label: "Второе сообщение", icon: "📨" },
{ key: "manual", label: "Ручное управление", icon: "🔧" },
{ key: "paid_storage", label: "Платное хранение", icon: "📦" },
];
// Status labels
const STATUS_LABELS = { const STATUS_LABELS = {
sent: "Отправлено", sent: "Отправлено",
checking: "Проверяется", checking: "Проверяется",
delivered: "Доставлено", delivered: "Доставлено",
send_failed: "Ошибка отправки", send_failed: "Ошибка отправки",
error: "Ошибка доставки", error: "Ошибка доставки",
expired: "Истекло", expired: "Истёк срок",
limit_exceeded: "Лимит превышен", limit_exceeded: "Лимит превышен",
manual_override: "Ручной режим",
}; };
const STATUS_TONES = { const STATUS_TONES = {
@ -27,6 +37,7 @@ const STATUS_TONES = {
error: "danger", error: "danger",
expired: "warning", expired: "warning",
limit_exceeded: "danger", limit_exceeded: "danger",
manual_override: "warning",
}; };
// SMS code labels (from sms.ru docs) // SMS code labels (from sms.ru docs)
@ -35,7 +46,7 @@ const SMS_CODE_LABELS = {
"101": "Оператору", "101": "Оператору",
"102": "В пути", "102": "В пути",
"103": "Доставлено", "103": "Доставлено",
"104": "Истекло время", "104": "Истёкло время",
"105": "Удалено оператором", "105": "Удалено оператором",
"106": "Сбой телефона", "106": "Сбой телефона",
"107": "Неизвестная причина", "107": "Неизвестная причина",
@ -70,7 +81,27 @@ const fmtPhone = (phone) => {
return phone; return phone;
}; };
/** Минуты с момента created_at */
const minutesAgo = (ts) => {
if (!ts) return null;
const diff = Date.now() - new Date(ts).getTime();
return Math.floor(diff / 60000);
};
/** Человекочитаемый "прошло X мин" */
const fmtElapsed = (ts) => {
const m = minutesAgo(ts);
if (m === null) return "—";
if (m < 1) return "только что";
if (m < 60) return `${m} мин назад`;
const h = Math.floor(m / 60);
const rest = m % 60;
return `${h}ч ${rest}м назад`;
};
// Component
export const SmsCampaignPanel = () => { export const SmsCampaignPanel = () => {
const [activeTab, setActiveTab] = useState("first_sms");
const [logs, setLogs] = useState([]); const [logs, setLogs] = useState([]);
const [settings, setSettings] = useState(null); const [settings, setSettings] = useState(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@ -78,16 +109,19 @@ export const SmsCampaignPanel = () => {
const [filter, setFilter] = useState("all"); const [filter, setFilter] = useState("all");
const [savingSettings, setSavingSettings] = useState(false); const [savingSettings, setSavingSettings] = useState(false);
const [settingsSaved, setSettingsSaved] = useState(false); const [settingsSaved, setSettingsSaved] = useState(false);
const [checkingIds, setCheckingIds] = useState(new Set());
const [autoRefresh, setAutoRefresh] = useState(true);
const refreshTimer = useRef(null);
// Load data // Load data
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setIsLoading(true);
setError(null); setError(null);
try { try {
// Load logs // Load logs for active campaign tab
let query = supabase let query = supabase
.from("sms_campaign_log") .from("sms_campaign_log")
.select("*") .select("*")
.eq("campaign_type", activeTab)
.order("created_at", { ascending: false }) .order("created_at", { ascending: false })
.limit(200); .limit(200);
@ -99,11 +133,11 @@ export const SmsCampaignPanel = () => {
if (logError) throw logError; if (logError) throw logError;
setLogs(logData || []); setLogs(logData || []);
// Load settings // Load settings for active campaign
const { data: settingsData, error: settingsError } = await supabase const { data: settingsData, error: settingsError } = await supabase
.from("sms_campaign_settings") .from("sms_campaign_settings")
.select("*") .select("*")
.eq("campaign_type", "first_sms") .eq("campaign_type", activeTab)
.single(); .single();
if (settingsError && settingsError.code !== "PGRST116") throw settingsError; if (settingsError && settingsError.code !== "PGRST116") throw settingsError;
setSettings(settingsData || null); setSettings(settingsData || null);
@ -112,9 +146,20 @@ export const SmsCampaignPanel = () => {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [filter]); }, [activeTab, filter]);
useEffect(() => { loadData(); }, [loadData]); // Initial load + auto-refresh
useEffect(() => {
setIsLoading(true);
loadData();
}, [loadData]);
useEffect(() => {
if (autoRefresh) {
refreshTimer.current = setInterval(() => loadData(), 30000);
return () => clearInterval(refreshTimer.current);
}
}, [autoRefresh, loadData]);
// Save settings // Save settings
const handleSaveSettings = async () => { const handleSaveSettings = async () => {
@ -141,14 +186,42 @@ export const SmsCampaignPanel = () => {
setSettings(prev => prev ? { ...prev, [key]: value } : prev); setSettings(prev => prev ? { ...prev, [key]: value } : prev);
}; };
// "Проверить снова" sets needs_check=true
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;
// Update local state
setLogs(prev => prev.map(l =>
l.id === logId ? { ...l, needs_check: true } : l
));
} catch (e) {
setError(`Ошибка: ${e.message}`);
} finally {
setTimeout(() => {
setCheckingIds(prev => {
const next = new Set(prev);
next.delete(logId);
return next;
});
}, 1000);
}
};
// Stats summary // Stats summary
const stats = logs.reduce((acc, log) => { const stats = logs.reduce((acc, log) => {
acc[log.status] = (acc[log.status] || 0) + 1; acc[log.status] = (acc[log.status] || 0) + 1;
return acc; return acc;
}, {}); }, {});
const hasSettings = activeTab === "first_sms"; // Only first_sms has settings for now
// Render // Render
if (isLoading) { if (isLoading && logs.length === 0) {
return ( return (
<Panel className="p-5"> <Panel className="p-5">
<div className="animate-pulse text-sm text-[var(--color-text-muted)]">Загрузка SMS-логов</div> <div className="animate-pulse text-sm text-[var(--color-text-muted)]">Загрузка SMS-логов</div>
@ -156,29 +229,44 @@ export const SmsCampaignPanel = () => {
); );
} }
if (error) {
return (
<Panel className="p-5">
<div className="text-sm text-[var(--color-danger)]">Ошибка: {error}</div>
<button onClick={loadData} className="mt-3 rounded-xl border border-[var(--color-border)] px-3 py-1.5 text-xs hover:bg-[var(--color-surface-strong)]">
Повторить
</button>
</Panel>
);
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Settings panel */} {/* ── Campaign sub-tabs ─────────────────────────────────────────────── */}
{settings && ( <div className="flex flex-wrap gap-2">
{CAMPAIGN_TABS.map(tab => (
<button
key={tab.key}
onClick={() => { setActiveTab(tab.key); setFilter("all"); }}
className={`rounded-xl px-3 py-1.5 text-xs font-medium transition ${
activeTab === tab.key
? "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)]"
}`}
>
{tab.icon} {tab.label}
</button>
))}
</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>
)}
{/* ── Settings panel (only for campaigns with settings) ─────────────── */}
{hasSettings && settings && (
<Panel className="p-5"> <Panel className="p-5">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-semibold text-[var(--color-text)]">Настройки кампании</h3> <h3 className="text-sm font-semibold text-[var(--color-text)]">Настройки: {CAMPAIGN_TABS.find(t => t.key === activeTab)?.label}</h3>
{settingsSaved && ( {settingsSaved && (
<span className="text-xs text-[var(--color-accent)]"> Сохранено</span> <span className="text-xs text-[var(--color-accent)]"> Сохранено</span>
)} )}
</div> </div>
{/* Test / Production mode toggle */} {/* Test / Production mode toggle */}
<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)]"}`}> <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"> <label className="flex items-center justify-between">
@ -210,6 +298,7 @@ export const SmsCampaignPanel = () => {
</div> </div>
)} )}
</div> </div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<SettingField <SettingField
label="Пауза между проверками (сек)" label="Пауза между проверками (сек)"
@ -263,7 +352,16 @@ export const SmsCampaignPanel = () => {
</Panel> </Panel>
)} )}
{/* Stats summary */} {/* ── Not-yet-implemented tabs ──────────────────────────────────────── */}
{!hasSettings && (
<Panel className="p-5">
<div className="text-sm text-[var(--color-text-muted)]">
{CAMPAIGN_TABS.find(t => t.key === activeTab)?.label} в разработке
</div>
</Panel>
)}
{/* ── Stats summary ──────────────────────────────────────────────────── */}
<Panel className="p-4"> <Panel className="p-4">
<div className="flex flex-wrap items-center gap-2"> <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> <span className="mr-2 text-xs font-semibold text-[var(--color-text-muted)]">Всего: {logs.length}</span>
@ -279,34 +377,46 @@ export const SmsCampaignPanel = () => {
</div> </div>
</Panel> </Panel>
{/* Filter buttons */} {/* ── Filter + auto-refresh ──────────────────────────────────────────── */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<FilterButton active={filter === "all"} onClick={() => setFilter("all")}> <div className="flex flex-wrap gap-2">
Все ({logs.length}) <FilterButton active={filter === "all"} onClick={() => setFilter("all")}>
</FilterButton> Все ({logs.length})
{Object.entries(STATUS_LABELS).map(([status, label]) => { </FilterButton>
const count = stats[status] || 0; {Object.entries(STATUS_LABELS).map(([status, label]) => {
if (count === 0) return null; const count = stats[status] || 0;
return ( if (count === 0) return null;
<FilterButton key={status} active={filter === status} onClick={() => setFilter(status)}> return (
{label} ({count}) <FilterButton key={status} active={filter === status} onClick={() => setFilter(status)}>
</FilterButton> {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> </div>
{/* Log table */} {/* ── Log table ──────────────────────────────────────────────────────── */}
<Panel className="p-0"> <Panel className="p-0">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<div className="min-w-[800px]"> <div className="min-w-[900px]">
{/* Header */} {/* Header */}
<div className="grid grid-cols-[minmax(120px,1.5fr)_minmax(100px,1fr)_minmax(80px,0.8fr)_minmax(70px,0.6fr)_minmax(80px,1fr)_minmax(100px,1.2fr)] 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="grid grid-cols-[minmax(120px,1.5fr)_minmax(100px,1fr)_minmax(80px,0.8fr)_minmax(70px,0.6fr)_minmax(60px,0.5fr)_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">Телефон</div>
<div className="px-3 py-1.5 font-medium">SMS ID</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 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> </div>
{/* Rows */} {/* Rows */}
{logs.length === 0 ? ( {logs.length === 0 ? (
@ -314,45 +424,67 @@ export const SmsCampaignPanel = () => {
Нет записей в логе Нет записей в логе
</div> </div>
) : ( ) : (
logs.map((entry) => ( logs.map((entry) => {
<div const canRecheck = entry.status === "sent" || entry.status === "checking";
key={entry.id} const isChecking = checkingIds.has(entry.id);
className="grid grid-cols-[minmax(120px,1.5fr)_minmax(100px,1fr)_minmax(80px,0.8fr)_minmax(70px,0.6fr)_minmax(80px,1fr)_minmax(100px,1.2fr)] gap-0 border-t border-[var(--color-border)] text-xs hover:bg-[var(--color-accent-soft)]" const elapsed = fmtElapsed(entry.created_at);
> return (
<div className="px-3 py-1.5 text-[var(--color-text)]"> <div
{fmtPhone(entry.customer_phone)} key={entry.id}
className="grid grid-cols-[minmax(120px,1.5fr)_minmax(100px,1fr)_minmax(80px,0.8fr)_minmax(70px,0.6fr)_minmax(60px,0.5fr)_minmax(100px,1fr)_minmax(90px,0.8fr)] gap-0 border-t border-[var(--color-border)] text-xs hover:bg-[var(--color-accent-soft)]"
>
<div className="px-3 py-1.5 text-[var(--color-text)]">
{fmtPhone(entry.customer_phone)}
</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] text-[var(--color-text-muted)]">{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] text-[var(--color-text-muted)]">{elapsed}</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">
{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 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>
</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] text-[var(--color-text-muted)]">{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)}
{entry.error_message && (
<div className="mt-0.5 text-[10px] text-[var(--color-danger)]">{entry.error_message.slice(0, 80)}</div>
)}
</div>
</div>
))
)} )}
</div> </div>
</div> </div>
</Panel> </Panel>
{/* Refresh button */} {/* ── Refresh button ────────────────────────────────────────────────── */}
<div className="flex justify-end"> <div className="flex justify-end">
<button <button
onClick={loadData} onClick={loadData}