1900 lines
98 KiB
JavaScript
1900 lines
98 KiB
JavaScript
/**
|
||
* @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": "✓ Платное хранение: отправлено",
|
||
"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",
|
||
"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;
|
||
|
||
// If client already chose date/time — show it regardless of delivery_status
|
||
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 === "cancelled") {
|
||
return { text: "❌ Отменён", tone: "danger", 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>
|
||
);
|
||
}; |