feat: SMS filter no_sms in queue + SW v55
This commit is contained in:
parent
6a34f36621
commit
e93215f837
|
|
@ -1,8 +1,8 @@
|
||||||
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
|
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
|
||||||
|
|
||||||
if (!isLocalhost) {
|
if (!isLocalhost) {
|
||||||
const STATIC_CACHE = "construction-delivery-static-v54";
|
const STATIC_CACHE = "construction-delivery-static-v55";
|
||||||
const RUNTIME_CACHE = "construction-delivery-runtime-v54";
|
const RUNTIME_CACHE = "construction-delivery-runtime-v55";
|
||||||
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
|
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,15 @@ const passesLinkFilter = (isOpened, lf) => {
|
||||||
return true;
|
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) => {
|
const fmtCountdown = (ts) => {
|
||||||
if (!ts) return "—";
|
if (!ts) return "—";
|
||||||
const diff = new Date(ts).getTime() - Date.now();
|
const diff = new Date(ts).getTime() - Date.now();
|
||||||
|
|
@ -332,10 +341,10 @@ const MiniToggle = ({ on, onClick, disabled }) => (
|
||||||
|
|
||||||
// ── KPI Tile ─────────────────────────────────────────────────────────────────
|
// ── KPI Tile ─────────────────────────────────────────────────────────────────
|
||||||
const KpiTile = ({ icon, value, label, color }) => (
|
const KpiTile = ({ icon, value, label, color }) => (
|
||||||
<div className="rounded-xl bg-[var(--color-surface-strong)] border border-[var(--color-border)] px-3 py-2.5 text-center transition hover:shadow-sm">
|
<div className="rounded-xl bg-[var(--color-surface-strong)] border border-[var(--color-border)] px-3 py-3 text-center transition hover:shadow-sm">
|
||||||
<div className="text-[10px] uppercase tracking-wide text-[var(--color-text-muted)] mb-1">{label}</div>
|
<div className="text-xs uppercase tracking-wide text-[var(--color-text-muted)] mb-1.5">{label}</div>
|
||||||
<div className="text-base font-bold" style={{ color: color || "var(--color-text)" }}>
|
<div className="text-lg font-bold" style={{ color: color || "var(--color-text)" }}>
|
||||||
{icon && <span className="mr-1 text-sm">{icon}</span>}{value}
|
{icon && <span className="mr-1 text-base">{icon}</span>}{value}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -372,13 +381,14 @@ const CardButton = ({ onClick, disabled, tone, children }) => {
|
||||||
const tones = {
|
const tones = {
|
||||||
warning: "border-[var(--color-warning)] text-[var(--color-warning)] hover:bg-[var(--color-accent-soft)]",
|
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)]",
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={`rounded-xl border px-3 py-1.5 text-[11px] font-medium transition-all duration-200 disabled:opacity-40 ${tones[tone] || tones.accent}`}
|
className={`rounded-xl border px-4 py-2 text-xs font-medium transition-all duration-200 disabled:opacity-40 ${tones[tone] || tones.accent}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -434,6 +444,8 @@ export const SmsCampaignPanel = () => {
|
||||||
const [campaignCounts, setCampaignCounts] = useState({});
|
const [campaignCounts, setCampaignCounts] = useState({});
|
||||||
const [queueData, setQueueData] = useState([]);
|
const [queueData, setQueueData] = useState([]);
|
||||||
const [queueCounts, setQueueCounts] = useState({});
|
const [queueCounts, setQueueCounts] = useState({});
|
||||||
|
const [lastSmsByCampaign, setLastSmsByCampaign] = useState({});
|
||||||
|
const [runningNow, setRunningNow] = useState(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [filter, setFilter] = useState("all");
|
const [filter, setFilter] = useState("all");
|
||||||
|
|
@ -446,6 +458,7 @@ export const SmsCampaignPanel = () => {
|
||||||
const [showSettingsPanel, setShowSettingsPanel] = useState(false);
|
const [showSettingsPanel, setShowSettingsPanel] = useState(false);
|
||||||
const [dateFilter, setDateFilter] = useState("all"); // all|today|yesterday|7d
|
const [dateFilter, setDateFilter] = useState("all"); // all|today|yesterday|7d
|
||||||
const [linkFilter, setLinkFilter] = useState("all"); // all|opened|not_opened
|
const [linkFilter, setLinkFilter] = useState("all"); // all|opened|not_opened
|
||||||
|
const [smsFilter, setSmsFilter] = useState("all"); // all|no_sms
|
||||||
const [selectedIds, setSelectedIds] = useState(new Set());
|
const [selectedIds, setSelectedIds] = useState(new Set());
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [showSmsPreview, setShowSmsPreview] = useState(false);
|
const [showSmsPreview, setShowSmsPreview] = useState(false);
|
||||||
|
|
@ -487,6 +500,21 @@ export const SmsCampaignPanel = () => {
|
||||||
});
|
});
|
||||||
setCampaignCounts(counts);
|
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
|
const { data: queueCountsData, error: qErr } = await supabase
|
||||||
.from("order_groups")
|
.from("order_groups")
|
||||||
.select("notification_status, delivery_status")
|
.select("notification_status, delivery_status")
|
||||||
|
|
@ -705,6 +733,7 @@ export const SmsCampaignPanel = () => {
|
||||||
|
|
||||||
const [restarting, setRestarting] = useState(null);
|
const [restarting, setRestarting] = useState(null);
|
||||||
const handleRestart = async (campaignKey) => {
|
const handleRestart = async (campaignKey) => {
|
||||||
|
// Reset all groups in this campaign to beginning
|
||||||
const s = allSettings[campaignKey];
|
const s = allSettings[campaignKey];
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
setRestarting(campaignKey);
|
setRestarting(campaignKey);
|
||||||
|
|
@ -720,6 +749,24 @@ export const SmsCampaignPanel = () => {
|
||||||
finally { setTimeout(() => setRestarting(null), 3000); }
|
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) => {
|
const handleRecheck = async (logId) => {
|
||||||
setCheckingIds(prev => new Set([...prev, logId]));
|
setCheckingIds(prev => new Set([...prev, logId]));
|
||||||
try {
|
try {
|
||||||
|
|
@ -807,16 +854,43 @@ export const SmsCampaignPanel = () => {
|
||||||
const isPaidStorageCampaign = activeCampaign === "paid_storage";
|
const isPaidStorageCampaign = activeCampaign === "paid_storage";
|
||||||
const showSmsFields = !isManualCampaign;
|
const showSmsFields = !isManualCampaign;
|
||||||
|
|
||||||
const getRunnerStatus = (s) => {
|
const getRunnerStatus = (s, lastSmsAt, queueCnt, timerActive) => {
|
||||||
if (!s) return { label: "—", color: "var(--color-text-muted)" };
|
if (!s) return { label: "—", color: "var(--color-text-muted)" };
|
||||||
if (!s.enabled) 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)" };
|
// If timer active and queue is 0 — all good, nothing to send
|
||||||
const lastRun = new Date(s.last_run_at).getTime();
|
if (timerActive && queueCnt === 0) {
|
||||||
const diffMin = Math.floor((Date.now() - lastRun) / 60000);
|
const lastRunDiff = s.last_run_at ? Math.floor((Date.now() - new Date(s.last_run_at).getTime()) / 60000) : null;
|
||||||
if (diffMin < 10) return { label: `🟢 Работает (${diffMin}м назад)`, color: "#22c55e" };
|
if (lastRunDiff !== null && lastRunDiff < 10)
|
||||||
if (diffMin < 60) return { label: `🟡 ${diffMin}м назад`, color: "var(--color-warning)" };
|
return { label: `✅ Очереди нет (тик ${lastRunDiff}м)`, color: "#22c55e" };
|
||||||
if (diffMin < 1440) return { label: `🔴 ${Math.floor(diffMin/60)}ч назад`, color: "var(--color-danger)" };
|
if (lastRunDiff !== null && lastRunDiff < 60)
|
||||||
return { label: `🔴 ${Math.floor(diffMin/1440)}д назад`, color: "var(--color-danger)" };
|
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
|
const balance = allSettings.first_sms?.last_balance
|
||||||
|
|
@ -861,43 +935,43 @@ export const SmsCampaignPanel = () => {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* ── Balance banner ─────────────────────────────────────────────────── */}
|
{/* ── Balance banner ─────────────────────────────────────────────────── */}
|
||||||
<Panel className="p-4">
|
<Panel className="p-5">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--color-accent-soft)] text-lg shrink-0">
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--color-accent-soft)] text-xl shrink-0">
|
||||||
💰
|
💰
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] uppercase tracking-wide text-[var(--color-text-muted)]">Баланс sms.ru</div>
|
<div className="text-xs uppercase tracking-wide text-[var(--color-text-muted)]">Баланс sms.ru</div>
|
||||||
<div className="text-base font-bold text-[var(--color-text)]">
|
<div className="text-xl font-bold text-[var(--color-text)]">
|
||||||
{balance != null ? `${Number(balance).toLocaleString("ru-RU")} ₽` : "—"}
|
{balance != null ? `${Number(balance).toLocaleString("ru-RU")} ₽` : "—"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--color-accent-soft)] text-lg shrink-0">
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--color-accent-soft)] text-xl shrink-0">
|
||||||
📊
|
📊
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div>
|
||||||
<div className="text-[10px] uppercase tracking-wide text-[var(--color-text-muted)]">SMS отправлено (боевые)</div>
|
<div className="text-xs uppercase tracking-wide text-[var(--color-text-muted)]">SMS отправлено</div>
|
||||||
<div className="text-base font-bold text-[var(--color-text)]">
|
<div className="text-xl font-bold text-[var(--color-text)]">
|
||||||
{Object.values(campaignCounts).reduce((a, c) => a + (c?.total || 0), 0)}
|
{Object.values(campaignCounts).reduce((a, c) => a + (c?.total || 0), 0)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{linkStats.total > 0 && (
|
{linkStats.total > 0 ? (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--color-accent-soft)] text-lg shrink-0">
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--color-accent-soft)] text-xl shrink-0">
|
||||||
👁
|
👁
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div>
|
||||||
<div className="text-[10px] uppercase tracking-wide text-[var(--color-text-muted)]">Открыли ссылку</div>
|
<div className="text-xs uppercase tracking-wide text-[var(--color-text-muted)]">Открыли ссылку</div>
|
||||||
<div className="text-base font-bold text-[var(--color-text)]">
|
<div className="text-xl font-bold text-[var(--color-text)]">
|
||||||
{linkStats.opened} / {linkStats.total}
|
{linkStats.opened} / {linkStats.total}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
|
@ -911,7 +985,7 @@ export const SmsCampaignPanel = () => {
|
||||||
const timerActive = s?.timer_active ?? false;
|
const timerActive = s?.timer_active ?? false;
|
||||||
const cnt = campaignCounts[c.key] || { total: 0, delivered: 0, sent: 0, errors: 0 };
|
const cnt = campaignCounts[c.key] || { total: 0, delivered: 0, sent: 0, errors: 0 };
|
||||||
const queueCnt = queueCounts[c.key] || 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 isToggling = togglingCampaign === c.key;
|
||||||
const isTimerToggling = togglingTimer === c.key;
|
const isTimerToggling = togglingTimer === c.key;
|
||||||
const isTestSending = testSending === c.key;
|
const isTestSending = testSending === c.key;
|
||||||
|
|
@ -928,22 +1002,25 @@ export const SmsCampaignPanel = () => {
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2.5">
|
||||||
<span className="text-xl">{c.icon}</span>
|
<span className="text-2xl">{c.icon}</span>
|
||||||
<span className="text-sm font-semibold text-[var(--color-text)]">{c.label}</span>
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-[var(--color-text)]">{c.label}</div>
|
||||||
|
<div className="text-xs text-[var(--color-text-muted)] mt-0.5">{c.desc}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
on={isEnabled}
|
on={isEnabled}
|
||||||
onClick={() => toggleCampaignEnabled(c.key)}
|
onClick={() => toggleCampaignEnabled(c.key)}
|
||||||
disabled={isToggling}
|
disabled={isToggling}
|
||||||
size="sm"
|
size="md"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status badges */}
|
{/* Status row */}
|
||||||
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className="rounded-full border px-2 py-0.5 text-[9px] font-semibold transition"
|
className="rounded-full border px-2.5 py-1 text-xs font-semibold transition"
|
||||||
style={{
|
style={{
|
||||||
borderColor: !isEnabled ? "var(--color-border)" : isManual ? "var(--color-accent)" : isTest ? "var(--color-warning)" : "#22c55e",
|
borderColor: !isEnabled ? "var(--color-border)" : isManual ? "var(--color-accent)" : isTest ? "var(--color-warning)" : "#22c55e",
|
||||||
color: !isEnabled ? "var(--color-text-muted)" : isManual ? "var(--color-accent)" : isTest ? "var(--color-warning)" : "#22c55e",
|
color: !isEnabled ? "var(--color-text-muted)" : isManual ? "var(--color-accent)" : isTest ? "var(--color-warning)" : "#22c55e",
|
||||||
|
|
@ -951,19 +1028,42 @@ export const SmsCampaignPanel = () => {
|
||||||
>
|
>
|
||||||
{!isEnabled ? "⏸ Выкл" : isManual ? "🔧 Ручное" : isTest ? "🧪 Тест" : "🚀 Боевой"}
|
{!isEnabled ? "⏸ Выкл" : isManual ? "🔧 Ручное" : isTest ? "🧪 Тест" : "🚀 Боевой"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[9px] font-medium" style={{ color: runner.color }}>{runner.label}</span>
|
<span className="text-xs font-medium" style={{ color: runner.color }}>{runner.label}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Timer status bar */}
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
|
<div className={`mt-2.5 flex items-center gap-2 rounded-xl px-3 py-2 text-xs ${
|
||||||
|
timerActive
|
||||||
|
? queueCnt > 0
|
||||||
|
? "bg-[rgba(34,197,94,0.06)]"
|
||||||
|
: "bg-[var(--color-surface-strong)]"
|
||||||
|
: "bg-[rgba(239,68,68,0.06)]"
|
||||||
|
}`}>
|
||||||
|
{timerActive ? (
|
||||||
|
<>
|
||||||
|
<span className={`h-2 w-2 rounded-full ${queueCnt > 0 ? "bg-[#22c55e] animate-pulse" : "bg-[#22c55e]"} shrink-0`} />
|
||||||
|
<span className="text-[var(--color-text)]">
|
||||||
|
Таймер активен{queueCnt > 0 ? ` · ${queueCnt} в очереди` : " · очереди нет"}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="h-2 w-2 rounded-full bg-[var(--color-danger)] shrink-0" />
|
||||||
|
<span className="text-[var(--color-danger)]">Таймер выключен — SMS не отправляются</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons — uniform size, full width row */}
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleTimer(c.key)}
|
onClick={() => toggleTimer(c.key)}
|
||||||
disabled={isTimerToggling}
|
disabled={isTimerToggling}
|
||||||
className={`flex items-center gap-1.5 rounded-xl px-3 py-1.5 text-[11px] font-medium transition-all duration-200 ${ /* bigger touch target */
|
className={`flex items-center gap-2 rounded-xl px-4 py-2 text-xs font-medium transition-all duration-200 ${
|
||||||
timerActive
|
timerActive
|
||||||
? "bg-[rgba(34,197,94,0.12)] text-[#22c55e]"
|
? "bg-[rgba(34,197,94,0.12)] text-[#22c55e] border border-[rgba(34,197,94,0.2)]"
|
||||||
: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]"
|
: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)] border border-[var(--color-border)]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<MiniToggle on={timerActive} onClick={() => {}} disabled={isTimerToggling} />
|
<MiniToggle on={timerActive} onClick={() => {}} disabled={isTimerToggling} />
|
||||||
|
|
@ -978,10 +1078,17 @@ export const SmsCampaignPanel = () => {
|
||||||
{isTestSending ? "…" : "🧪 Тест"}
|
{isTestSending ? "…" : "🧪 Тест"}
|
||||||
</CardButton>
|
</CardButton>
|
||||||
)}
|
)}
|
||||||
|
<CardButton
|
||||||
|
onClick={() => handleRunNow(c.key)}
|
||||||
|
disabled={runningNow === c.key}
|
||||||
|
tone="accent"
|
||||||
|
>
|
||||||
|
{runningNow === c.key ? "…" : "▶ Запустить"}
|
||||||
|
</CardButton>
|
||||||
<CardButton
|
<CardButton
|
||||||
onClick={() => handleRestart(c.key)}
|
onClick={() => handleRestart(c.key)}
|
||||||
disabled={restarting === c.key}
|
disabled={restarting === c.key}
|
||||||
tone="accent"
|
tone="danger"
|
||||||
>
|
>
|
||||||
{restarting === c.key ? "…" : "🔄 Сброс"}
|
{restarting === c.key ? "…" : "🔄 Сброс"}
|
||||||
</CardButton>
|
</CardButton>
|
||||||
|
|
@ -1024,6 +1131,7 @@ export const SmsCampaignPanel = () => {
|
||||||
<FilterButton active={viewMode === "queue"} onClick={() => setViewMode("queue")}>⏳ Очередь отправки</FilterButton>
|
<FilterButton active={viewMode === "queue"} onClick={() => setViewMode("queue")}>⏳ Очередь отправки</FilterButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* ── Queue view ────────────────────────────────────────────────────── */}
|
{/* ── Queue view ────────────────────────────────────────────────────── */}
|
||||||
{viewMode === "queue" && (
|
{viewMode === "queue" && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -1047,6 +1155,13 @@ export const SmsCampaignPanel = () => {
|
||||||
].map(opt => (
|
].map(opt => (
|
||||||
<FilterButton key={opt.v} active={linkFilter === opt.v} onClick={() => setLinkFilter(opt.v)}>{opt.l}</FilterButton>
|
<FilterButton key={opt.v} active={linkFilter === opt.v} onClick={() => setLinkFilter(opt.v)}>{opt.l}</FilterButton>
|
||||||
))}
|
))}
|
||||||
|
<span className="ml-3 text-[10px] uppercase tracking-wide text-[var(--color-text-muted)]">📨 SMS:</span>
|
||||||
|
{[
|
||||||
|
{ v: "all", l: "Все" },
|
||||||
|
{ v: "no_sms", l: "Без SMS" },
|
||||||
|
].map(opt => (
|
||||||
|
<FilterButton key={opt.v} active={smsFilter === opt.v} onClick={() => setSmsFilter(opt.v)}>{opt.l}</FilterButton>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop table */}
|
{/* Desktop table */}
|
||||||
|
|
@ -1061,7 +1176,7 @@ export const SmsCampaignPanel = () => {
|
||||||
queueData.filter((g) => {
|
queueData.filter((g) => {
|
||||||
const inv = g._invitation;
|
const inv = g._invitation;
|
||||||
const opened = inv && inv.opened_at;
|
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) => {
|
}).slice((queuePage - 1) * PAGE_SIZE, queuePage * PAGE_SIZE).map((g) => {
|
||||||
const nextSend = calcNextSendTime(g, settings, activeCampaign);
|
const nextSend = calcNextSendTime(g, settings, activeCampaign);
|
||||||
const inv = g._invitation;
|
const inv = g._invitation;
|
||||||
|
|
@ -1176,7 +1291,7 @@ export const SmsCampaignPanel = () => {
|
||||||
queueData.filter((g) => {
|
queueData.filter((g) => {
|
||||||
const inv = g._invitation;
|
const inv = g._invitation;
|
||||||
const opened = inv && inv.opened_at;
|
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) => {
|
}).map((g) => {
|
||||||
const nextSend = calcNextSendTime(g, settings, activeCampaign);
|
const nextSend = calcNextSendTime(g, settings, activeCampaign);
|
||||||
const inv = g._invitation;
|
const inv = g._invitation;
|
||||||
|
|
@ -1252,7 +1367,7 @@ export const SmsCampaignPanel = () => {
|
||||||
const filteredQueue = queueData.filter((g) => {
|
const filteredQueue = queueData.filter((g) => {
|
||||||
const inv = g._invitation;
|
const inv = g._invitation;
|
||||||
const opened = inv && inv.opened_at;
|
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);
|
const totalPages = Math.ceil(filteredQueue.length / PAGE_SIZE);
|
||||||
if (totalPages <= 1) return null;
|
if (totalPages <= 1) return null;
|
||||||
|
|
@ -1271,7 +1386,7 @@ export const SmsCampaignPanel = () => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowSettingsPanel(!showSettingsPanel)}
|
onClick={() => setShowSettingsPanel(!showSettingsPanel)}
|
||||||
className="flex w-full items-center justify-between rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-5 py-3 text-left transition hover:bg-[var(--color-surface-strong)]"
|
className="flex w-full items-center justify-between rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-5 py-4 text-left transition hover:bg-[var(--color-surface-strong)]"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-semibold text-[var(--color-text)]">
|
<span className="text-sm font-semibold text-[var(--color-text)]">
|
||||||
|
|
@ -1775,7 +1890,7 @@ export const SmsCampaignPanel = () => {
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={loadData}
|
onClick={loadData}
|
||||||
className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-2 text-xs font-medium text-[var(--color-text)] transition hover:bg-[var(--color-surface-strong)] hover:border-[var(--color-text-muted)]"
|
className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-5 py-2.5 text-sm font-medium text-[var(--color-text)] transition hover:bg-[var(--color-surface-strong)] hover:border-[var(--color-text-muted)]"
|
||||||
>
|
>
|
||||||
↻ Обновить
|
↻ Обновить
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue