- {icon &&
{icon}}{value}
+
+
{label}
+
+ {icon && {icon}}{value}
);
@@ -372,13 +381,14 @@ const CardButton = ({ onClick, disabled, tone, children }) => {
const tones = {
warning: "border-[var(--color-warning)] text-[var(--color-warning)] hover:bg-[var(--color-accent-soft)]",
accent: "border-[var(--color-accent)] text-[var(--color-accent)] hover:bg-[var(--color-accent-soft)]",
+ danger: "border-[var(--color-danger)] text-[var(--color-danger)] hover:bg-[rgba(239,68,68,0.08)]",
};
return (
@@ -434,6 +444,8 @@ export const SmsCampaignPanel = () => {
const [campaignCounts, setCampaignCounts] = useState({});
const [queueData, setQueueData] = useState([]);
const [queueCounts, setQueueCounts] = useState({});
+ const [lastSmsByCampaign, setLastSmsByCampaign] = useState({});
+ const [runningNow, setRunningNow] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [filter, setFilter] = useState("all");
@@ -446,6 +458,7 @@ export const SmsCampaignPanel = () => {
const [showSettingsPanel, setShowSettingsPanel] = useState(false);
const [dateFilter, setDateFilter] = useState("all"); // all|today|yesterday|7d
const [linkFilter, setLinkFilter] = useState("all"); // all|opened|not_opened
+ const [smsFilter, setSmsFilter] = useState("all"); // all|no_sms
const [selectedIds, setSelectedIds] = useState(new Set());
const [deleting, setDeleting] = useState(false);
const [showSmsPreview, setShowSmsPreview] = useState(false);
@@ -487,6 +500,21 @@ export const SmsCampaignPanel = () => {
});
setCampaignCounts(counts);
+ // Load last SMS timestamp per campaign (non-test only)
+ const { data: lastSmsData, error: lastSmsErr } = await supabase
+ .from("sms_campaign_log")
+ .select("campaign_type, created_at")
+ .eq("was_test_mode", false)
+ .order("created_at", { ascending: false })
+ .limit(200);
+ const lastSmsMap = {};
+ if (!lastSmsErr && lastSmsData) {
+ lastSmsData.forEach(r => {
+ if (!lastSmsMap[r.campaign_type]) lastSmsMap[r.campaign_type] = r.created_at;
+ });
+ }
+ setLastSmsByCampaign(lastSmsMap);
+
const { data: queueCountsData, error: qErr } = await supabase
.from("order_groups")
.select("notification_status, delivery_status")
@@ -705,6 +733,7 @@ export const SmsCampaignPanel = () => {
const [restarting, setRestarting] = useState(null);
const handleRestart = async (campaignKey) => {
+ // Reset all groups in this campaign to beginning
const s = allSettings[campaignKey];
if (!s) return;
setRestarting(campaignKey);
@@ -720,6 +749,24 @@ export const SmsCampaignPanel = () => {
finally { setTimeout(() => setRestarting(null), 3000); }
};
+ const handleRunNow = async (campaignKey) => {
+ // Trigger immediate run via run_requested flag
+ const s = allSettings[campaignKey];
+ if (!s) return;
+ setRunningNow(campaignKey);
+ try {
+ const { error: updateError } = await supabase
+ .from("sms_campaign_settings")
+ .update({ run_requested: true, updated_at: new Date().toISOString() })
+ .eq("id", s.id);
+ if (updateError) throw updateError;
+ // Quick refresh to show updated status
+ setTimeout(() => loadData(), 3000);
+ setTimeout(() => loadData(), 10000);
+ } catch (e) { setError(`Ошибка: ${e.message}`); }
+ finally { setTimeout(() => setRunningNow(null), 3000); }
+ };
+
const handleRecheck = async (logId) => {
setCheckingIds(prev => new Set([...prev, logId]));
try {
@@ -807,16 +854,43 @@ export const SmsCampaignPanel = () => {
const isPaidStorageCampaign = activeCampaign === "paid_storage";
const showSmsFields = !isManualCampaign;
- const getRunnerStatus = (s) => {
+ const getRunnerStatus = (s, lastSmsAt, queueCnt, timerActive) => {
if (!s) return { label: "—", color: "var(--color-text-muted)" };
if (!s.enabled) return { label: "⏸ Остановлена", color: "var(--color-text-muted)" };
- if (!s.last_run_at) return { label: "⚠️ Не запускалась", color: "var(--color-danger)" };
- const lastRun = new Date(s.last_run_at).getTime();
- const diffMin = Math.floor((Date.now() - lastRun) / 60000);
- if (diffMin < 10) return { label: `🟢 Работает (${diffMin}м назад)`, color: "#22c55e" };
- if (diffMin < 60) return { label: `🟡 ${diffMin}м назад`, color: "var(--color-warning)" };
- if (diffMin < 1440) return { label: `🔴 ${Math.floor(diffMin/60)}ч назад`, color: "var(--color-danger)" };
- return { label: `🔴 ${Math.floor(diffMin/1440)}д назад`, color: "var(--color-danger)" };
+ // If timer active and queue is 0 — all good, nothing to send
+ if (timerActive && queueCnt === 0) {
+ const lastRunDiff = s.last_run_at ? Math.floor((Date.now() - new Date(s.last_run_at).getTime()) / 60000) : null;
+ if (lastRunDiff !== null && lastRunDiff < 10)
+ return { label: `✅ Очереди нет (тик ${lastRunDiff}м)`, color: "#22c55e" };
+ if (lastRunDiff !== null && lastRunDiff < 60)
+ return { label: `✅ Очереди нет (тик ${lastRunDiff}м назад)`, color: "#22c55e" };
+ return { label: "✅ Очереди нет", color: "#22c55e" };
+ }
+ // If timer active and queue > 0 — waiting for delay, show calm status
+ if (timerActive && queueCnt > 0) {
+ if (lastSmsAt) {
+ const diffMin = Math.floor((Date.now() - new Date(lastSmsAt).getTime()) / 60000);
+ if (diffMin < 10) return { label: `🟢 SMS ${diffMin}м назад · ${queueCnt} в очереди`, color: "#22c55e" };
+ if (diffMin < 60) return { label: `⏳ SMS ${diffMin}м назад · ${queueCnt} в очереди`, color: "var(--color-warning)" };
+ if (diffMin < 1440) return { label: `⏳ SMS ${Math.floor(diffMin/60)}ч назад · ${queueCnt} ждут`, color: "var(--color-warning)" };
+ return { label: `⏳ ${queueCnt} в очереди · SMS ${Math.floor(diffMin/1440)}д назад`, color: "var(--color-warning)" };
+ }
+ // No SMS in log but queue has items — waiting to send
+ const lastRunDiff = s.last_run_at ? Math.floor((Date.now() - new Date(s.last_run_at).getTime()) / 60000) : null;
+ if (lastRunDiff !== null && lastRunDiff < 10)
+ return { label: `🟢 Запуск ${lastRunDiff}м назад · ${queueCnt} ждут`, color: "#22c55e" };
+ return { label: `⏳ ${queueCnt} в очереди · ждём задержку`, color: "var(--color-warning)" };
+ }
+ // Timer NOT active — show warning
+ if (!timerActive) return { label: "⏸ Таймер выключен", color: "var(--color-danger)" };
+ // Fallback
+ if (lastSmsAt) {
+ const diffMin = Math.floor((Date.now() - new Date(lastSmsAt).getTime()) / 60000);
+ if (diffMin < 10) return { label: `🟢 SMS ${diffMin}м назад`, color: "#22c55e" };
+ if (diffMin < 60) return { label: `🟡 SMS ${diffMin}м назад`, color: "var(--color-warning)" };
+ return { label: `🔴 SMS ${Math.floor(diffMin/1440)}д назад`, color: "var(--color-danger)" };
+ }
+ return { label: "⚠️ Не запускалась", color: "var(--color-danger)" };
};
const balance = allSettings.first_sms?.last_balance
@@ -861,43 +935,43 @@ export const SmsCampaignPanel = () => {
return (
{/* ── Balance banner ─────────────────────────────────────────────────── */}
-
-
+
+
-
+
💰
-
Баланс sms.ru
-
+
Баланс sms.ru
+
{balance != null ? `${Number(balance).toLocaleString("ru-RU")} ₽` : "—"}
-
+
📊
-
-
SMS отправлено (боевые)
-
+
+
SMS отправлено
+
{Object.values(campaignCounts).reduce((a, c) => a + (c?.total || 0), 0)}
- {linkStats.total > 0 && (
+ {linkStats.total > 0 ? (
-
+
👁
-
-
Открыли ссылку
-
+
+
Открыли ссылку
+
{linkStats.opened} / {linkStats.total}
- )}
+ ) : null}
@@ -911,7 +985,7 @@ export const SmsCampaignPanel = () => {
const timerActive = s?.timer_active ?? false;
const cnt = campaignCounts[c.key] || { total: 0, delivered: 0, sent: 0, errors: 0 };
const queueCnt = queueCounts[c.key] || 0;
- const runner = getRunnerStatus(s);
+ const runner = getRunnerStatus(s, lastSmsByCampaign[c.key], queueCnt, timerActive);
const isToggling = togglingCampaign === c.key;
const isTimerToggling = togglingTimer === c.key;
const isTestSending = testSending === c.key;
@@ -928,22 +1002,25 @@ export const SmsCampaignPanel = () => {
>
{/* Header */}
-
-
{c.icon}
-
{c.label}
+
toggleCampaignEnabled(c.key)}
disabled={isToggling}
- size="sm"
+ size="md"
/>
- {/* Status badges */}
-
+ {/* Status row */}
+
{
>
{!isEnabled ? "⏸ Выкл" : isManual ? "🔧 Ручное" : isTest ? "🧪 Тест" : "🚀 Боевой"}
- {runner.label}
+ {runner.label}
- {/* Action buttons */}
-
e.stopPropagation()}>
+ {/* Timer status bar */}
+
0
+ ? "bg-[rgba(34,197,94,0.06)]"
+ : "bg-[var(--color-surface-strong)]"
+ : "bg-[rgba(239,68,68,0.06)]"
+ }`}>
+ {timerActive ? (
+ <>
+ 0 ? "bg-[#22c55e] animate-pulse" : "bg-[#22c55e]"} shrink-0`} />
+
+ Таймер активен{queueCnt > 0 ? ` · ${queueCnt} в очереди` : " · очереди нет"}
+
+ >
+ ) : (
+ <>
+
+ Таймер выключен — SMS не отправляются
+ >
+ )}
+
+
+ {/* Action buttons — uniform size, full width row */}
+
e.stopPropagation()}>
+
{/* ── Queue view ────────────────────────────────────────────────────── */}
{viewMode === "queue" && (
<>
@@ -1047,6 +1155,13 @@ export const SmsCampaignPanel = () => {
].map(opt => (
setLinkFilter(opt.v)}>{opt.l}
))}
+
📨 SMS:
+ {[
+ { v: "all", l: "Все" },
+ { v: "no_sms", l: "Без SMS" },
+ ].map(opt => (
+
setSmsFilter(opt.v)}>{opt.l}
+ ))}
{/* Desktop table */}
@@ -1061,7 +1176,7 @@ export const SmsCampaignPanel = () => {
queueData.filter((g) => {
const inv = g._invitation;
const opened = inv && inv.opened_at;
- return passesDateFilter(g.created_at, dateFilter) && passesLinkFilter(opened, linkFilter);
+ return passesDateFilter(g.created_at, dateFilter) && passesLinkFilter(opened, linkFilter) && passesSmsFilter(g, smsFilter);
}).slice((queuePage - 1) * PAGE_SIZE, queuePage * PAGE_SIZE).map((g) => {
const nextSend = calcNextSendTime(g, settings, activeCampaign);
const inv = g._invitation;
@@ -1176,7 +1291,7 @@ export const SmsCampaignPanel = () => {
queueData.filter((g) => {
const inv = g._invitation;
const opened = inv && inv.opened_at;
- return passesDateFilter(g.created_at, dateFilter) && passesLinkFilter(opened, linkFilter);
+ return passesDateFilter(g.created_at, dateFilter) && passesLinkFilter(opened, linkFilter) && passesSmsFilter(g, smsFilter);
}).map((g) => {
const nextSend = calcNextSendTime(g, settings, activeCampaign);
const inv = g._invitation;
@@ -1252,7 +1367,7 @@ export const SmsCampaignPanel = () => {
const filteredQueue = queueData.filter((g) => {
const inv = g._invitation;
const opened = inv && inv.opened_at;
- return passesDateFilter(g.created_at, dateFilter) && passesLinkFilter(opened, linkFilter);
+ return passesDateFilter(g.created_at, dateFilter) && passesLinkFilter(opened, linkFilter) && passesSmsFilter(g, smsFilter);
});
const totalPages = Math.ceil(filteredQueue.length / PAGE_SIZE);
if (totalPages <= 1) return null;
@@ -1271,7 +1386,7 @@ export const SmsCampaignPanel = () => {