supersam/src/components/admin/SmsCampaignPanel.jsx

886 lines
48 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @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";
import { SmsCityProfilesPanel } from "./SmsCityProfilesPanel";
import { resolveSmsBalance } from "../../services/smsBalance";
// ── 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"];
// ── SMS text templates (доставка / самовывоз) ────────────────────────────────
const SMS_LINK_PREVIEW = "https://dost.supersamsev.ru/d/XXXXXX";
// Образцы значений для предпросмотра (как будет выглядеть SMS у клиента)
const SMS_PREVIEW_VALUES = [
["{номер_доставки}", "010796"], ["{номер}", "010796"], ["{order}", "010796"],
["{код_выдачи}", "7598"], ["{код}", "7598"], ["{code}", "7598"],
["{ссылка}", SMS_LINK_PREVIEW], ["{link}", SMS_LINK_PREVIEW],
];
const smsPreviewText = (template) => {
let text = (template || "").trim();
SMS_PREVIEW_VALUES.forEach(([token, value]) => {
text = text.split(token).join(value);
});
return text;
};
const renderSmsPreview = (template, emptyLabel) => smsPreviewText(template) || emptyLabel;
// ── 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 [smsBalance, setSmsBalance] = useState(null);
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 [showCityRules, setShowCityRules] = useState(false);
const [logPage, setLogPage] = useState(1);
const [manualPage, setManualPage] = useState(1);
const refreshTimer = useRef(null);
const PER_PAGE = 20;
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);
// Баланс — тот же живой источник, что на главной странице
resolveSmsBalance(settingsMap.first_sms?.last_balance ?? null).then(bal => setSmsBalance(bal));
// 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 banner — тот же живой баланс, что на главной (один источник) */}
{smsBalance?.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)]">
{smsBalance.balance.toLocaleString("ru-RU")} ₽
</div>
{!smsBalance.live && (
<div className="text-[10px] text-[var(--color-text-muted)]">
показан баланс на момент последней рассылки — живой запрос не прошёл
</div>
)}
</div>
</div>
</Panel>
)}
{/* Campaign cards */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4 max-w-4xl">
{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"); setLogPage(1); setManualPage(1); }}
>
{/* 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>
{/* ── City rules button ──────────────────────────────────────────── */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setShowCityRules(!showCityRules)}
className={[
"rounded-2xl border px-4 py-2.5 text-sm font-medium transition flex items-center gap-2",
showCityRules
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
].join(" ")}
>
🏙️ SMS по городам
</button>
</div>
{/* ── City profiles panel ────────────────────────────────────────── */}
{showCityRules && <SmsCityProfilesPanel />}
{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 ────────────────────────────────────────────────────── */}
{!showCityRules && <SmsCampaignStats campaignType={activeCampaign} />}
{/* ── Settings ──────────────────────────────────────────────────────── */}
{!showCityRules && 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 templates: доставка / самовывоз ───────────────────── */}
{!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>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<div>
<div className="mb-1 text-[11px] font-semibold text-[var(--color-text-muted)]">📦 Доставка</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="Ваш заказ готов. Согласуйте дату доставки по ссылке: {link}"
/>
<div className="mt-1 text-[10px] text-[var(--color-text-muted)]">
Текущая длина: {smsPreviewText(settings.sms_text_template).length} символов
</div>
</div>
<div>
<div className="mb-1 text-[11px] font-semibold text-[var(--color-text-muted)]">🏪 Самовывоз</div>
<textarea
value={settings.sms_text_template_pickup || ""}
onChange={(e) => updateSetting("sms_text_template_pickup", 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="Ваш заказ готов. Согласуйте дату самовывоза по ссылке: {link}"
/>
<div className="mt-1 text-[10px] text-[var(--color-text-muted)]">
Текущая длина: {smsPreviewText(settings.sms_text_template_pickup).length} символов
</div>
</div>
</div>
<div className="mt-2 rounded-lg bg-[var(--color-surface-strong)] px-3 py-2 text-[10px] text-[var(--color-text-muted)]">
Текст выбирается по типу отгрузки: самовывоз → правый шаблон, доставка → левый.
<div className="mt-1">
Переменные: {"{link}"} — ссылка согласования, {"{order}"} — номер доставки (можно {"{номер}"}), {"{code}"} — код выдачи (можно {"{код}"}).
</div>
<div className="mt-1">📦 Доставка: {renderSmsPreview(settings.sms_text_template, "— пусто, уйдёт стандартный текст кампании")}</div>
<div>🏪 Самовывоз: {renderSmsPreview(settings.sms_text_template_pickup, "— пусто, уйдёт стандартный текст кампании")}</div>
<div className="mt-1">Если у города в «SMS по городам» задан свой текст в режиме «Уведомление» — уходит он, и тогда шаблон применяется только если в нём нет {"{link}"}.</div>
</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)} hint="Отсчёт от последнего контакта: SMS или звонок робота. Если автодозвон включён — ждём звонок, если включено второе SMS — ждём его." />
<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 ──────────────────────────────────────────────── */}
{!showCityRules && !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>
) : (
(() => {
const start = (manualPage - 1) * PER_PAGE;
const pageGroups = manualGroups.slice(start, start + PER_PAGE);
return pageGroups.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>
{/* ── Pagination: manual groups ─────────────────────────────────── */}
{manualGroups.length > PER_PAGE && (
<div className="flex items-center justify-center gap-2">
<button
onClick={() => setManualPage(p => Math.max(1, p - 1))}
disabled={manualPage <= 1}
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-40"
>← Назад</button>
<span className="text-xs text-[var(--color-text-muted)]">
Стр. {manualPage} / {Math.ceil(manualGroups.length / PER_PAGE)} · {manualGroups.length} записей
</span>
<button
onClick={() => setManualPage(p => Math.min(Math.ceil(manualGroups.length / PER_PAGE), p + 1))}
disabled={manualPage >= Math.ceil(manualGroups.length / PER_PAGE)}
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-40"
>Вперёд →</button>
</div>
)}
</>
) : (
<>
{/* ── 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"); setLogPage(1); }}>Все ({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); setLogPage(1); }}>{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>
) : (
(() => {
const totalPages = Math.ceil(logs.length / PER_PAGE);
const start = (logPage - 1) * PER_PAGE;
const pageLogs = logs.slice(start, start + PER_PAGE);
return pageLogs.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>
{/* ── Pagination: logs ─────────────────────────────────────────────── */}
{logs.length > PER_PAGE && (
<div className="flex items-center justify-center gap-2">
<button
onClick={() => setLogPage(p => Math.max(1, p - 1))}
disabled={logPage <= 1}
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-40"
>← Назад</button>
<span className="text-xs text-[var(--color-text-muted)]">
Стр. {logPage} / {Math.ceil(logs.length / PER_PAGE)} · {logs.length} записей
</span>
<button
onClick={() => setLogPage(p => Math.min(Math.ceil(logs.length / PER_PAGE), p + 1))}
disabled={logPage >= Math.ceil(logs.length / PER_PAGE)}
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-40"
>Вперёд →</button>
</div>
)}
<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, hint }) => (
<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"
/>
{hint ? <span className="mt-1 block text-[10px] leading-snug text-[var(--color-text-muted)]">{hint}</span> : null}
</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>
);