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:
parent
4215001ab7
commit
d2ac10d849
|
|
@ -9,9 +9,14 @@ SuperSam — SMS First Campaign (State Machine)
|
|||
2. Проверяет статус ранее отправленных SMS (sent но не delivered, в пределах max_check_duration)
|
||||
3. Обновляет статусы в order_groups + sms_campaign_log
|
||||
|
||||
Защита от повторной отправки:
|
||||
- Группа с sms_campaign_log status='sent'/'checking' в последние max_check_duration_minutes → skip
|
||||
- Код 231/132 = лимит одинаковых → не повторять
|
||||
Защита от повторной отправки (ДВОЙНАЯ):
|
||||
1. После отправки SMS → notification_status = 'sms_sending' (не 'link_ready')
|
||||
→ 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:
|
||||
100 = в очереди, 101 = оператору, 102 = в пути → ждём
|
||||
|
|
@ -192,19 +197,26 @@ def get_groups_to_send(conn):
|
|||
return [dict(r) for r in cur.fetchall()]
|
||||
|
||||
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:
|
||||
cur.execute("""
|
||||
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
|
||||
FROM sms_campaign_log scl
|
||||
JOIN order_groups og ON og.id = scl.order_group_id
|
||||
WHERE scl.campaign_type = 'first_sms'
|
||||
AND scl.status IN ('sent', 'checking')
|
||||
AND scl.sms_id IS NOT NULL
|
||||
AND scl.created_at > NOW() - INTERVAL '%s minutes'
|
||||
ORDER BY scl.created_at ASC
|
||||
AND (
|
||||
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)
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
|
||||
|
|
@ -239,8 +251,11 @@ def update_sms_log(conn, log_id, **kwargs):
|
|||
set_parts = []
|
||||
values = []
|
||||
for k, v in kwargs.items():
|
||||
set_parts.append(f"{k} = %s")
|
||||
values.append(v)
|
||||
if v == "NOW()":
|
||||
set_parts.append(f"{k} = NOW()")
|
||||
else:
|
||||
set_parts.append(f"{k} = %s")
|
||||
values.append(v)
|
||||
values.append(log_id)
|
||||
cur.execute(f"UPDATE sms_campaign_log SET {', '.join(set_parts)}, updated_at = NOW() WHERE id = %s", values)
|
||||
conn.commit()
|
||||
|
|
@ -313,7 +328,13 @@ def step_send_new(conn, settings):
|
|||
sms_code=code,
|
||||
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
|
||||
else:
|
||||
# Ошибка отправки
|
||||
|
|
@ -357,9 +378,13 @@ def step_check_status(conn, settings):
|
|||
name = item.get("customer_name") or item.get("group_key", "—")
|
||||
phone = item.get("customer_phone", "")
|
||||
attempts = item.get("attempts", 1)
|
||||
was_needs_check = item.get("needs_check", False)
|
||||
|
||||
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:
|
||||
# Доставлено!
|
||||
log.info(f"Group {group_id}: SMS delivered (103)!")
|
||||
|
|
|
|||
|
|
@ -1,22 +1,32 @@
|
|||
/**
|
||||
* @file SmsCampaignPanel.jsx
|
||||
* @description SMS Campaign log + settings for mega_admin.
|
||||
* Shows sms_campaign_log entries with filtering and sms_campaign_settings editable.
|
||||
* @description SMS Campaign management for mega_admin.
|
||||
* 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 { Badge } from "../UI/Badge";
|
||||
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 = {
|
||||
sent: "Отправлено",
|
||||
checking: "Проверяется",
|
||||
delivered: "Доставлено",
|
||||
send_failed: "Ошибка отправки",
|
||||
error: "Ошибка доставки",
|
||||
expired: "Истекло",
|
||||
expired: "Истёк срок",
|
||||
limit_exceeded: "Лимит превышен",
|
||||
manual_override: "Ручной режим",
|
||||
};
|
||||
|
||||
const STATUS_TONES = {
|
||||
|
|
@ -27,6 +37,7 @@ const STATUS_TONES = {
|
|||
error: "danger",
|
||||
expired: "warning",
|
||||
limit_exceeded: "danger",
|
||||
manual_override: "warning",
|
||||
};
|
||||
|
||||
// ── SMS code labels (from sms.ru docs) ────────────────────────────────────────
|
||||
|
|
@ -35,7 +46,7 @@ const SMS_CODE_LABELS = {
|
|||
"101": "Оператору",
|
||||
"102": "В пути",
|
||||
"103": "Доставлено",
|
||||
"104": "Истекло время",
|
||||
"104": "Истёкло время",
|
||||
"105": "Удалено оператором",
|
||||
"106": "Сбой телефона",
|
||||
"107": "Неизвестная причина",
|
||||
|
|
@ -70,7 +81,27 @@ const fmtPhone = (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 = () => {
|
||||
const [activeTab, setActiveTab] = useState("first_sms");
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [settings, setSettings] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
|
@ -78,16 +109,19 @@ export const SmsCampaignPanel = () => {
|
|||
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 refreshTimer = useRef(null);
|
||||
|
||||
// ── Load data ──────────────────────────────────────────────────────────────
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Load logs
|
||||
// Load logs for active campaign tab
|
||||
let query = supabase
|
||||
.from("sms_campaign_log")
|
||||
.select("*")
|
||||
.eq("campaign_type", activeTab)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(200);
|
||||
|
||||
|
|
@ -99,11 +133,11 @@ export const SmsCampaignPanel = () => {
|
|||
if (logError) throw logError;
|
||||
setLogs(logData || []);
|
||||
|
||||
// Load settings
|
||||
// Load settings for active campaign
|
||||
const { data: settingsData, error: settingsError } = await supabase
|
||||
.from("sms_campaign_settings")
|
||||
.select("*")
|
||||
.eq("campaign_type", "first_sms")
|
||||
.eq("campaign_type", activeTab)
|
||||
.single();
|
||||
if (settingsError && settingsError.code !== "PGRST116") throw settingsError;
|
||||
setSettings(settingsData || null);
|
||||
|
|
@ -112,9 +146,20 @@ export const SmsCampaignPanel = () => {
|
|||
} finally {
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
const handleSaveSettings = async () => {
|
||||
|
|
@ -141,14 +186,42 @@ export const SmsCampaignPanel = () => {
|
|||
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 ─────────────────────────────────────────────────────────
|
||||
const stats = logs.reduce((acc, log) => {
|
||||
acc[log.status] = (acc[log.status] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const hasSettings = activeTab === "first_sms"; // Only first_sms has settings for now
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
if (isLoading) {
|
||||
if (isLoading && logs.length === 0) {
|
||||
return (
|
||||
<Panel className="p-5">
|
||||
<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 (
|
||||
<div className="space-y-4">
|
||||
{/* Settings panel */}
|
||||
{settings && (
|
||||
{/* ── Campaign sub-tabs ─────────────────────────────────────────────── */}
|
||||
<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">
|
||||
<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 && (
|
||||
<span className="text-xs text-[var(--color-accent)]">✓ Сохранено</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* 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)]"}`}>
|
||||
<label className="flex items-center justify-between">
|
||||
|
|
@ -210,6 +298,7 @@ export const SmsCampaignPanel = () => {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<SettingField
|
||||
label="Пауза между проверками (сек)"
|
||||
|
|
@ -263,7 +352,16 @@ export const SmsCampaignPanel = () => {
|
|||
</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">
|
||||
<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>
|
||||
|
|
@ -279,34 +377,46 @@ export const SmsCampaignPanel = () => {
|
|||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* Filter buttons */}
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
{/* ── 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 */}
|
||||
{/* ── Log table ──────────────────────────────────────────────────────── */}
|
||||
<Panel className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[800px]">
|
||||
<div className="min-w-[900px]">
|
||||
{/* 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">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>
|
||||
{/* Rows */}
|
||||
{logs.length === 0 ? (
|
||||
|
|
@ -314,45 +424,67 @@ export const SmsCampaignPanel = () => {
|
|||
Нет записей в логе
|
||||
</div>
|
||||
) : (
|
||||
logs.map((entry) => (
|
||||
<div
|
||||
key={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)]"
|
||||
>
|
||||
<div className="px-3 py-1.5 text-[var(--color-text)]">
|
||||
{fmtPhone(entry.customer_phone)}
|
||||
logs.map((entry) => {
|
||||
const canRecheck = entry.status === "sent" || entry.status === "checking";
|
||||
const isChecking = checkingIds.has(entry.id);
|
||||
const elapsed = fmtElapsed(entry.created_at);
|
||||
return (
|
||||
<div
|
||||
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 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>
|
||||
</Panel>
|
||||
|
||||
{/* Refresh button */}
|
||||
{/* ── Refresh button ────────────────────────────────────────────────── */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={loadData}
|
||||
|
|
|
|||
Loading…
Reference in New Issue