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)
|
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)!")
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue