diff --git a/public/service-worker.js b/public/service-worker.js index e61e17a..c8aad88 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,8 +1,8 @@ const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1"; if (!isLocalhost) { - const STATIC_CACHE = "construction-delivery-static-v54"; - const RUNTIME_CACHE = "construction-delivery-runtime-v54"; + const STATIC_CACHE = "construction-delivery-static-v55"; + const RUNTIME_CACHE = "construction-delivery-runtime-v55"; const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"]; self.addEventListener("install", (event) => { diff --git a/src/components/admin/SmsCampaignPanel.jsx b/src/components/admin/SmsCampaignPanel.jsx index 8447118..ac3787b 100644 --- a/src/components/admin/SmsCampaignPanel.jsx +++ b/src/components/admin/SmsCampaignPanel.jsx @@ -184,6 +184,15 @@ const passesLinkFilter = (isOpened, lf) => { return true; }; +// smsFilter: "no_sms" = group has no sms_sent_at and no first/second_sms_sent_at +const passesSmsFilter = (group, sf) => { + if (sf === "all" || !sf) return true; + if (sf === "no_sms") { + return !group.sms_sent_at && !group.first_sms_sent_at && !group.second_sms_sent_at; + } + return true; +}; + const fmtCountdown = (ts) => { if (!ts) return "—"; const diff = new Date(ts).getTime() - Date.now(); @@ -332,10 +341,10 @@ const MiniToggle = ({ on, onClick, disabled }) => ( // ── KPI Tile ───────────────────────────────────────────────────────────────── const KpiTile = ({ icon, value, label, color }) => ( -
-
{label}
-
- {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} +
+ {c.icon} +
+
{c.label}
+
{c.desc}
+
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 = () => {