759 lines
40 KiB
JavaScript
759 lines
40 KiB
JavaScript
/**
|
||
* @file SmsCampaignPanel.jsx
|
||
* @description SMS Campaign management for mega_admin.
|
||
* Campaign selector cards + settings + log table + "Проверить снова" button
|
||
*/
|
||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||
import { useNavigate } from "react-router-dom";
|
||
import { Panel } from "../UI/Panel";
|
||
import { Badge } from "../UI/Badge";
|
||
import { supabase } from "../../supabaseClient";
|
||
import { SmsCampaignStats } from "./SmsCampaignStats";
|
||
|
||
// ── Campaigns ───────────────────────────────────────────────────────────────
|
||
const CAMPAIGNS = [
|
||
{ key: "first_sms", label: "Первая отправка", icon: "📤", desc: "Первое SMS клиенту: ссылка на согласование доставки" },
|
||
{ key: "second_sms", label: "Второе сообщение", icon: "📨", desc: "Повторное SMS через 3ч, если клиент не согласовал" },
|
||
{ key: "manual", label: "Ручное управление", icon: "🔧", desc: "Переход к ручному согласованию" },
|
||
{ key: "paid_storage", label: "Платное хранение", icon: "📦", desc: "Уведомление о платном хранении" },
|
||
];
|
||
|
||
const HAS_SETTINGS = ["first_sms", "second_sms", "manual", "paid_storage"];
|
||
|
||
// ── Days of week ─────────────────────────────────────────────────────────────
|
||
const DAYS = [
|
||
{ num: 1, short: "Пн" },
|
||
{ num: 2, short: "Вт" },
|
||
{ num: 3, short: "Ср" },
|
||
{ num: 4, short: "Чт" },
|
||
{ num: 5, short: "Пт" },
|
||
{ num: 6, short: "Сб" },
|
||
{ num: 7, short: "Вс" },
|
||
];
|
||
|
||
// ── Status labels ────────────────────────────────────────────────────────────
|
||
const STATUS_LABELS = {
|
||
sent: "Отправлено",
|
||
checking: "Проверяется",
|
||
delivered: "Доставлено",
|
||
send_failed: "Ошибка отправки",
|
||
error: "Ошибка доставки",
|
||
expired: "Истёк срок",
|
||
limit_exceeded: "Лимит превышен",
|
||
manual_override: "Ручной режим",
|
||
};
|
||
|
||
const STATUS_TONES = {
|
||
sent: "info",
|
||
checking: "neutral",
|
||
delivered: "accent",
|
||
send_failed: "danger",
|
||
error: "danger",
|
||
expired: "warning",
|
||
limit_exceeded: "danger",
|
||
manual_override: "warning",
|
||
};
|
||
|
||
// ── SMS code labels ──────────────────────────────────────────────────────────
|
||
const SMS_CODE_LABELS = {
|
||
"100": "В очереди", "101": "Оператору", "102": "В пути",
|
||
"103": "Доставлено", "104": "Истёкло время", "105": "Удалено оператором",
|
||
"106": "Сбой телефона", "107": "Неизвестная причина", "108": "Отклонено",
|
||
"130": "Лимит на номер/день", "131": "Лимит одинаковых/мин",
|
||
"132": "Лимит одинаковых/день", "200": "Неправильный api_id",
|
||
"201": "Недостаточно средств", "202": "Неправильный получатель",
|
||
"230": "Общий лимит/день", "231": "Лимит одинаковых/мин",
|
||
"232": "Лимит одинаковых/день",
|
||
};
|
||
|
||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||
const fmtTime = (ts) => {
|
||
if (!ts) return "—";
|
||
try {
|
||
return new Date(ts).toLocaleString("ru-RU", {
|
||
day: "2-digit", month: "2-digit", year: "2-digit",
|
||
hour: "2-digit", minute: "2-digit",
|
||
});
|
||
} catch { return ts; }
|
||
};
|
||
|
||
const fmtPhone = (phone) => {
|
||
if (!phone) return "—";
|
||
const digits = String(phone).replace(/\D/g, "");
|
||
if (digits.length === 11 && digits.startsWith("7")) {
|
||
return `+7 (${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7, 9)}-${digits.slice(9)}`;
|
||
}
|
||
return phone;
|
||
};
|
||
|
||
const fmtElapsed = (ts) => {
|
||
if (!ts) return "—";
|
||
const diff = Date.now() - new Date(ts).getTime();
|
||
const m = Math.floor(diff / 60000);
|
||
if (m < 1) return "только что";
|
||
if (m < 60) return `${m} мин назад`;
|
||
const h = Math.floor(m / 60);
|
||
const rest = m % 60;
|
||
return `${h}ч ${rest}м назад`;
|
||
};
|
||
|
||
const parseWorkDays = (str) => {
|
||
if (!str) return new Set([1, 2, 3, 4, 5]);
|
||
return new Set(str.split(",").map(s => parseInt(s.trim())).filter(n => n >= 1 && n <= 7));
|
||
};
|
||
|
||
const serializeWorkDays = (set) => [...set].sort().join(",");
|
||
|
||
// ── Component ────────────────────────────────────────────────────────────────
|
||
export const SmsCampaignPanel = () => {
|
||
const navigate = useNavigate();
|
||
const [activeCampaign, setActiveCampaign] = useState("first_sms");
|
||
const [logs, setLogs] = useState([]);
|
||
const [manualGroups, setManualGroups] = useState([]);
|
||
const [settings, setSettings] = useState(null);
|
||
const [allSettings, setAllSettings] = useState({});
|
||
const [campaignCounts, setCampaignCounts] = useState({});
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [error, setError] = useState(null);
|
||
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 [togglingCampaign, setTogglingCampaign] = useState(null);
|
||
const refreshTimer = useRef(null);
|
||
|
||
const handleOpenGroup = useCallback((groupId) => {
|
||
if (groupId) navigate("/dashboard/group/" + groupId);
|
||
}, [navigate]);
|
||
|
||
// ── Load data ──────────────────────────────────────────────────────────────
|
||
const loadData = useCallback(async () => {
|
||
setError(null);
|
||
try {
|
||
// Load all settings for cards
|
||
const { data: allSettingsData, error: allErr } = await supabase
|
||
.from("sms_campaign_settings")
|
||
.select("*");
|
||
if (allErr) throw allErr;
|
||
const settingsMap = {};
|
||
(allSettingsData || []).forEach(s => { settingsMap[s.campaign_type] = s; });
|
||
setAllSettings(settingsMap);
|
||
|
||
// Load counts per campaign (for cards)
|
||
const { data: countsData, error: countsErr } = await supabase
|
||
.from("sms_campaign_log")
|
||
.select("campaign_type, status");
|
||
if (countsErr) throw countsErr;
|
||
const counts = {};
|
||
(countsData || []).forEach(r => {
|
||
if (!counts[r.campaign_type]) counts[r.campaign_type] = { total: 0, delivered: 0, sent: 0, errors: 0 };
|
||
counts[r.campaign_type].total++;
|
||
if (r.status === "delivered") counts[r.campaign_type].delivered++;
|
||
if (r.status === "sent" || r.status === "checking") counts[r.campaign_type].sent++;
|
||
if (["send_failed", "error", "limit_exceeded"].includes(r.status)) counts[r.campaign_type].errors++;
|
||
});
|
||
|
||
// Also count manual_required groups for the manual card
|
||
const { count: manualCount, error: manualCountErr } = await supabase
|
||
.from("order_groups")
|
||
.select("id", { count: "exact", head: true })
|
||
.eq("notification_status", "manual_required");
|
||
if (!manualCountErr && manualCount != null) {
|
||
counts["manual"] = counts["manual"] || { total: 0, delivered: 0, sent: 0, errors: 0 };
|
||
counts["manual"].total = manualCount;
|
||
}
|
||
setCampaignCounts(counts);
|
||
|
||
// For "manual" campaign: load order_groups with manual_required status
|
||
if (activeCampaign === "manual") {
|
||
const { data: manualData, error: manualErr } = await supabase
|
||
.from("order_groups")
|
||
.select("id, customer_name, customer_phone, notification_status, first_sms_sent_at, second_sms_sent_at, next_notification_check_at, sms_attempts, last_sms_error, delivery_status, delivery_link, created_at, updated_at")
|
||
.eq("notification_status", "manual_required")
|
||
.order("updated_at", { ascending: false })
|
||
.limit(200);
|
||
if (manualErr) throw manualErr;
|
||
setManualGroups(manualData || []);
|
||
setLogs([]); // No SMS logs for manual campaign
|
||
} else {
|
||
setManualGroups([]);
|
||
// Load logs for active campaign
|
||
let query = supabase
|
||
.from("sms_campaign_log")
|
||
.select("*")
|
||
.eq("campaign_type", activeCampaign)
|
||
.order("created_at", { ascending: false })
|
||
.limit(200);
|
||
if (filter !== "all") query = query.eq("status", filter);
|
||
const { data: logData, error: logError } = await query;
|
||
if (logError) throw logError;
|
||
setLogs(logData || []);
|
||
}
|
||
|
||
// Load settings for active campaign
|
||
if (HAS_SETTINGS.includes(activeCampaign)) {
|
||
setSettings(settingsMap[activeCampaign] || null);
|
||
} else {
|
||
setSettings(null);
|
||
}
|
||
} catch (e) {
|
||
setError(e.message || String(e));
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, [activeCampaign, filter]);
|
||
|
||
useEffect(() => { setIsLoading(true); loadData(); }, [loadData]);
|
||
useEffect(() => {
|
||
if (autoRefresh) {
|
||
refreshTimer.current = setInterval(() => loadData(), 30000);
|
||
return () => clearInterval(refreshTimer.current);
|
||
}
|
||
}, [autoRefresh, loadData]);
|
||
|
||
// ── Save settings ──────────────────────────────────────────────────────────
|
||
const handleSaveSettings = async () => {
|
||
if (!settings) return;
|
||
setSavingSettings(true);
|
||
try {
|
||
const { id, ...updates } = settings;
|
||
updates.updated_at = new Date().toISOString();
|
||
const { error: updateError } = await supabase
|
||
.from("sms_campaign_settings")
|
||
.update(updates)
|
||
.eq("id", id);
|
||
if (updateError) throw updateError;
|
||
setSettingsSaved(true);
|
||
setTimeout(() => setSettingsSaved(false), 2000);
|
||
} catch (e) {
|
||
setError(`Ошибка сохранения: ${e.message}`);
|
||
} finally {
|
||
setSavingSettings(false);
|
||
}
|
||
};
|
||
|
||
const updateSetting = (key, value) => {
|
||
setSettings(prev => prev ? { ...prev, [key]: value } : prev);
|
||
};
|
||
|
||
// ── Quick toggle campaign on/off from card ────────────────────────────────
|
||
const toggleCampaignEnabled = async (campaignKey) => {
|
||
const s = allSettings[campaignKey];
|
||
if (!s) return;
|
||
setTogglingCampaign(campaignKey);
|
||
const newVal = !(s.enabled ?? true);
|
||
try {
|
||
const { error: updateError } = await supabase
|
||
.from("sms_campaign_settings")
|
||
.update({ enabled: newVal, updated_at: new Date().toISOString() })
|
||
.eq("id", s.id);
|
||
if (updateError) throw updateError;
|
||
// Update local state
|
||
setAllSettings(prev => ({
|
||
...prev,
|
||
[campaignKey]: { ...prev[campaignKey], enabled: newVal }
|
||
}));
|
||
if (campaignKey === activeCampaign) {
|
||
setSettings(prev => prev ? { ...prev, enabled: newVal } : prev);
|
||
}
|
||
} catch (e) {
|
||
setError(`Ошибка: ${e.message}`);
|
||
} finally {
|
||
setTogglingCampaign(null);
|
||
}
|
||
};
|
||
|
||
const toggleWorkDay = (dayNum) => {
|
||
const current = parseWorkDays(settings?.work_days);
|
||
if (current.has(dayNum)) current.delete(dayNum);
|
||
else current.add(dayNum);
|
||
updateSetting("work_days", serializeWorkDays(current));
|
||
};
|
||
|
||
// ── Recheck ────────────────────────────────────────────────────────────────
|
||
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;
|
||
setLogs(prev => prev.map(l => l.id === logId ? { ...l, needs_check: true } : l));
|
||
} catch (e) {
|
||
setError(`Ошибка: ${e.message}`);
|
||
} finally {
|
||
setTimeout(() => {
|
||
setCheckingIds(prev => { const n = new Set(prev); n.delete(logId); return n; });
|
||
}, 1000);
|
||
}
|
||
};
|
||
|
||
// ── Stats ──────────────────────────────────────────────────────────────────
|
||
const stats = logs.reduce((acc, log) => {
|
||
acc[log.status] = (acc[log.status] || 0) + 1;
|
||
return acc;
|
||
}, {});
|
||
|
||
const showSettings = HAS_SETTINGS.includes(activeCampaign);
|
||
const isManualCampaign = activeCampaign === "manual";
|
||
const isPaidStorageCampaign = activeCampaign === "paid_storage";
|
||
const showSmsFields = !isManualCampaign;
|
||
|
||
// ── Render ──────────────────────────────────────────────────────────────────
|
||
if (isLoading && logs.length === 0) {
|
||
return (
|
||
<Panel className="p-5">
|
||
<div className="animate-pulse text-sm text-[var(--color-text-muted)]">Загрузка…</div>
|
||
</Panel>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* ── Balance + Campaign cards ─────────────────────────────────────────── */}
|
||
{/* Balance banner */}
|
||
{allSettings.first_sms?.last_balance != null && (
|
||
<Panel className="p-3">
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-2xl">💰</span>
|
||
<div>
|
||
<div className="text-xs text-[var(--color-text-muted)]">Баланс sms.ru</div>
|
||
<div className="text-lg font-bold text-[var(--color-text)]">
|
||
{allSettings.first_sms.last_balance.toLocaleString("ru-RU")} ₽
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Panel>
|
||
)}
|
||
|
||
{/* Campaign cards */}
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||
{CAMPAIGNS.map(c => {
|
||
const isActive = activeCampaign === c.key;
|
||
const s = allSettings[c.key];
|
||
const isEnabled = s?.enabled ?? true;
|
||
const isTest = s?.test_mode ?? true;
|
||
const cnt = campaignCounts[c.key] || { total: 0, delivered: 0, sent: 0, errors: 0 };
|
||
const hasS = HAS_SETTINGS.includes(c.key);
|
||
const isToggling = togglingCampaign === c.key;
|
||
return (
|
||
<div
|
||
key={c.key}
|
||
className={`rounded-2xl border p-4 transition cursor-pointer ${
|
||
isActive
|
||
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
|
||
: "border-[var(--color-border)] bg-[var(--color-surface)] hover:bg-[var(--color-surface-strong)]"
|
||
}`}
|
||
onClick={() => { setActiveCampaign(c.key); setFilter("all"); }}
|
||
>
|
||
{/* Header: icon + toggle */}
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-2xl">{c.icon}</span>
|
||
{hasS && (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); toggleCampaignEnabled(c.key); }}
|
||
disabled={isToggling}
|
||
className={`relative h-6 w-11 rounded-full transition ${isEnabled ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"}`}
|
||
>
|
||
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition ${isEnabled ? "left-[20px]" : "left-0.5"}`} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Title */}
|
||
<div className="mt-2 text-sm font-semibold text-[var(--color-text)]">{c.label}</div>
|
||
|
||
{/* Status badge */}
|
||
{hasS && (
|
||
<div className="mt-1">
|
||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
|
||
isEnabled
|
||
? (isTest ? "bg-[rgba(191,123,33,0.15)] text-[var(--color-warning)]" : "bg-[rgba(34,197,94,0.15)] text-[#22c55e]")
|
||
: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]"
|
||
}`}>
|
||
{!isEnabled ? "⏸ Выключена" : isTest ? "🧪 Тест" : "🚀 Боевой"}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Quick stats */}
|
||
{hasS && cnt.total > 0 && (
|
||
<div className="mt-3 grid grid-cols-3 gap-1 text-center">
|
||
<div className="rounded-lg bg-[var(--color-surface-strong)] py-1">
|
||
<div className="text-[10px] text-[var(--color-text-muted)]">Всего</div>
|
||
<div className="text-xs font-bold text-[var(--color-text)]">{cnt.total}</div>
|
||
</div>
|
||
<div className="rounded-lg bg-[var(--color-surface-strong)] py-1">
|
||
<div className="text-[10px] text-[var(--color-text-muted)]">Доставл.</div>
|
||
<div className="text-xs font-bold text-[#22c55e]">{cnt.delivered}</div>
|
||
</div>
|
||
<div className="rounded-lg bg-[var(--color-surface-strong)] py-1">
|
||
<div className="text-[10px] text-[var(--color-text-muted)]">Ошибок</div>
|
||
<div className="text-xs font-bold text-[#ef4444]">{cnt.errors}</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{!hasS && (
|
||
<div className="mt-2 text-[11px] text-[var(--color-text-muted)] leading-snug">В разработке</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</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>
|
||
)}
|
||
|
||
{/* ── Statistics ────────────────────────────────────────────────────── */}
|
||
<SmsCampaignStats campaignType={activeCampaign} />
|
||
|
||
{/* ── Settings ──────────────────────────────────────────────────────── */}
|
||
{showSettings && settings && (
|
||
<Panel className="p-5">
|
||
<div className="mb-4 flex items-center justify-between">
|
||
<h3 className="text-sm font-semibold text-[var(--color-text)]">
|
||
{CAMPAIGNS.find(c => c.key === activeCampaign)?.icon} {CAMPAIGNS.find(c => c.key === activeCampaign)?.label}
|
||
</h3>
|
||
{settingsSaved && <span className="text-xs text-[var(--color-accent)]">✓ Сохранено</span>}
|
||
</div>
|
||
|
||
{/* Test / Production toggle — только для SMS-кампаний */}
|
||
{!isManualCampaign && (
|
||
<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">
|
||
<div>
|
||
<div className="text-sm font-semibold text-[var(--color-text)]">
|
||
{settings.test_mode ? "🧪 Тестовый режим" : "🚀 Боевой режим"}
|
||
</div>
|
||
<div className="mt-0.5 text-xs text-[var(--color-text-muted)]">
|
||
{settings.test_mode
|
||
? `SMS только на: ${settings.test_phone || "—"}`
|
||
: "SMS реальным клиентам из базы"}
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => updateSetting("test_mode", !settings.test_mode)}
|
||
className={`relative h-7 w-12 rounded-full transition ${settings.test_mode ? "bg-[var(--color-warning)]" : "bg-[var(--color-accent)]"}`}
|
||
>
|
||
<span className={`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition ${settings.test_mode ? "left-0.5" : "left-[22px]"}`} />
|
||
</button>
|
||
</label>
|
||
{settings.test_mode && (
|
||
<div className="mt-3">
|
||
<SettingField label="Тестовый номер" value={settings.test_phone || ""} onChange={(v) => updateSetting("test_phone", v)} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── SMS text template ──────────────────────────────────────────── */}
|
||
{!isManualCampaign && (
|
||
<div className="mb-4 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
|
||
<div className="mb-2 text-xs font-semibold text-[var(--color-text)]">📝 Текст SMS</div>
|
||
<textarea
|
||
value={settings.sms_text_template || ""}
|
||
onChange={(e) => updateSetting("sms_text_template", e.target.value)}
|
||
rows={3}
|
||
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none resize-y"
|
||
placeholder="Текст SMS. Используйте {link} для подстановки ссылки"
|
||
/>
|
||
<div className="mt-1 text-[10px] text-[var(--color-text-muted)]">
|
||
{"{link}"} будет заменён на ссылку доставки. Текущая длина: {(settings.sms_text_template || "").replace("{link}", "https://dost.supersamsev.ru/d/XXXXXX").length} символов
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Интервалы ─────────────────────────────────────────────────── */}
|
||
<div className="mb-1 text-xs font-semibold text-[var(--color-text)]">
|
||
{isManualCampaign ? "⚙️ Настройки" : "📊 Интервалы и попытки"}
|
||
</div>
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||
{!isManualCampaign && (
|
||
<>
|
||
<SettingField label="Пауза между проверками (сек)" value={settings.wait_between_checks_seconds} onChange={(v) => updateSetting("wait_between_checks_seconds", parseInt(v) || 25)} />
|
||
<SettingField label="Макс. время ожидания (мин)" value={settings.max_check_duration_minutes} onChange={(v) => updateSetting("max_check_duration_minutes", parseInt(v) || 90)} />
|
||
<SettingField label="Макс. попыток отправки" value={settings.max_attempts} onChange={(v) => updateSetting("max_attempts", parseInt(v) || 2)} />
|
||
{!isPaidStorageCampaign && (
|
||
<SettingField label="Вторая SMS через (часов)" value={settings.second_sms_delay_hours} onChange={(v) => updateSetting("second_sms_delay_hours", parseInt(v) || 3)} />
|
||
)}
|
||
</>
|
||
)}
|
||
<SettingField label="Ручное согласование через (часов)" value={settings.auto_manual_after_hours} onChange={(v) => updateSetting("auto_manual_after_hours", parseInt(v) || 3)} />
|
||
<SettingField label="Telegram chat ID" value={settings.telegram_chat_id || ""} onChange={(v) => updateSetting("telegram_chat_id", v)} />
|
||
</div>
|
||
|
||
{/* ── Время работы ────────────────────────────────────────────── */}
|
||
<div className="mt-4 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
|
||
<div className="mb-3 text-xs font-semibold text-[var(--color-text)]">
|
||
{isManualCampaign ? "⏰ Время проверки" : "⏰ Время отправки SMS"}
|
||
</div>
|
||
|
||
{/* Часы */}
|
||
<div className="mb-3 flex items-center gap-2">
|
||
<select
|
||
value={settings.work_hours_start ?? 8}
|
||
onChange={(e) => updateSetting("work_hours_start", parseInt(e.target.value))}
|
||
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
|
||
>
|
||
{Array.from({ length: 24 }, (_, h) => <option key={h} value={h}>{h}:00</option>)}
|
||
</select>
|
||
<span className="text-xs text-[var(--color-text-muted)]">—</span>
|
||
<select
|
||
value={settings.work_hours_end ?? 21}
|
||
onChange={(e) => updateSetting("work_hours_end", parseInt(e.target.value))}
|
||
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
|
||
>
|
||
{Array.from({ length: 24 }, (_, h) => <option key={h} value={h}>{h}:00</option>)}
|
||
</select>
|
||
<span className="text-[10px] text-[var(--color-text-muted)]">по Москве</span>
|
||
</div>
|
||
|
||
{/* Дни недели — таблетки */}
|
||
<div className="flex flex-wrap gap-2">
|
||
{DAYS.map(d => {
|
||
const activeDays = parseWorkDays(settings.work_days);
|
||
const isOn = activeDays.has(d.num);
|
||
return (
|
||
<button
|
||
key={d.num}
|
||
type="button"
|
||
onClick={() => toggleWorkDay(d.num)}
|
||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition ${
|
||
isOn
|
||
? "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)]"
|
||
}`}
|
||
>
|
||
{d.short}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
|
||
Проверка статусов работает круглосуточно. Отправка — только в выбранные часы и дни.
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Вкл/выкл + сохранить ──────────────────────────────────────── */}
|
||
<div className="mt-4 flex items-center gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => updateSetting("enabled", !(settings.enabled ?? true))}
|
||
className={`relative h-7 w-12 rounded-full transition ${settings.enabled ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"}`}
|
||
>
|
||
<span className={`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition ${settings.enabled ? "left-[22px]" : "left-0.5"}`} />
|
||
</button>
|
||
<span className="text-xs font-medium text-[var(--color-text)]">
|
||
{settings.enabled ? "Кампания включена" : "Кампания выключена"}
|
||
</span>
|
||
<button
|
||
onClick={handleSaveSettings}
|
||
disabled={savingSettings}
|
||
className="ml-auto rounded-xl bg-[var(--color-accent)] px-4 py-1.5 text-xs font-semibold text-white hover:opacity-90 disabled:opacity-50"
|
||
>
|
||
{savingSettings ? "Сохранение…" : "Сохранить"}
|
||
</button>
|
||
</div>
|
||
</Panel>
|
||
)}
|
||
|
||
{/* ── Not implemented ──────────────────────────────────────────────── */}
|
||
{!showSettings && (
|
||
<Panel className="p-5">
|
||
<div className="text-sm text-[var(--color-text-muted)]">
|
||
{CAMPAIGNS.find(c => c.key === activeCampaign)?.label} — в разработке
|
||
</div>
|
||
</Panel>
|
||
)}
|
||
|
||
{/* ── Manual campaign: groups table ─────────────────────────────────── */}
|
||
{isManualCampaign ? (
|
||
<>
|
||
<Panel className="p-4">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs font-semibold text-[var(--color-text-muted)]">В ручном управлении: {manualGroups.length}</span>
|
||
</div>
|
||
</Panel>
|
||
|
||
<Panel className="p-0">
|
||
<div className="overflow-x-auto">
|
||
<div className="min-w-[800px]">
|
||
<div className="grid grid-cols-[minmax(160px,2fr)_minmax(120px,1fr)_minmax(120px,1fr)_minmax(120px,1fr)_minmax(100px,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">1-я SMS</div>
|
||
<div className="px-3 py-1.5 font-medium">2-я SMS</div>
|
||
<div className="px-3 py-1.5 font-medium">Обновлён</div>
|
||
</div>
|
||
{manualGroups.length === 0 ? (
|
||
<div className="px-4 py-6 text-xs text-[var(--color-text-muted)]">Нет групп в ручном управлении</div>
|
||
) : (
|
||
manualGroups.map((group) => (
|
||
<div
|
||
key={group.id}
|
||
onClick={() => handleOpenGroup(group.id)}
|
||
className="grid grid-cols-[minmax(160px,2fr)_minmax(120px,1fr)_minmax(120px,1fr)_minmax(120px,1fr)_minmax(100px,0.8fr)] gap-0 border-t border-[var(--color-border)] text-xs hover:bg-[var(--color-accent-soft)] cursor-pointer"
|
||
>
|
||
<div className="px-3 py-1.5 text-[var(--color-text)]">
|
||
<div className="font-medium">{group.customer_name || "—"}</div>
|
||
{group.last_sms_error && <div className="mt-0.5 text-[10px] text-[var(--color-danger)]">{group.last_sms_error.slice(0, 60)}</div>}
|
||
</div>
|
||
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">{fmtPhone(group.customer_phone)}</div>
|
||
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">
|
||
{group.first_sms_sent_at ? fmtTime(group.first_sms_sent_at) : "—"}
|
||
</div>
|
||
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">
|
||
{group.second_sms_sent_at ? fmtTime(group.second_sms_sent_at) : <span className="text-[var(--color-danger)]">не отправлена</span>}
|
||
</div>
|
||
<div className="px-3 py-1.5 text-[var(--color-text-muted)]">
|
||
{fmtTime(group.updated_at)}
|
||
<div className="text-[10px]">{fmtElapsed(group.updated_at)}</div>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Panel>
|
||
</>
|
||
) : (
|
||
<>
|
||
{/* ── Stats ─────────────────────────────────────────────────────────── */}
|
||
<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>
|
||
{Object.entries(STATUS_LABELS).map(([status, label]) => {
|
||
const count = stats[status] || 0;
|
||
if (count === 0) return null;
|
||
return <Badge key={status} tone={STATUS_TONES[status] || "neutral"}>{label}: {count}</Badge>;
|
||
})}
|
||
</div>
|
||
</Panel>
|
||
|
||
{/* ── 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 ──────────────────────────────────────────────────────── */}
|
||
<Panel className="p-0">
|
||
<div className="overflow-x-auto">
|
||
<div className="min-w-[900px]">
|
||
<div className="grid grid-cols-[minmax(140px,1.5fr)_minmax(100px,1fr)_minmax(80px,0.8fr)_minmax(70px,0.6fr)_minmax(50px,0.4fr)_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>
|
||
{logs.length === 0 ? (
|
||
<div className="px-4 py-6 text-xs text-[var(--color-text-muted)]">Нет записей</div>
|
||
) : (
|
||
logs.map((entry) => {
|
||
const canRecheck = entry.status === "sent" || entry.status === "checking";
|
||
const isChecking = checkingIds.has(entry.id);
|
||
const isTestMode = settings?.test_mode;
|
||
return (
|
||
<div
|
||
key={entry.id}
|
||
onClick={() => handleOpenGroup(entry.order_group_id)}
|
||
className="grid grid-cols-[minmax(140px,1.5fr)_minmax(100px,1fr)_minmax(80px,0.8fr)_minmax(70px,0.6fr)_minmax(50px,0.4fr)_minmax(100px,1fr)_minmax(90px,0.8fr)] gap-0 border-t border-[var(--color-border)] text-xs hover:bg-[var(--color-accent-soft)] cursor-pointer"
|
||
>
|
||
<div className="px-3 py-1.5 text-[var(--color-text)]">
|
||
{fmtPhone(entry.customer_phone)}
|
||
{isTestMode && <div className="text-[10px] text-[var(--color-warning)]">🧪 на тест. номер</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>
|
||
{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]">{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]">{fmtElapsed(entry.created_at)}</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" onClick={(e) => e.stopPropagation()}>
|
||
{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>
|
||
</Panel>
|
||
|
||
<div className="flex justify-end">
|
||
<button onClick={loadData} className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)]">
|
||
↻ Обновить
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||
const SettingField = ({ label, value, onChange }) => (
|
||
<label className="block">
|
||
<span className="mb-1 block text-[10px] font-medium text-[var(--color-text-muted)]">{label}</span>
|
||
<input
|
||
type="text"
|
||
value={value ?? ""}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
|
||
/>
|
||
</label>
|
||
);
|
||
|
||
const FilterButton = ({ active, onClick, children }) => (
|
||
<button
|
||
onClick={onClick}
|
||
className={`rounded-full px-3 py-1 text-xs font-medium transition ${
|
||
active
|
||
? "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)]"
|
||
}`}
|
||
>
|
||
{children}
|
||
</button>
|
||
); |