supersam/src/components/admin/SmsCampaignPanel.jsx

1910 lines
98 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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 cards + settings + log table + queue with next-send times
*/
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, supabaseUrl, supabaseAnonKey } from "../../supabaseClient";
import { Pagination } from "../UI/Pagination";
import { SmsCampaignStats } from "./SmsCampaignStats";
// ── Campaigns ────────────────────────────────────────────────────────────────
const CAMPAIGNS = [
{ key: "first_sms", label: "Первая отправка", icon: "📤", desc: "Первое SMS клиенту: ссылка на согласование доставки", queueStatus: "link_ready" },
{ key: "second_sms", label: "Второе сообщение", icon: "📨", desc: "Повторное SMS через 3ч, если клиент не согласовал", queueStatus: "first_sms_sent" },
{ key: "manual", label: "Ручное управление", icon: "🔧", desc: "Перевод к ручному согласованию после двух SMS", queueStatus: "second_sms_sent" },
{ key: "paid_storage", label: "Платное хранение", icon: "📦", desc: "Уведомление о платном хранении", queueStatus: null },
];
const HAS_SETTINGS = ["first_sms", "second_sms", "manual", "paid_storage"];
const MANUAL_CAMPAIGN = "manual";
// ── 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: "SMS отправлено",
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",
};
const STATUS_ICONS = {
sent: "📤",
checking: "📡",
delivered: "✅",
send_failed: "❌",
error: "⛔",
expired: "⏰",
limit_exceeded: "🚫",
manual_override: "✋",
};
// ── SMS code labels ──────────────────────────────────────────────────────────
const SMS_CODE_LABELS = {
"100": "В очереди SMS.ru", "101": "Передано оператору", "102": "В пути",
"103": "Доставлено", "104": "Истёкло время", "105": "Удалено оператором",
"106": "Сбой телефона", "107": "Неизвестная причина", "108": "Отклонено",
"130": "Лимит на номер/день", "131": "Лимит одинаковых/мин",
"132": "Лимит одинаковых/день", "200": "Неправильный api_id",
"201": "Недостаточно средств", "202": "Неправильный получатель",
"230": "Общий лимит/день", "231": "Лимит одинаковых/мин",
"232": "Лимит одинаковых/день",
};
// ── Notification status labels (for queue) ──────────────────────────────────
const NOTIF_LABELS = {
"not_started": "⏳ В очереди",
"link_ready": "⏳ В очереди",
"sms_sending": "📡 Отправляется…",
"first_sms_sent": "✓ 1-я SMS отправлена",
"second_sms_sending": "📡 2-я SMS отправляется…",
"second_sms_sent": "✓ 2-я SMS отправлена",
"send_failed": "❌ Ошибка отправки",
"manual_required": "🔧 Ручное управление",
"paid_storage_sending": "📡 Отправляется…",
"paid_storage_sent": "✓ Платное хранение: отправлено",
"completed": "✅ Завершено",
"confirmed": "✅ Согласовано клиентом",
"address_required": "⚠ Требуется адрес",
"draft": "Черновик",
};
const NOTIF_TONES = {
"not_started": "neutral",
"link_ready": "warning",
"sms_sending": "info",
"first_sms_sent": "info",
"second_sms_sending": "info",
"second_sms_sent": "info",
"send_failed": "danger",
"manual_required": "warning",
"paid_storage_sending": "info",
"paid_storage_sent": "accent",
"completed": "accent",
"confirmed": "accent",
"address_required": "danger",
"draft": "neutral",
};
// ── 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 fmtTimeShort = (ts) => {
if (!ts) return "—";
try {
return new Date(ts).toLocaleString("ru-RU", {
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}м назад`;
};
// ── Date filter helpers ───────────────────────────────────────────────
const isToday = (ts) => {
if (!ts) return false;
const d = new Date(ts);
const now = new Date();
return d.getDate() === now.getDate() && d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear();
};
const isYesterday = (ts) => {
if (!ts) return false;
const d = new Date(ts);
const y = new Date();
y.setDate(y.getDate() - 1);
return d.getDate() === y.getDate() && d.getMonth() === y.getMonth() && d.getFullYear() === y.getFullYear();
};
const isWithin7d = (ts) => {
if (!ts) return false;
return new Date(ts).getTime() >= Date.now() - 7 * 24 * 3600 * 1000;
};
const passesDateFilter = (ts, df) => {
if (df === "all" || !df) return true;
if (df === "today") return isToday(ts);
if (df === "yesterday") return isYesterday(ts);
if (df === "7d") return isWithin7d(ts);
return true;
};
const passesLinkFilter = (isOpened, lf) => {
if (lf === "all" || !lf) return true;
if (lf === "opened") return isOpened === true;
if (lf === "not_opened") return isOpened !== 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) => {
if (!ts) return "—";
const diff = new Date(ts).getTime() - Date.now();
if (diff <= 0) return "сейчас";
const m = Math.floor(diff / 60000);
if (m < 60) return `через ${m} мин`;
const h = Math.floor(m / 60);
const rest = m % 60;
return `через ${h}ч ${rest}м`;
};
// ── Agreement formatter (delivery/pickup status) ────────────────────────────
const fmtAgreement = (g) => {
if (!g) return { text: "—", tone: "neutral", icon: "⏳" };
const ds = g.delivery_status;
// Terminal states first — delivery already happened or cancelled
if (ds === "picked_up") {
return { text: "✅ Самовывоз завершён", tone: "accent", icon: "✅" };
}
if (ds === "delivered") {
return { text: "✅ Доставлено", tone: "accent", icon: "✅" };
}
if (ds === "cancelled") {
return { text: "❌ Отменён", tone: "danger", icon: "❌" };
}
// If client already chose date/time — show it
if (g.delivery_type === "pickup" || g.pickup_date) {
const date = g.pickup_date ? new Date(g.pickup_date).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" }) : "";
const slot = g.pickup_time_slot || "";
const hasAll = date && slot;
return { text: `🏪 Самовывоз${date ? " " + date : ""}${slot ? ", " + slot : ""}${!hasAll ? " (неполно)" : ""}`, tone: "accent", icon: "🏪" };
}
if (g.delivery_date) {
const date = new Date(g.delivery_date).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
const time = g.delivery_time || "";
// Use delivery_address, or customer_address if it's NOT 'САМОВЫВОЗ'
const rawAddr = g.delivery_address || (g.customer_address && !/^самовывоз/i.test(g.customer_address.trim()) ? g.customer_address : "");
const addrShort = rawAddr ? ` (${rawAddr.slice(0, 30)}${rawAddr.length > 30 ? "…" : ""})` : "";
const missingAddr = !rawAddr && ds === "requires_address";
return { text: `📦 Доставка${date ? " " + date : ""}${time ? ", " + time : ""}${addrShort}${missingAddr ? " ⚠ адрес" : ""}`, tone: missingAddr ? "warning" : "accent", icon: "📦" };
}
// No chosen date — status-based messages
if (ds === "requires_address" || ds === "address_required") {
return { text: "⚠ Адрес нужен", tone: "warning", icon: "⚠" };
}
if (ds === "manual_confirmation_required") {
return { text: "🔧 Ручное управление", tone: "warning", icon: "🔧" };
}
if (ds === "paid_storage") {
return { text: "📦 Платное хранение", tone: "warning", icon: "📦" };
}
if (ds === "problem") {
return { text: "⚠ Проблема", tone: "danger", icon: "⚠" };
}
// pending_confirmation — not agreed yet
return { text: "⏳ Не согласовано", tone: "neutral", icon: "⏳" };
};
const AGREEMENT_TONES = {
accent: "#22c55e",
warning: "var(--color-warning)",
danger: "var(--color-danger)",
neutral: "var(--color-text-muted)",
};
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(",");
// Calculate next send time based on work hours/days
const calcNextSendTime = (group, settings, campaignKey) => {
if (!settings) return null;
const now = new Date();
const workStart = settings.work_hours_start ?? 8;
const workEnd = settings.work_hours_end ?? 21;
const workDays = parseWorkDays(settings.work_days);
const delayHours = settings.second_sms_delay_hours ?? 3;
let base;
if (campaignKey === "first_sms") {
// Next send: next work window
base = new Date(now);
} else if (campaignKey === "second_sms") {
// Second SMS: first_sms_sent_at + delayHours
if (!group.first_sms_sent_at) return null;
base = new Date(group.first_sms_sent_at);
base.setHours(base.getHours() + delayHours);
} else if (campaignKey === "manual") {
// Manual: second_sms_sent_at + auto_manual_after_hours
if (!group.second_sms_sent_at) return null;
base = new Date(group.second_sms_sent_at);
base.setHours(base.getHours() + (settings.auto_manual_after_hours ?? 3));
} else {
base = new Date(now);
}
// If base is in the past, find next work window from now
if (base < now) base = new Date(now);
// Adjust to work hours/days
let candidate = new Date(base);
// Round up to next minute
candidate.setSeconds(0, 0);
// If outside work hours or not a work day, find next valid slot
for (let i = 0; i < 14 * 24; i++) { // max 14 days
const day = candidate.getDay() === 0 ? 7 : candidate.getDay();
const hour = candidate.getHours();
if (workDays.has(day) && hour >= workStart && hour < workEnd) {
return candidate.toISOString();
}
candidate.setHours(candidate.getHours() + 1, 0, 0, 0);
}
return null;
};
// ── Toggle Switch ────────────────────────────────────────────────────────────
const ToggleSwitch = ({ on, onClick, disabled, size = "md" }) => {
const dims = size === "sm" ? { w: "w-9", h: "h-5", knob: "h-4 w-4", on: "left-[18px]", off: "left-0.5" }
: { w: "w-12", h: "h-7", knob: "h-6 w-6", on: "left-[22px]", off: "left-0.5" };
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`relative ${dims.w} ${dims.h} rounded-full transition-all duration-200 disabled:opacity-50 ${
on ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"
}`}
>
<span className={`absolute top-0.5 ${dims.knob} rounded-full bg-white shadow-sm transition-all duration-200 ${on ? dims.on : dims.off}`} />
</button>
);
};
// ── Mini Toggle (for timer in card) ──────────────────────────────────────────
const MiniToggle = ({ on, onClick, disabled }) => (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`relative h-3.5 w-7 rounded-full transition-all duration-200 disabled:opacity-50 ${
on ? "bg-[#22c55e]" : "bg-[var(--color-border)]"
}`}
>
<span className={`absolute top-0.5 h-2.5 w-2.5 rounded-full bg-white shadow-sm transition-all duration-200 ${on ? "left-[14px]" : "left-0.5"}`} />
</button>
);
// ── KPI Tile ─────────────────────────────────────────────────────────────────
const KpiTile = ({ icon, value, label, color }) => (
<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-xs uppercase tracking-wide text-[var(--color-text-muted)] mb-1.5">{label}</div>
<div className="text-lg font-bold" style={{ color: color || "var(--color-text)" }}>
{icon && <span className="mr-1 text-base">{icon}</span>}{value}
</div>
</div>
);
// ── SettingField ─────────────────────────────────────────────────────────────
const SettingField = ({ label, value, onChange }) => (
<label className="block">
<span className="mb-1 block text-[10px] font-medium uppercase tracking-wide 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-2 text-xs text-[var(--color-text)] transition focus:border-[var(--color-accent)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent-soft)]"
/>
</label>
);
// ── FilterButton ─────────────────────────────────────────────────────────────
const FilterButton = ({ active, onClick, children }) => (
<button
onClick={onClick}
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-all duration-200 ${
active
? "bg-[var(--color-accent)] text-white shadow-sm"
: "border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)]"
}`}
>
{children}
</button>
);
// ── Card Button ──────────────────────────────────────────────────────────────
const CardButton = ({ onClick, disabled, tone, children }) => {
const tones = {
warning: "border-[var(--color-warning)] text-[var(--color-warning)] hover:bg-[var(--color-accent-soft)]",
accent: "border-[var(--color-accent)] text-[var(--color-accent)] hover:bg-[var(--color-accent-soft)]",
danger: "border-[var(--color-danger)] text-[var(--color-danger)] hover:bg-[rgba(239,68,68,0.08)]",
};
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`rounded-xl border px-4 py-2 text-xs font-medium transition-all duration-200 disabled:opacity-40 ${tones[tone] || tones.accent}`}
>
{children}
</button>
);
};
// ── Table helpers (desktop) ──────────────────────────────────────────────────
const TableHeader = ({ cols }) => (
<div
className="hidden sm:grid border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]"
style={{ gridTemplateColumns: cols.map(c => c.width).join(" ") }}
>
{cols.map((c, i) => (
<div key={i} className="px-4 py-2.5">{c.label}</div>
))}
</div>
);
const TableRow = ({ cols, onClick, children }) => (
<div
onClick={onClick}
className="hidden sm:grid border-t border-[var(--color-border)] text-xs transition-colors duration-150 hover:bg-[var(--color-accent-soft)] cursor-pointer"
style={{ gridTemplateColumns: cols.map(c => c.width).join(" ") }}
>
{children}
</div>
);
// ── Mobile card row ───────────────────────────────────────────────────────────
const MobileCard = ({ onClick, children, className = "" }) => (
<div
onClick={onClick}
className={`sm:hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3 mb-2 cursor-pointer transition hover:bg-[var(--color-surface-strong)] ${className}`}
>
{children}
</div>
);
const MobileRow = ({ label, value, color }) => (
<div className="flex items-center justify-between gap-2 py-1">
<span className="text-[10px] uppercase tracking-wide text-[var(--color-text-muted)]">{label}</span>
<span className="text-xs font-medium text-right" style={color ? { color } : { color: "var(--color-text)" }}>{value}</span>
</div>
);
// ── Component ────────────────────────────────────────────────────────────────
export const SmsCampaignPanel = () => {
const navigate = useNavigate();
const [activeCampaign, setActiveCampaign] = useState("first_sms");
const [logs, setLogs] = useState([]);
const [settings, setSettings] = useState(null);
const [allSettings, setAllSettings] = useState({});
const [campaignCounts, setCampaignCounts] = useState({});
const [queueData, setQueueData] = useState([]);
const [queueCounts, setQueueCounts] = useState({});
const [lastSmsByCampaign, setLastSmsByCampaign] = useState({});
const [runningNow, setRunningNow] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [filter, setFilter] = useState("all");
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 [viewMode, setViewMode] = useState("logs");
const [showSettingsPanel, setShowSettingsPanel] = useState(false);
const [dateFilter, setDateFilter] = useState("all"); // all|today|yesterday|7d
const [linkFilter, setLinkFilter] = useState("all"); // all|opened|not_opened
const [smsFilter, setSmsFilter] = useState("all"); // all|no_sms
const [selectedIds, setSelectedIds] = useState(new Set());
const [deleting, setDeleting] = useState(false);
const [showSmsPreview, setShowSmsPreview] = useState(false);
const [logPage, setLogPage] = useState(1);
const [queuePage, setQueuePage] = useState(1);
const PAGE_SIZE = 25;
const [linkStats, setLinkStats] = useState({ opened: 0, total: 0, notOpened: 0 });
const [openedGroupIds, setOpenedGroupIds] = useState(new Set());
const refreshTimer = useRef(null);
const handleOpenGroup = useCallback((groupId) => {
if (groupId) navigate("/dashboard/group/" + groupId);
}, [navigate]);
// ── Load data ──────────────────────────────────────────────────────────────
const loadData = useCallback(async () => {
setError(null);
try {
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);
const { data: countsData, error: countsErr } = await supabase
.from("sms_campaign_log")
.select("campaign_type, status, was_test_mode");
if (countsErr) throw countsErr;
const counts = {};
(countsData || []).forEach(r => {
if (r.was_test_mode) return; // Skip test sends from KPI
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++;
});
setCampaignCounts(counts);
// Load last SMS timestamp per campaign (non-test only)
const { data: lastSmsData, error: lastSmsErr } = await supabase
.from("sms_campaign_log")
.select("campaign_type, created_at")
.eq("was_test_mode", false)
.order("created_at", { ascending: false })
.limit(200);
const lastSmsMap = {};
if (!lastSmsErr && lastSmsData) {
lastSmsData.forEach(r => {
if (!lastSmsMap[r.campaign_type]) lastSmsMap[r.campaign_type] = r.created_at;
});
}
setLastSmsByCampaign(lastSmsMap);
const { data: queueCountsData, error: qErr } = await supabase
.from("order_groups")
.select("notification_status, delivery_status")
.in("delivery_status", ["pending_confirmation", "paid_storage"]);
if (qErr) throw qErr;
const qCounts = { first_sms: 0, second_sms: 0, manual: 0, paid_storage: 0 };
(queueCountsData || []).forEach(r => {
if (r.delivery_status === "pending_confirmation") {
if (r.notification_status === "link_ready" || r.notification_status === "not_started") qCounts.first_sms++;
if (r.notification_status === "first_sms_sent") qCounts.second_sms++;
if (r.notification_status === "second_sms_sent") qCounts.manual++;
}
if (r.delivery_status === "paid_storage" && r.notification_status !== "paid_storage_sent" && r.notification_status !== "paid_storage_sending") {
qCounts.paid_storage++;
}
});
setQueueCounts(qCounts);
// Load link open statistics from delivery_invitations
const { data: invStatsData, error: invErr } = await supabase
.from("delivery_invitations")
.select("order_group_id, opened_at, access_count")
.not("order_group_id", "is", null);
if (!invErr && invStatsData) {
const opened = invStatsData.filter(i => i.opened_at).length;
const total = invStatsData.length;
setLinkStats({ opened, total, notOpened: total - opened });
}
// Load per-campaign link stats by joining with order_groups notification_status
const { data: invWithGroups, error: invGroupErr } = await supabase
.from("delivery_invitations")
.select("order_group_id, opened_at, access_count")
.not("order_group_id", "is", null)
.not("opened_at", "is", null);
if (!invGroupErr && invWithGroups) {
const openedGroupIds = new Set(invWithGroups.map(i => i.order_group_id));
setOpenedGroupIds(openedGroupIds);
}
// Load queue detail for active campaign (always load — needed for preview + queue view)
if (viewMode === "queue" || showSmsPreview) {
let qQuery = supabase
.from("order_groups")
.select("id, group_key, customer_name, customer_phone, notification_status, delivery_status, delivery_link, created_at, next_notification_check_at, sms_sent_at, first_sms_sent_at, second_sms_sent_at, sms_attempts, delivery_type, delivery_date, delivery_time, pickup_date, pickup_time_slot, delivery_address, customer_address, manual_confirmation_at")
.order("created_at", { ascending: true })
.limit(200);
if (activeCampaign === "first_sms") {
qQuery = qQuery.eq("delivery_status", "pending_confirmation").in("notification_status", ["link_ready", "not_started", "sms_sending", "send_failed"]);
} else if (activeCampaign === "second_sms") {
qQuery = qQuery.eq("delivery_status", "pending_confirmation").in("notification_status", ["first_sms_sent", "second_sms_sending"]);
} else if (activeCampaign === "manual") {
// Manual: groups in manual_confirmation_required OR pending with second_sms_sent/manual_required
qQuery = qQuery.in("delivery_status", ["pending_confirmation", "manual_confirmation_required"]).in("notification_status", ["second_sms_sent", "manual_required"]);
} else if (activeCampaign === "paid_storage") {
qQuery = qQuery.eq("delivery_status", "paid_storage").in("notification_status", ["not_started", "paid_storage_sending", "link_ready", "first_sms_sent", "second_sms_sent"]);
}
const { data: qData, error: qErr2 } = await qQuery;
if (qErr2) throw qErr2;
// Fetch delivery invitations for these groups — to show link open tracking
let invitationsMap = {};
if (qData && qData.length > 0) {
const groupIds = qData.map(g => g.id);
const { data: invData, error: invErr } = await supabase
.from("delivery_invitations")
.select("order_group_id, opened_at, access_count, last_accessed_at, confirmed_at, state")
.in("order_group_id", groupIds);
if (!invErr && invData) {
invData.forEach(inv => {
invitationsMap[inv.order_group_id] = inv;
});
}
}
// Merge invitation data into queue items
const qDataWithInv = (qData || []).map(g => ({
...g,
_invitation: invitationsMap[g.id] || null,
}));
setQueueData(qDataWithInv);
}
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;
// Fetch order_groups for these logs to show agreement status
let groupsMap = {};
if (logData && logData.length > 0) {
const groupIds = [...new Set(logData.map(l => l.order_group_id).filter(Boolean))];
if (groupIds.length > 0) {
const { data: groupsData, error: groupsErr } = await supabase
.from("order_groups")
.select("id, delivery_status, delivery_type, delivery_date, delivery_time, pickup_date, pickup_time_slot, delivery_address, customer_address")
.in("id", groupIds);
if (!groupsErr && groupsData) {
groupsData.forEach(og => { groupsMap[og.id] = og; });
}
}
}
// Merge group data into log entries
const logsWithGroups = (logData || []).map(l => ({
...l,
_group: groupsMap[l.order_group_id] || null,
}));
setLogs(logsWithGroups);
if (HAS_SETTINGS.includes(activeCampaign)) {
setSettings(settingsMap[activeCampaign] || null);
} else {
setSettings(null);
}
} catch (e) {
setError(e.message || String(e));
} finally {
setIsLoading(false);
}
}, [activeCampaign, filter, viewMode, showSmsPreview]);
useEffect(() => { setIsLoading(true); loadData(); }, [loadData]);
useEffect(() => {
if (autoRefresh) {
refreshTimer.current = setInterval(() => loadData(), 15000);
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), 3000);
} catch (e) {
setError(`Ошибка сохранения: ${e.message}`);
} finally {
setSavingSettings(false);
}
};
const updateSetting = (key, value) => {
setSettings(prev => prev ? { ...prev, [key]: value } : prev);
};
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;
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));
};
const [togglingTimer, setTogglingTimer] = useState(null);
const toggleTimer = async (campaignKey) => {
const s = allSettings[campaignKey];
if (!s) return;
setTogglingTimer(campaignKey);
const newVal = !(s.timer_active ?? false);
try {
const { error: updateError } = await supabase
.from("sms_campaign_settings")
.update({ timer_active: newVal, updated_at: new Date().toISOString() })
.eq("id", s.id);
if (updateError) throw updateError;
setAllSettings(prev => ({ ...prev, [campaignKey]: { ...prev[campaignKey], timer_active: newVal } }));
if (campaignKey === activeCampaign) setSettings(prev => prev ? { ...prev, timer_active: newVal } : prev);
} catch (e) { setError(`Ошибка: ${e.message}`); }
finally { setTogglingTimer(null); }
};
const [testSending, setTestSending] = useState(null);
const handleTestSend = async (campaignKey) => {
const s = allSettings[campaignKey];
if (!s) return;
if (!s.test_mode) { setError("Тестовая отправка доступна только в тестовом режиме"); return; }
setTestSending(campaignKey);
try {
const { error: updateError } = await supabase
.from("sms_campaign_settings")
.update({ test_send_requested: true, updated_at: new Date().toISOString() })
.eq("id", s.id);
if (updateError) throw updateError;
setTimeout(() => loadData(), 5000);
} catch (e) { setError(`Ошибка: ${e.message}`); }
finally { setTimeout(() => setTestSending(null), 3000); }
};
const [restarting, setRestarting] = useState(null);
const handleRestart = async (campaignKey) => {
// Reset all groups in this campaign to beginning
const s = allSettings[campaignKey];
if (!s) return;
setRestarting(campaignKey);
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;
setTimeout(() => loadData(), 5000);
setTimeout(() => loadData(), 15000);
} catch (e) { setError(`Ошибка: ${e.message}`); }
finally { setTimeout(() => setRestarting(null), 3000); }
};
const handleRunNow = async (campaignKey) => {
// Trigger immediate run via run_requested flag
const s = allSettings[campaignKey];
if (!s) return;
setRunningNow(campaignKey);
try {
const { error: updateError } = await supabase
.from("sms_campaign_settings")
.update({ run_requested: true, updated_at: new Date().toISOString() })
.eq("id", s.id);
if (updateError) throw updateError;
// Quick refresh to show updated status
setTimeout(() => loadData(), 3000);
setTimeout(() => loadData(), 10000);
} catch (e) { setError(`Ошибка: ${e.message}`); }
finally { setTimeout(() => setRunningNow(null), 3000); }
};
const handleRecheck = async (logId) => {
setCheckingIds(prev => new Set([...prev, logId]));
try {
const resp = await fetch(`${supabaseUrl}/functions/v1/check-sms-status`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${supabaseAnonKey}`,
"apikey": supabaseAnonKey,
},
body: JSON.stringify({ log_id: logId }),
});
const data = await resp.json();
if (!resp.ok || data.error) throw new Error(data.error || `HTTP ${resp.status}`);
setLogs(prev => prev.map(l => {
if (l.id !== logId) return l;
return {
...l,
status: data.status,
sms_code: data.code,
needs_check: false,
checked_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
}));
} catch (e) { setError(`Ошибка проверки: ${e.message}`); }
finally { setTimeout(() => setCheckingIds(prev => { const n = new Set(prev); n.delete(logId); return n; }), 1000); }
};
const handleDeleteLogs = async (ids) => {
if (!ids || ids.length === 0) return;
setDeleting(true);
try {
const { error } = await supabase
.from("sms_campaign_log")
.delete()
.in("id", ids);
if (error) throw error;
setLogs(prev => prev.filter(l => !ids.includes(l.id)));
setSelectedIds(new Set());
} catch (e) {
setError(`Ошибка удаления: ${e.message}`);
} finally {
setDeleting(false);
}
};
const handleDeleteTestLogs = async () => {
const testIds = logs.filter(l => l.was_test_mode === true).map(l => l.id);
if (testIds.length === 0) return;
if (!window.confirm(`Удалить ${testIds.length} тестовых записей?`)) return;
await handleDeleteLogs(testIds);
};
const handleDeleteSelected = async () => {
const ids = [...selectedIds];
if (ids.length === 0) return;
if (!window.confirm(`Удалить ${ids.length} записей?`)) return;
await handleDeleteLogs(ids);
};
const toggleSelect = (id) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const toggleSelectAll = () => {
const filtered = logs.filter((entry) => {
const isOpened = openedGroupIds.has(entry.order_group_id);
return passesDateFilter(entry.created_at, dateFilter) && passesLinkFilter(isOpened, linkFilter);
});
const allIds = filtered.map(l => l.id);
const allSelected = allIds.length > 0 && allIds.every(id => selectedIds.has(id));
setSelectedIds(allSelected ? new Set() : new Set(allIds));
};
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_CAMPAIGN;
const isPaidStorageCampaign = activeCampaign === "paid_storage";
const showSmsFields = !isManualCampaign;
const getRunnerStatus = (s, lastSmsAt, queueCnt, timerActive) => {
if (!s) return { label: "—", color: "var(--color-text-muted)" };
if (!s.enabled) return { label: "⏸ Остановлена", color: "var(--color-text-muted)" };
// If timer active and queue is 0 — all good, nothing to send
if (timerActive && queueCnt === 0) {
const lastRunDiff = s.last_run_at ? Math.floor((Date.now() - new Date(s.last_run_at).getTime()) / 60000) : null;
if (lastRunDiff !== null && lastRunDiff < 10)
return { label: `✅ Очереди нет (тик ${lastRunDiff}м)`, color: "#22c55e" };
if (lastRunDiff !== null && lastRunDiff < 60)
return { label: `✅ Очереди нет (тик ${lastRunDiff}м назад)`, color: "#22c55e" };
return { label: "✅ Очереди нет", color: "#22c55e" };
}
// If timer active and queue > 0 — waiting for delay, show calm status
if (timerActive && queueCnt > 0) {
if (lastSmsAt) {
const diffMin = Math.floor((Date.now() - new Date(lastSmsAt).getTime()) / 60000);
if (diffMin < 10) return { label: `🟢 SMS ${diffMin}м назад · ${queueCnt} в очереди`, color: "#22c55e" };
if (diffMin < 60) return { label: `⏳ SMS ${diffMin}м назад · ${queueCnt} в очереди`, color: "var(--color-warning)" };
if (diffMin < 1440) return { label: `⏳ SMS ${Math.floor(diffMin/60)}ч назад · ${queueCnt} ждут`, color: "var(--color-warning)" };
return { label: `${queueCnt} в очереди · SMS ${Math.floor(diffMin/1440)}д назад`, color: "var(--color-warning)" };
}
// No SMS in log but queue has items — waiting to send
const lastRunDiff = s.last_run_at ? Math.floor((Date.now() - new Date(s.last_run_at).getTime()) / 60000) : null;
if (lastRunDiff !== null && lastRunDiff < 10)
return { label: `🟢 Запуск ${lastRunDiff}м назад · ${queueCnt} ждут`, color: "#22c55e" };
return { label: `${queueCnt} в очереди · ждём задержку`, color: "var(--color-warning)" };
}
// Timer NOT active — show warning
if (!timerActive) return { label: "⏸ Таймер выключен", color: "var(--color-danger)" };
// Fallback
if (lastSmsAt) {
const diffMin = Math.floor((Date.now() - new Date(lastSmsAt).getTime()) / 60000);
if (diffMin < 10) return { label: `🟢 SMS ${diffMin}м назад`, color: "#22c55e" };
if (diffMin < 60) return { label: `🟡 SMS ${diffMin}м назад`, color: "var(--color-warning)" };
return { label: `🔴 SMS ${Math.floor(diffMin/1440)}д назад`, color: "var(--color-danger)" };
}
return { label: "⚠️ Не запускалась", color: "var(--color-danger)" };
};
const balance = allSettings.first_sms?.last_balance
?? allSettings.second_sms?.last_balance
?? allSettings.paid_storage?.last_balance ?? null;
if (isLoading && logs.length === 0 && viewMode === "logs") {
return (
<Panel className="p-6">
<div className="flex items-center gap-3 animate-pulse">
<span className="text-lg"></span>
<span className="text-sm text-[var(--color-text-muted)]">Загрузка данных кампании</span>
</div>
</Panel>
);
}
// Queue table column definitions
const queueCols = [
{ label: "Клиент", width: "minmax(130px,1.5fr)" },
{ label: "Телефон", width: "minmax(120px,1fr)" },
{ label: "Статус SMS", width: "minmax(110px,1fr)" },
{ label: "Согласование", width: "minmax(150px,1.3fr)" },
{ label: "Добавлен", width: "minmax(110px,0.9fr)" },
{ label: activeCampaign === "manual" ? "⏰ Перевод в ручное" : "Следующая отправка", width: "minmax(120px,1fr)" },
{ label: "Следующая проверка", width: "minmax(110px,0.9fr)" },
{ label: "Ссылка открыта", width: "minmax(100px,0.9fr)" },
];
// Log table column definitions
const logCols = [
{ label: "☐", width: "minmax(40px,0.3fr)" },
{ label: "Клиент", width: "minmax(140px,1.5fr)" },
{ label: "Статус SMS", width: "minmax(130px,1.2fr)" },
{ label: "Согласование", width: "minmax(150px,1.3fr)" },
{ label: "Отправлено", width: "minmax(100px,0.9fr)" },
{ label: "Проверено", width: "minmax(120px,1fr)" },
{ label: "Ссылка", width: "minmax(90px,0.8fr)" },
{ label: "Действие", width: "minmax(100px,0.8fr)" },
];
return (
<div className="space-y-4">
{/* ── Balance banner ─────────────────────────────────────────────────── */}
<Panel className="p-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex items-center gap-3">
<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-xs uppercase tracking-wide text-[var(--color-text-muted)]">Баланс sms.ru</div>
<div className="text-xl font-bold text-[var(--color-text)]">
{balance != null ? `${Number(balance).toLocaleString("ru-RU")}` : "—"}
</div>
</div>
</div>
<div className="flex items-center gap-3">
<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-xs uppercase tracking-wide text-[var(--color-text-muted)]">SMS отправлено</div>
<div className="text-xl font-bold text-[var(--color-text)]">
{Object.values(campaignCounts).reduce((a, c) => a + (c?.total || 0), 0)}
</div>
</div>
</div>
{linkStats.total > 0 ? (
<div className="flex items-center gap-3">
<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-xs uppercase tracking-wide text-[var(--color-text-muted)]">Открыли ссылку</div>
<div className="text-xl font-bold text-[var(--color-text)]">
{linkStats.opened} / {linkStats.total}
</div>
</div>
</div>
) : null}
</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 timerActive = s?.timer_active ?? false;
const cnt = campaignCounts[c.key] || { total: 0, delivered: 0, sent: 0, errors: 0 };
const queueCnt = queueCounts[c.key] || 0;
const runner = getRunnerStatus(s, lastSmsByCampaign[c.key], queueCnt, timerActive);
const isToggling = togglingCampaign === c.key;
const isTimerToggling = togglingTimer === c.key;
const isTestSending = testSending === c.key;
const isManual = c.key === MANUAL_CAMPAIGN;
return (
<div
key={c.key}
className={`rounded-2xl border p-4 transition-all duration-200 cursor-pointer ${
isActive
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] shadow-sm"
: "border-[var(--color-border)] bg-[var(--color-surface)] hover:bg-[var(--color-surface-strong)] hover:shadow-sm hover:border-[var(--color-text-muted)]"
}`}
onClick={() => { setActiveCampaign(c.key); setFilter("all"); setViewMode(c.key === "manual" ? "queue" : "logs"); }}
>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<span className="text-2xl">{c.icon}</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>
<ToggleSwitch
on={isEnabled}
onClick={() => toggleCampaignEnabled(c.key)}
disabled={isToggling}
size="md"
/>
</div>
{/* Status row */}
<div className="mt-3 flex flex-wrap items-center gap-2">
<span
className="rounded-full border px-2.5 py-1 text-xs font-semibold transition"
style={{
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",
}}
>
{!isEnabled ? "⏸ Выкл" : isManual ? "🔧 Ручное" : isTest ? "🧪 Тест" : "🚀 Боевой"}
</span>
<span className="text-xs font-medium" style={{ color: runner.color }}>{runner.label}</span>
</div>
{/* Timer status bar */}
<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
type="button"
onClick={() => toggleTimer(c.key)}
disabled={isTimerToggling}
className={`flex items-center gap-2 rounded-xl px-4 py-2 text-xs font-medium transition-all duration-200 ${
timerActive
? "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)] border border-[var(--color-border)]"
}`}
>
<MiniToggle on={timerActive} onClick={() => {}} disabled={isTimerToggling} />
Таймер
</button>
{!isManual && isTest && (
<CardButton
onClick={() => handleTestSend(c.key)}
disabled={isTestSending}
tone="warning"
>
{isTestSending ? "…" : "🧪 Тест"}
</CardButton>
)}
<CardButton
onClick={() => handleRunNow(c.key)}
disabled={runningNow === c.key}
tone="accent"
>
{runningNow === c.key ? "…" : "▶ Запустить"}
</CardButton>
<CardButton
onClick={() => handleRestart(c.key)}
disabled={restarting === c.key}
tone="danger"
>
{restarting === c.key ? "…" : "🔄 Сброс"}
</CardButton>
</div>
{/* Stats grid — 2×2 KPI tiles (real only, test excluded) */}
<div className="mt-3 grid grid-cols-2 gap-1.5">
<KpiTile icon="⏳" value={queueCnt} label="В очереди" color="var(--color-warning)" />
<KpiTile icon="📤" value={cnt.sent + cnt.delivered} label="Отправлено" color="var(--color-text)" />
<KpiTile icon="✅" value={cnt.delivered} label="Доставлено" color="#22c55e" />
<KpiTile
icon="📊"
value={cnt.total > 0 ? `${Math.round((cnt.delivered / cnt.total) * 100)}%` : "—"}
label="% доставки"
color={cnt.total > 0 && cnt.delivered / cnt.total >= 0.8 ? "#22c55e" : cnt.total > 0 && cnt.delivered / cnt.total >= 0.5 ? "var(--color-warning)" : "var(--color-danger)"}
/>
</div>
</div>
);
})}
</div>
{error && (
<Panel className="p-4">
<div className="flex items-center justify-between">
<div className="text-xs text-[var(--color-danger)]"> {error}</div>
<button
onClick={() => { setError(null); loadData(); }}
className="rounded-full px-3 py-1 text-xs font-medium text-[var(--color-accent)] hover:bg-[var(--color-accent-soft)] transition"
>
Повторить
</button>
</div>
</Panel>
)}
{/* ── View mode toggle ──────────────────────────────────────────────── */}
<div className="flex gap-2">
<FilterButton active={viewMode === "logs"} onClick={() => setViewMode("logs")}>📋 Журнал отправок</FilterButton>
<FilterButton active={viewMode === "queue"} onClick={() => setViewMode("queue")}> Очередь отправки</FilterButton>
</div>
{/* ── Queue view ────────────────────────────────────────────────────── */}
{viewMode === "queue" && (
<>
<Panel className="p-0">
{/* Date + link filters */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-[10px] uppercase tracking-wide text-[var(--color-text-muted)]">📅 Добавлен:</span>
{[
{ v: "all", l: "Все" },
{ v: "today", l: "Сегодня" },
{ v: "yesterday", l: "Вчера" },
{ v: "7d", l: "7 дней" },
].map(opt => (
<FilterButton key={opt.v} active={dateFilter === opt.v} onClick={() => setDateFilter(opt.v)}>{opt.l}</FilterButton>
))}
<span className="ml-3 text-[10px] uppercase tracking-wide text-[var(--color-text-muted)]">🔗 Ссылка:</span>
{[
{ v: "all", l: "Все" },
{ v: "opened", l: "Открыта" },
{ v: "not_opened", l: "Не открыта" },
].map(opt => (
<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>
{/* Desktop table */}
<div className="hidden sm:block overflow-x-auto">
<div className="min-w-[1100px]">
<TableHeader cols={queueCols} />
{queueData.length === 0 ? (
<div className="px-4 py-8 text-center text-xs text-[var(--color-text-muted)]">
Очередь пуста нет групп, ожидающих отправки SMS
</div>
) : (
queueData.filter((g) => {
const inv = g._invitation;
const opened = inv && inv.opened_at;
return passesDateFilter(g.created_at, dateFilter) && passesLinkFilter(opened, linkFilter) && passesSmsFilter(g, smsFilter);
}).slice((queuePage - 1) * PAGE_SIZE, queuePage * PAGE_SIZE).map((g) => {
const nextSend = calcNextSendTime(g, settings, activeCampaign);
const inv = g._invitation;
const opened = inv && inv.opened_at;
const accessCount = inv?.access_count || 0;
const manualTransfer = activeCampaign === "manual" && g.second_sms_sent_at
? new Date(new Date(g.second_sms_sent_at).getTime() + (settings?.auto_manual_after_hours ?? 3) * 3600000)
: null;
const isOverdue = manualTransfer && manualTransfer.getTime() < Date.now();
return (
<TableRow
key={g.id}
cols={queueCols}
onClick={() => handleOpenGroup(g.id)}
>
<div className="px-4 py-2.5 text-[var(--color-text)] font-medium">
{g.customer_name || g.group_key || "—"}
</div>
<div className="px-4 py-2.5 text-[var(--color-text-muted)]">{fmtPhone(g.customer_phone)}</div>
<div className="px-4 py-2.5">
<Badge tone={NOTIF_TONES[g.notification_status] || "neutral"}>
{NOTIF_LABELS[g.notification_status] || g.notification_status || "—"}
</Badge>
{g.sms_attempts > 0 && <div className="mt-0.5 text-[10px] text-[var(--color-text-muted)]">попыток: {g.sms_attempts}</div>}
</div>
<div className="px-4 py-2.5">
{(() => { const a = fmtAgreement(g); return (
<span className="text-xs font-medium" style={{ color: AGREEMENT_TONES[a.tone] }}>{a.text}</span>
); })()}
</div>
<div className="px-4 py-2.5 text-[var(--color-text-muted)]">
<div className="text-[10px] uppercase tracking-wide mb-0.5">в базу</div>
<div className="text-[var(--color-text)] text-xs">{fmtTimeShort(g.created_at)}</div>
<div className="text-[10px]">{fmtElapsed(g.created_at)}</div>
{g.sms_sent_at && (
<>
<div className="text-[10px] uppercase tracking-wide mt-1.5 mb-0.5">SMS старт</div>
<div className="text-[var(--color-accent)] text-xs">{fmtTimeShort(g.sms_sent_at)}</div>
<div className="text-[10px]">{fmtElapsed(g.sms_sent_at)}</div>
</>
)}
</div>
<div className="px-4 py-2.5">
{activeCampaign === "manual" ? (
manualTransfer ? (
<div>
<div className="text-[var(--color-text-muted)] text-[10px] mb-0.5">2-я SMS: {fmtTimeShort(g.second_sms_sent_at)}</div>
<div className="font-medium" style={{ color: isOverdue ? "var(--color-danger)" : "var(--color-warning)" }}>
{fmtTimeShort(manualTransfer.toISOString())}
</div>
<div className="text-[10px]" style={{ color: isOverdue ? "var(--color-danger)" : "var(--color-text-muted)" }}>
{isOverdue ? "⏰ просрочено!" : fmtCountdown(manualTransfer.toISOString())}
</div>
{g.manual_confirmation_at && (
<div className="mt-1 text-[10px] text-[var(--color-accent)]">
🔧 В ручном: {fmtTimeShort(g.manual_confirmation_at)}
</div>
)}
</div>
) : g.manual_confirmation_at ? (
<div>
<div className="text-[var(--color-accent)] font-medium text-xs">🔧 В ручном</div>
<div className="text-[10px] text-[var(--color-text-muted)]">{fmtTimeShort(g.manual_confirmation_at)}</div>
<div className="text-[10px] text-[var(--color-text-muted)]">{fmtElapsed(g.manual_confirmation_at)}</div>
</div>
) : (
<span className="text-[var(--color-text-muted)]"></span>
)
) : nextSend ? (
<div>
<div className="text-[var(--color-accent)] font-medium">{fmtTimeShort(nextSend)}</div>
<div className="text-[10px] text-[var(--color-text-muted)]">{fmtCountdown(nextSend)}</div>
</div>
) : (
<span className="text-[var(--color-text-muted)]"></span>
)}
</div>
<div className="px-4 py-2.5 text-[var(--color-text-muted)]">
{fmtTimeShort(g.next_notification_check_at)}
{g.next_notification_check_at && <div className="text-[10px]">{fmtCountdown(g.next_notification_check_at)}</div>}
</div>
<div className="px-4 py-2.5">
{opened ? (
<div>
<span className="font-medium" style={{ color: "#22c55e" }}> Открыта</span>
{inv.last_accessed_at && (
<div className="text-[10px] text-[var(--color-text-muted)]">
{accessCount > 0 ? `${accessCount}× ` : ""}{fmtElapsed(inv.last_accessed_at)}
</div>
)}
</div>
) : inv ? (
<span className="text-[var(--color-text-muted)]"> не открыта</span>
) : (
<span className="text-[var(--color-text-muted)]"> нет ссылки</span>
)}
</div>
</TableRow>
);
})
)}
</div>
</div>
{/* Mobile cards */}
<div className="sm:hidden p-2">
{queueData.length === 0 ? (
<div className="px-4 py-8 text-center text-xs text-[var(--color-text-muted)]">
Очередь пуста нет групп, ожидающих отправки SMS
</div>
) : (
queueData.filter((g) => {
const inv = g._invitation;
const opened = inv && inv.opened_at;
return passesDateFilter(g.created_at, dateFilter) && passesLinkFilter(opened, linkFilter) && passesSmsFilter(g, smsFilter);
}).map((g) => {
const nextSend = calcNextSendTime(g, settings, activeCampaign);
const inv = g._invitation;
const opened = inv && inv.opened_at;
const accessCount = inv?.access_count || 0;
const manualTransfer = activeCampaign === "manual" && g.second_sms_sent_at
? new Date(new Date(g.second_sms_sent_at).getTime() + (settings?.auto_manual_after_hours ?? 3) * 3600000)
: null;
const isOverdue = manualTransfer && manualTransfer.getTime() < Date.now();
return (
<MobileCard key={g.id} onClick={() => handleOpenGroup(g.id)}>
{/* Header: name + phone */}
<div className="flex items-center justify-between mb-2">
<div className="text-sm font-semibold text-[var(--color-text)] truncate">
{g.customer_name || g.group_key || "—"}
</div>
<Badge tone={NOTIF_TONES[g.notification_status] || "neutral"}>
{NOTIF_LABELS[g.notification_status] || g.notification_status || "—"}
</Badge>
</div>
<div className="text-xs text-[var(--color-text-muted)] mb-2">{fmtPhone(g.customer_phone)}</div>
{/* Info rows */}
<div className="border-t border-[var(--color-border)] pt-2">
{(() => { const a = fmtAgreement(g); return (
<MobileRow label="Согласование" value={a.text} color={AGREEMENT_TONES[a.tone]} />
); })()}
<MobileRow
label="Добавлен"
value={fmtTimeShort(g.created_at)}
/>
{g.sms_sent_at && (
<MobileRow
label="SMS старт"
value={fmtTimeShort(g.sms_sent_at)}
color="var(--color-accent)"
/>
)}
{activeCampaign === "manual" && manualTransfer ? (
<MobileRow
label="⏰ Перевод в ручное"
value={`${fmtTimeShort(manualTransfer.toISOString())} ${isOverdue ? "⚠ просрочено!" : fmtCountdown(manualTransfer.toISOString())}`}
color={isOverdue ? "var(--color-danger)" : "var(--color-warning)"}
/>
) : nextSend ? (
<MobileRow
label="Отправка"
value={`${fmtTimeShort(nextSend)} (${fmtCountdown(nextSend)})`}
color="var(--color-accent)"
/>
) : null}
{g.next_notification_check_at && (
<MobileRow
label="Проверка"
value={`${fmtTimeShort(g.next_notification_check_at)} (${fmtCountdown(g.next_notification_check_at)})`}
/>
)}
{g.sms_attempts > 0 && (
<MobileRow label="Попыток" value={String(g.sms_attempts)} />
)}
<MobileRow
label="Ссылка"
value={opened ? `✅ Открыта${accessCount > 0 ? ` ${accessCount}×` : ""}${inv?.last_accessed_at ? ` ${fmtElapsed(inv.last_accessed_at)}` : ""}` : inv ? "— не открыта" : "— нет ссылки"}
color={opened ? "#22c55e" : "var(--color-text-muted)"}
/>
</div>
</MobileCard>
);
})
)}
</div>
</Panel>
{(() => {
const filteredQueue = queueData.filter((g) => {
const inv = g._invitation;
const opened = inv && inv.opened_at;
return passesDateFilter(g.created_at, dateFilter) && passesLinkFilter(opened, linkFilter) && passesSmsFilter(g, smsFilter);
});
const totalPages = Math.ceil(filteredQueue.length / PAGE_SIZE);
if (totalPages <= 1) return null;
return <Pagination page={queuePage} totalPages={totalPages} onChange={setQueuePage} itemsPerPage={PAGE_SIZE} totalItems={filteredQueue.length} />;
})()}
</>
)}
{/* ── Logs view ─────────────────────────────────────────────────────── */}
{viewMode === "logs" && (
<>
<SmsCampaignStats campaignType={activeCampaign} />
{showSettings && settings && (
<div className="space-y-3">
<button
type="button"
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-4 text-left transition hover:bg-[var(--color-surface-strong)]"
>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[var(--color-text)]">
{CAMPAIGNS.find(c => c.key === activeCampaign)?.icon} Настройки кампании
</span>
{settings.test_mode
? <span className="text-[10px] rounded-full bg-[var(--color-warning-soft)] px-2 py-0.5 font-medium text-[var(--color-warning)]">ТЕСТ</span>
: <span className="text-[10px] rounded-full bg-[var(--color-accent-soft)] px-2 py-0.5 font-medium text-[var(--color-accent)]">БОЙ</span>
}
</div>
<div className="flex items-center gap-3">
{settingsSaved && showSettingsPanel && (
<span className="text-[10px] text-[var(--color-accent)]"> Сохранено</span>
)}
<svg
className="h-4 w-4 text-[var(--color-text-muted)] transition-transform"
style={{ transform: showSettingsPanel ? "rotate(180deg)" : "rotate(0deg)" }}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
{showSettingsPanel && (
<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="rounded-full bg-[var(--color-accent-soft)] px-3 py-1 text-xs font-medium text-[var(--color-accent)]">
Сохранено
</span>
)}
</div>
{/* Test/Battle mode toggle */}
{!isManualCampaign && (
<div className={`mb-4 rounded-2xl border p-4 transition ${settings.test_mode ? "border-[var(--color-warning)] bg-[var(--color-accent-soft)]" : "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>
<ToggleSwitch
on={!settings.test_mode}
onClick={() => updateSetting("test_mode", !settings.test_mode)}
size="md"
/>
</label>
{settings.test_mode && (
<div className="mt-3">
<SettingField label="Тестовый номер" value={settings.test_phone || ""} onChange={(v) => updateSetting("test_phone", v)} />
</div>
)}
</div>
)}
{/* SMS text editor */}
{!isManualCampaign && (
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<label className="text-[10px] font-semibold uppercase tracking-wide text-[var(--color-text-muted)]">
Текст SMS (используйте {"{link}"} для ссылки)
</label>
<button
type="button"
onClick={() => setShowSmsPreview(!showSmsPreview)}
className="rounded-full px-2.5 py-1 text-[10px] font-medium text-[var(--color-accent)] hover:bg-[var(--color-accent-soft)] transition"
>
{showSmsPreview ? "Скрыть предпросмотр" : "👁 Предпросмотр"}
</button>
</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.5 text-xs text-[var(--color-text)] transition focus:border-[var(--color-accent)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent-soft)]"
placeholder="Ваш заказ готов. Согласуйте дату доставки по ссылке: {link}"
/>
<div className="mt-1.5 text-[10px] text-[var(--color-text-muted)]">
{(settings.sms_text_template || "").replace("{link}", "https://dost.supersamsev.ru/d/XXXXX").length} символов
</div>
{showSmsPreview && (
<div className="mt-3 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wide text-[var(--color-text-muted)]">Пример SMS (по первой группе из очереди):</div>
{queueData.length > 0 ? (
<>
<div className="text-[10px] text-[var(--color-text-muted)] mb-2">
Клиент: {queueData[0].customer_name || "—"} {fmtPhone(queueData[0].customer_phone)}
</div>
<div className="rounded-xl border border-[var(--color-border)] bg-white px-3 py-2.5 text-xs text-gray-800" style={{ fontFamily: "monospace", whiteSpace: "pre-wrap", wordBreak: "break-all" }}>
{(settings.sms_text_template || "").replace("{link}", queueData[0].delivery_link || "https://dost.supersamsev.ru/d/XXXXX")}
</div>
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
Длина: {(settings.sms_text_template || "").replace("{link}", queueData[0].delivery_link || "").length} символов
{(settings.sms_text_template || "").replace("{link}", queueData[0].delivery_link || "").length > 160 && " ⚠️ >160 символов = 2 SMS"}
</div>
</>
) : (
<>
<div className="text-[10px] text-[var(--color-text-muted)] mb-2">Нет групп в очереди пример с тестовой ссылкой:</div>
<div className="rounded-xl border border-[var(--color-border)] bg-white px-3 py-2.5 text-xs text-gray-800" style={{ fontFamily: "monospace", whiteSpace: "pre-wrap", wordBreak: "break-all" }}>
{(settings.sms_text_template || "").replace("{link}", "https://dost.supersamsev.ru/delivery/abc123example")}
</div>
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
Длина: {(settings.sms_text_template || "").replace("{link}", "https://dost.supersamsev.ru/delivery/abc123example").length} символов
</div>
</>
)}
</div>
)}
</div>
)}
{/* Intervals and attempts */}
<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)} />
<SettingField label="Пауза между SMS (сек)" value={settings.send_interval_seconds} onChange={(v) => updateSetting("send_interval_seconds", parseInt(v) || 15)} />
{!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>
{/* Work hours / days */}
<div className="mt-5 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-4">
<div className="mb-3 text-xs font-semibold text-[var(--color-text)]"> Время отправки SMS</div>
<div className="mb-4 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-strong)] px-3 py-2 text-xs text-[var(--color-text)] transition 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-strong)] px-3 py-2 text-xs text-[var(--color-text)] transition 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-xl px-3.5 py-2 text-xs font-medium transition-all duration-200 ${
isOn
? "bg-[var(--color-accent)] text-white shadow-sm"
: "border border-[var(--color-border)] bg-[var(--color-surface-strong)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
}`}
>
{d.short}
</button>
);
})}
</div>
<div className="mt-3 text-[10px] text-[var(--color-text-muted)]">
Проверка статусов работает круглосуточно. Отправка только в выбранные часы и дни.
</div>
</div>
{/* Footer: enable toggle + save */}
<div className="mt-5 flex items-center gap-3">
<ToggleSwitch
on={settings.enabled ?? true}
onClick={() => updateSetting("enabled", !(settings.enabled ?? true))}
size="md"
/>
<span className="text-xs font-medium text-[var(--color-text)]">
{settings.enabled ? "Кампания включена" : "Кампания выключена"}
</span>
<button
onClick={handleSaveSettings}
disabled={savingSettings}
className={`ml-auto rounded-full px-5 py-2 text-xs font-semibold transition-all duration-200 ${
settingsSaved
? "bg-[#22c55e] text-white shadow-sm"
: "bg-[var(--color-accent)] text-white hover:opacity-90 shadow-sm"
} disabled:opacity-50`}
>
{savingSettings ? "Сохранение…" : settingsSaved ? "✓ Сохранено" : "Сохранить"}
</button>
</div>
</Panel>
)}
</div>
)}
{/* Stats summary */}
<Panel className="p-4">
<div className="flex flex-wrap items-center gap-2">
<span className="mr-1 text-xs font-semibold text-[var(--color-text)]">Всего: {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="space-y-2">
<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-2 text-xs text-[var(--color-text-muted)] cursor-pointer">
<ToggleSwitch
on={autoRefresh}
onClick={() => setAutoRefresh(!autoRefresh)}
size="sm"
/>
Авто-обновление (15с)
</label>
</div>
{/* Date + link filters */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-[10px] uppercase tracking-wide text-[var(--color-text-muted)]">📅 Дата:</span>
{[
{ v: "all", l: "Все" },
{ v: "today", l: "Сегодня" },
{ v: "yesterday", l: "Вчера" },
{ v: "7d", l: "7 дней" },
].map(opt => (
<FilterButton key={opt.v} active={dateFilter === opt.v} onClick={() => setDateFilter(opt.v)}>{opt.l}</FilterButton>
))}
<span className="ml-3 text-[10px] uppercase tracking-wide text-[var(--color-text-muted)]">🔗 Ссылка:</span>
{[
{ v: "all", l: "Все" },
{ v: "opened", l: "Открыта" },
{ v: "not_opened", l: "Не открыта" },
].map(opt => (
<FilterButton key={opt.v} active={linkFilter === opt.v} onClick={() => setLinkFilter(opt.v)}>{opt.l}</FilterButton>
))}
</div>
</div>
{/* ── Delete toolbar ── */}
<div className="flex flex-wrap items-center gap-2">
<FilterButton active={false} onClick={toggleSelectAll}>
{(() => {
const filtered = logs.filter((entry) => {
const isOpened = openedGroupIds.has(entry.order_group_id);
return passesDateFilter(entry.created_at, dateFilter) && passesLinkFilter(isOpened, linkFilter);
});
const allIds = filtered.map(l => l.id);
const allSelected = allIds.length > 0 && allIds.every(id => selectedIds.has(id));
return allSelected ? "✓ Снять выделение" : "☐ Выбрать все";
})()}
</FilterButton>
{selectedIds.size > 0 && (
<button
onClick={handleDeleteSelected}
disabled={deleting}
className="rounded-full border border-[var(--color-danger)] px-3.5 py-1.5 text-xs font-medium text-[var(--color-danger)] transition hover:bg-[var(--color-danger)] hover:text-white disabled:opacity-50"
>
{deleting ? "Удаление…" : `🗑 Удалить (${selectedIds.size})`}
</button>
)}
{logs.some(l => l.was_test_mode === true) && (
<button
onClick={handleDeleteTestLogs}
disabled={deleting}
className="rounded-full border border-[var(--color-warning)] px-3.5 py-1.5 text-xs font-medium text-[var(--color-warning)] transition hover:bg-[var(--color-warning)] hover:text-white disabled:opacity-50"
>
🧪 Удалить все тестовые ({logs.filter(l => l.was_test_mode === true).length})
</button>
)}
{selectedIds.size > 0 && (
<span className="text-xs text-[var(--color-text-muted)]">Выбрано: {selectedIds.size}</span>
)}
</div>
{/* ── Log table ── */}
<Panel className="p-0">
{/* Desktop table */}
<div className="hidden sm:block overflow-x-auto">
<div className="min-w-[1000px]">
<TableHeader cols={logCols} />
{logs.length === 0 ? (
<div className="px-4 py-8 text-center text-xs text-[var(--color-text-muted)]">Нет записей</div>
) : (
logs.filter((entry) => {
const isOpened = openedGroupIds.has(entry.order_group_id);
return passesDateFilter(entry.created_at, dateFilter) && passesLinkFilter(isOpened, linkFilter);
}).slice((logPage - 1) * PAGE_SIZE, logPage * PAGE_SIZE).map((entry) => {
const canRecheck = entry.status === "sent" || entry.status === "checking";
const isChecking = checkingIds.has(entry.id);
const wasTest = entry.was_test_mode === true;
const isReplaced = entry.sent_to && entry.sent_to !== entry.customer_phone;
const isOpened = openedGroupIds.has(entry.order_group_id);
return (
<TableRow
key={entry.id}
cols={logCols}
onClick={() => handleOpenGroup(entry.order_group_id)}
>
<div className="px-4 py-2.5" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(entry.id)}
onChange={() => toggleSelect(entry.id)}
className="h-4 w-4 cursor-pointer rounded border-[var(--color-border)] accent-[var(--color-accent)]"
/>
</div>
<div className="px-4 py-2.5 text-[var(--color-text)]">
<div className="font-medium">{fmtPhone(entry.customer_phone)}</div>
{wasTest
? <div className="text-[10px] text-[var(--color-warning)]">🧪 тестовая</div>
: <div className="text-[10px]" style={{ color: "#22c55e" }}>🚀 боевая</div>
}
{isReplaced && <div className="text-[10px] text-[var(--color-warning)]"> {fmtPhone(entry.sent_to)}</div>}
</div>
<div className="px-4 py-2.5">
<Badge tone={STATUS_TONES[entry.status] || "neutral"}>
{STATUS_ICONS[entry.status] || ""} {STATUS_LABELS[entry.status] || entry.status}
</Badge>
{entry.attempts > 1 && <div className="mt-0.5 text-[10px] text-[var(--color-text-muted)]">попытка {entry.attempts}</div>}
{entry.sms_code && SMS_CODE_LABELS[entry.sms_code] && (
<div className="mt-0.5 text-[10px] text-[var(--color-text-muted)]" title={`Код: ${entry.sms_code}`}>
{entry.sms_code}: {SMS_CODE_LABELS[entry.sms_code]}
</div>
)}
</div>
<div className="px-4 py-2.5">
{(() => { const a = fmtAgreement(entry._group); return (
<span className="text-xs font-medium" style={{ color: AGREEMENT_TONES[a.tone] }}>{a.text}</span>
); })()}
</div>
<div className="px-4 py-2.5 text-[var(--color-text-muted)]">
{fmtTime(entry.created_at)}
<div className="text-[10px]">{fmtElapsed(entry.created_at)}</div>
</div>
<div className="px-4 py-2.5 text-[var(--color-text-muted)]">
{entry.checked_at ? (
<div>
<div className="text-[var(--color-text)]">{fmtTime(entry.checked_at)}</div>
<div className="text-[10px]">{fmtElapsed(entry.checked_at)}</div>
</div>
) : (
<span className="text-[var(--color-text-muted)]"> не проверено</span>
)}
{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-4 py-2.5">
{isOpened
? <span className="font-medium" style={{ color: "#22c55e" }}> Открыта</span>
: <span className="text-[var(--color-text-muted)]"> нет</span>
}
</div>
<div className="px-4 py-2.5" onClick={(e) => e.stopPropagation()}>
{canRecheck ? (
<button
onClick={() => handleRecheck(entry.id)}
disabled={isChecking}
className="rounded-xl border border-[var(--color-border)] px-2.5 py-1.5 text-[11px] font-medium text-[var(--color-text)] transition hover:bg-[var(--color-surface-strong)] hover:border-[var(--color-accent)] disabled:opacity-40"
>
{isChecking ? "⏳ Проверка…" : "↻ Проверить"}
</button>
) : (
<span className="text-[10px] text-[var(--color-text-muted)]"> к доставке</span>
)}
</div>
</TableRow>
);
})
)}
</div>
</div>
{/* Mobile cards */}
<div className="sm:hidden p-2">
{logs.length === 0 ? (
<div className="px-4 py-8 text-center text-xs text-[var(--color-text-muted)]">Нет записей</div>
) : (
logs.filter((entry) => {
const isOpened = openedGroupIds.has(entry.order_group_id);
return passesDateFilter(entry.created_at, dateFilter) && passesLinkFilter(isOpened, linkFilter);
}).slice((logPage - 1) * PAGE_SIZE, logPage * PAGE_SIZE).map((entry) => {
const canRecheck = entry.status === "sent" || entry.status === "checking";
const isChecking = checkingIds.has(entry.id);
const wasTest = entry.was_test_mode === true;
const isReplaced = entry.sent_to && entry.sent_to !== entry.customer_phone;
const isOpened = openedGroupIds.has(entry.order_group_id);
return (
<MobileCard key={entry.id} onClick={() => handleOpenGroup(entry.order_group_id)}>
{/* Header: checkbox + phone + status badge */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={selectedIds.has(entry.id)}
onChange={() => toggleSelect(entry.id)}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4 cursor-pointer rounded border-[var(--color-border)] accent-[var(--color-accent)]"
/>
<div className="text-sm font-semibold text-[var(--color-text)]">
{fmtPhone(entry.customer_phone)}
</div>
</div>
<Badge tone={STATUS_TONES[entry.status] || "neutral"}>
{STATUS_ICONS[entry.status] || ""} {STATUS_LABELS[entry.status] || entry.status}
</Badge>
</div>
{/* Test/real badge */}
<div className="mb-2">
{wasTest
? <span className="text-[10px] text-[var(--color-warning)]">🧪 тестовая</span>
: <span className="text-[10px]" style={{ color: "#22c55e" }}>🚀 боевая</span>
}
{isReplaced && <span className="text-[10px] text-[var(--color-warning)] ml-2"> {fmtPhone(entry.sent_to)}</span>}
</div>
{/* Info rows */}
<div className="border-t border-[var(--color-border)] pt-2">
{(() => { const a = fmtAgreement(entry._group); return (
<MobileRow label="Согласование" value={a.text} color={AGREEMENT_TONES[a.tone]} />
); })()}
{entry.sms_code && SMS_CODE_LABELS[entry.sms_code] && (
<MobileRow label="Код SMS" value={`${entry.sms_code}: ${SMS_CODE_LABELS[entry.sms_code]}`} />
)}
<MobileRow label="Отправлено" value={`${fmtTime(entry.created_at)} (${fmtElapsed(entry.created_at)})`} />
<MobileRow
label="Проверено"
value={entry.checked_at
? `${fmtTime(entry.checked_at)} (${fmtElapsed(entry.checked_at)})`
: null
? ""
: "— не проверено"
}
color="var(--color-text)"
/>
<MobileRow
label="Ссылка"
value={isOpened ? "✅ Открыта" : "— нет"}
color={isOpened ? "#22c55e" : "var(--color-text-muted)"}
/>
{entry.error_message && (
<div className="mt-1 text-[10px] text-[var(--color-danger)]"> {entry.error_message.slice(0, 80)}</div>
)}
</div>
{/* Action button */}
{canRecheck && (
<div className="mt-2 flex justify-end" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => handleRecheck(entry.id)}
disabled={isChecking}
className="rounded-xl border border-[var(--color-border)] px-3 py-1.5 text-xs font-medium text-[var(--color-text)] transition hover:bg-[var(--color-surface-strong)] hover:border-[var(--color-accent)] disabled:opacity-40"
>
{isChecking ? "⏳ Проверка…" : "↻ Проверить"}
</button>
</div>
)}
</MobileCard>
);
})
)}
</div>
</Panel>
{(() => {
const filteredLogs = logs.filter((entry) => {
const isOpened = openedGroupIds.has(entry.order_group_id);
return passesDateFilter(entry.created_at, dateFilter) && passesLinkFilter(isOpened, linkFilter);
});
const totalPages = Math.ceil(filteredLogs.length / PAGE_SIZE);
if (totalPages <= 1) return null;
return <Pagination page={logPage} totalPages={totalPages} onChange={setLogPage} itemsPerPage={PAGE_SIZE} totalItems={filteredLogs.length} />;
})()}
</>
)}
{/* ── Footer ─────────────────────────────────────────────────────────── */}
<div className="flex justify-end">
<button
onClick={loadData}
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>
</div>
</div>
);
};