From cadcceef7e9a8fbcce76a07c8b13e1ddebb4b109 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 30 Jun 2026 06:55:55 +0000 Subject: [PATCH 01/39] =?UTF-8?q?feat:=20manage-users=20edge=20function=20?= =?UTF-8?q?=E2=80=94=20admin=20user=20CRUD=20(create/delete)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- supabase/functions/manage-users/index.ts | 194 +++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 supabase/functions/manage-users/index.ts diff --git a/supabase/functions/manage-users/index.ts b/supabase/functions/manage-users/index.ts new file mode 100644 index 0000000..9121ea6 --- /dev/null +++ b/supabase/functions/manage-users/index.ts @@ -0,0 +1,194 @@ +import { + createServiceClient, + getCorsHeaders, + jsonResponse, + preflightResponse, + readJsonBody, +} from "../_shared/security.ts"; + +const MAX_BODY_BYTES = 8 * 1024; + +const ADMIN_ROLES = new Set(["admin", "mega_admin"]); + +const isValidEmail = (value: string) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()); + +/** + * Verify the caller's JWT and return their role from public.users. + * Returns null if not authorized. + */ +async function getCallerRole(request: Request): Promise { + const authHeader = request.headers.get("authorization") || ""; + const token = authHeader.replace(/^Bearer\s+/i, "").trim(); + if (!token) return null; + + const supabase = createServiceClient(); + const { data: userData, error: userError } = await supabase.auth.getUser(token); + if (userError || !userData?.user) return null; + + const userId = userData.user.id; + const { data: userRow, error: roleError } = await supabase + .from("users") + .select("roles(name)") + .eq("id", userId) + .maybeSingle(); + if (roleError || !userRow) return null; + + return userRow.roles?.name || null; +} + +Deno.serve(async (request) => { + if (request.method === "OPTIONS") { + return preflightResponse(request, "private"); + } + + const corsHeaders = getCorsHeaders(request, "private"); + if (!corsHeaders) { + return jsonResponse({ ok: false, error: "Origin not allowed" }, 403); + } + + try { + // ── Authorization ── + const callerRole = await getCallerRole(request); + if (!callerRole || !ADMIN_ROLES.has(callerRole)) { + return jsonResponse( + { ok: false, error: "Недостаточно прав. Требуется роль admin или mega_admin." }, + 403, + corsHeaders, + ); + } + + const supabase = createServiceClient(); + + // ── POST: create new user ── + if (request.method === "POST") { + const { body } = await readJsonBody<{ email?: string; name?: string; role?: string }>( + request, + { maxBytes: MAX_BODY_BYTES }, + ); + + const email = String(body.email || "").trim().toLowerCase(); + const name = String(body.name || "").trim(); + const role = String(body.role || "").trim().toLowerCase(); + + if (!email || !isValidEmail(email)) { + return jsonResponse({ ok: false, error: "Некорректный email" }, 400, corsHeaders); + } + if (!name) { + return jsonResponse({ ok: false, error: "Имя обязательно" }, 400, corsHeaders); + } + if (!role) { + return jsonResponse({ ok: false, error: "Роль обязательна" }, 400, corsHeaders); + } + + // Resolve role_id from roles table + const { data: roleRow, error: roleErr } = await supabase + .from("roles") + .select("id") + .eq("name", role) + .maybeSingle(); + if (roleErr || !roleRow) { + return jsonResponse({ ok: false, error: "Неизвестная роль: " + role }, 400, corsHeaders); + } + const roleId = roleRow.id; + + // Check if email already exists in public.users + const { data: existingUser } = await supabase + .from("users") + .select("id") + .eq("email", email) + .maybeSingle(); + if (existingUser) { + return jsonResponse({ ok: false, error: "Пользователь с таким email уже существует" }, 409, corsHeaders); + } + + // Create auth user + const { data: authData, error: authError } = await supabase.auth.admin.createUser({ + email, + email_confirm: true, + user_metadata: { name }, + }); + + if (authError) { + console.error("auth.createUser error:", authError); + return jsonResponse( + { ok: false, error: "Ошибка создания auth-пользователя: " + authError.message }, + 500, + corsHeaders, + ); + } + + const newUserId = authData.user.id; + + // Insert into public.users + const { error: usersInsertError } = await supabase.from("users").insert({ + id: newUserId, + email, + name, + role_id: roleId, + }); + + if (usersInsertError) { + console.error("users.insert error:", usersInsertError); + // Rollback auth user + await supabase.auth.admin.deleteUser(newUserId); + return jsonResponse( + { ok: false, error: "Ошибка создания записи пользователя: " + usersInsertError.message }, + 500, + corsHeaders, + ); + } + + return jsonResponse({ ok: true, data: { id: newUserId, email, name, role } }, 201, corsHeaders); + } + + // ── DELETE: remove user ── + if (request.method === "DELETE") { + const url = new URL(request.url); + const userId = url.searchParams.get("id"); + if (!userId) { + return jsonResponse({ ok: false, error: "Параметр id обязателен" }, 400, corsHeaders); + } + + // Get user info before deletion + const { data: userRow } = await supabase + .from("users") + .select("id, email, name") + .eq("id", userId) + .maybeSingle(); + + if (!userRow) { + return jsonResponse({ ok: false, error: "Пользователь не найден" }, 404, corsHeaders); + } + + // Delete auth user (FK ON DELETE CASCADE will remove public.users row) + const { error: authDeleteError } = await supabase.auth.admin.deleteUser(userId); + if (authDeleteError) { + console.error("auth.deleteUser error:", authDeleteError); + // Try deleting public.users directly as fallback + const { error: dbDeleteError } = await supabase.from("users").delete().eq("id", userId); + if (dbDeleteError) { + return jsonResponse( + { ok: false, error: "Ошибка удаления: " + authDeleteError.message }, + 500, + corsHeaders, + ); + } + } + + return jsonResponse({ ok: true, data: { id: userId } }, 200, corsHeaders); + } + + return jsonResponse({ ok: false, error: "Method not allowed" }, 405, corsHeaders); + } catch (error) { + if (error instanceof Error && "status" in error) { + const httpError = error as { status: number; message: string }; + return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders); + } + return jsonResponse( + { ok: false, error: error instanceof Error ? error.message : "Unexpected error" }, + 500, + corsHeaders, + ); + } +}); \ No newline at end of file From 93d3ce4ca8592ae2dd1b2b81f0d239d49bbbb722 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 30 Jun 2026 07:04:24 +0000 Subject: [PATCH 02/39] =?UTF-8?q?fix:=20manage-users=20=E2=80=94=20remove?= =?UTF-8?q?=20manual=20public.users=20insert,=20trigger=20handle=5Fnew=5Fu?= =?UTF-8?q?ser=20does=20it=20via=20user=5Fmetadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- supabase/functions/manage-users/index.ts | 37 +++--------------------- 1 file changed, 4 insertions(+), 33 deletions(-) diff --git a/supabase/functions/manage-users/index.ts b/supabase/functions/manage-users/index.ts index 9121ea6..b2d1a10 100644 --- a/supabase/functions/manage-users/index.ts +++ b/supabase/functions/manage-users/index.ts @@ -81,17 +81,6 @@ Deno.serve(async (request) => { return jsonResponse({ ok: false, error: "Роль обязательна" }, 400, corsHeaders); } - // Resolve role_id from roles table - const { data: roleRow, error: roleErr } = await supabase - .from("roles") - .select("id") - .eq("name", role) - .maybeSingle(); - if (roleErr || !roleRow) { - return jsonResponse({ ok: false, error: "Неизвестная роль: " + role }, 400, corsHeaders); - } - const roleId = roleRow.id; - // Check if email already exists in public.users const { data: existingUser } = await supabase .from("users") @@ -102,17 +91,18 @@ Deno.serve(async (request) => { return jsonResponse({ ok: false, error: "Пользователь с таким email уже существует" }, 409, corsHeaders); } - // Create auth user + // Create auth user — trigger handle_new_user will auto-insert into public.users + // using user_metadata.role and user_metadata.name const { data: authData, error: authError } = await supabase.auth.admin.createUser({ email, email_confirm: true, - user_metadata: { name }, + user_metadata: { name, role }, }); if (authError) { console.error("auth.createUser error:", authError); return jsonResponse( - { ok: false, error: "Ошибка создания auth-пользователя: " + authError.message }, + { ok: false, error: "Ошибка создания пользователя: " + authError.message }, 500, corsHeaders, ); @@ -120,25 +110,6 @@ Deno.serve(async (request) => { const newUserId = authData.user.id; - // Insert into public.users - const { error: usersInsertError } = await supabase.from("users").insert({ - id: newUserId, - email, - name, - role_id: roleId, - }); - - if (usersInsertError) { - console.error("users.insert error:", usersInsertError); - // Rollback auth user - await supabase.auth.admin.deleteUser(newUserId); - return jsonResponse( - { ok: false, error: "Ошибка создания записи пользователя: " + usersInsertError.message }, - 500, - corsHeaders, - ); - } - return jsonResponse({ ok: true, data: { id: newUserId, email, name, role } }, 201, corsHeaders); } From 858d36e5b08e7a02f413289c3cad545fd8f144b9 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 30 Jun 2026 07:30:12 +0000 Subject: [PATCH 03/39] feat: manual SMS campaign shows groups in manual mode + pagination on all delivery pages --- src/components/UI/Pagination.jsx | 80 + src/components/admin/SmsCampaignPanel.jsx | 1888 +++++++++++++++-- .../logistics/LogisticsReadinessBoard.jsx | 17 +- src/components/orders/OrdersTable.jsx | 473 +++-- 4 files changed, 2048 insertions(+), 410 deletions(-) create mode 100644 src/components/UI/Pagination.jsx diff --git a/src/components/UI/Pagination.jsx b/src/components/UI/Pagination.jsx new file mode 100644 index 0000000..481a59b --- /dev/null +++ b/src/components/UI/Pagination.jsx @@ -0,0 +1,80 @@ +import React from "react"; + +/** + * Universal pagination control. + * Usage: + */ +export const Pagination = ({ page, totalPages, onChange, itemsPerPage, totalItems }) => { + if (totalPages <= 1) return null; + + const from = (page - 1) * (itemsPerPage || 0) + 1; + const to = Math.min(page * (itemsPerPage || 0), totalItems || 0); + + const pages = []; + const maxButtons = 7; + let start = Math.max(1, page - 3); + let end = Math.min(totalPages, start + maxButtons - 1); + if (end - start < maxButtons - 1) start = Math.max(1, end - maxButtons + 1); + + for (let i = start; i <= end; i++) pages.push(i); + + return ( +
+ + {totalItems != null && itemsPerPage + ? `${from}–${to} из ${totalItems}` + : `Стр. ${page} из ${totalPages}`} + +
+ + {start > 1 && ( + <> + + {start > 2 && } + + )} + {pages.map((p) => ( + + ))} + {end < totalPages && ( + <> + {end < totalPages - 1 && } + + + )} + +
+
+ ); +}; \ No newline at end of file diff --git a/src/components/admin/SmsCampaignPanel.jsx b/src/components/admin/SmsCampaignPanel.jsx index 8099dfb..8447118 100644 --- a/src/components/admin/SmsCampaignPanel.jsx +++ b/src/components/admin/SmsCampaignPanel.jsx @@ -1,22 +1,44 @@ /** * @file SmsCampaignPanel.jsx - * @description SMS Campaign log + settings for mega_admin. - * Shows sms_campaign_log entries with filtering and sms_campaign_settings editable. + * @description SMS Campaign management for mega_admin. + * Campaign cards + settings + log table + queue with next-send times */ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; +import { useNavigate } from "react-router-dom"; import { Panel } from "../UI/Panel"; import { Badge } from "../UI/Badge"; -import { supabase } from "../../supabaseClient"; +import { supabase, supabaseUrl, supabaseAnonKey } from "../../supabaseClient"; +import { Pagination } from "../UI/Pagination"; +import { SmsCampaignStats } from "./SmsCampaignStats"; -// ── Status labels ────────────────────────────────────────────────────────── +// ── 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: "Отправлено", - checking: "Проверяется", - delivered: "Доставлено", - send_failed: "Ошибка отправки", + sent: "SMS отправлено", + checking: "В пути к абоненту", + delivered: "✓ Доставлено", + send_failed: "Не отправлено", error: "Ошибка доставки", - expired: "Истекло", + expired: "Срок истёк", limit_exceeded: "Лимит превышен", + manual_override: "Ручной режим", }; const STATUS_TONES = { @@ -27,36 +49,81 @@ const STATUS_TONES = { error: "danger", expired: "warning", limit_exceeded: "danger", + manual_override: "warning", }; -// ── SMS code labels (from sms.ru docs) ──────────────────────────────────────── +const STATUS_ICONS = { + sent: "📤", + checking: "📡", + delivered: "✅", + send_failed: "❌", + error: "⛔", + expired: "⏰", + limit_exceeded: "🚫", + manual_override: "✋", +}; + +// ── SMS code labels ────────────────────────────────────────────────────────── const SMS_CODE_LABELS = { - "100": "В очереди", - "101": "Оператору", - "102": "В пути", - "103": "Доставлено", - "104": "Истекло время", - "105": "Удалено оператором", - "106": "Сбой телефона", - "107": "Неизвестная причина", - "108": "Отклонено", - "130": "Лимит на номер/день", - "131": "Лимит одинаковых/мин", - "132": "Лимит одинаковых/день", - "200": "Неправильный api_id", - "201": "Недостаточно средств", - "202": "Неправильный получатель", - "230": "Общий лимит/день", - "231": "Лимит одинаковых/мин", + "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", second: "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; } }; @@ -70,51 +137,488 @@ const fmtPhone = (phone) => { 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; +}; + +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 ( + + ); +}; + +// ── Mini Toggle (for timer in card) ────────────────────────────────────────── +const MiniToggle = ({ on, onClick, disabled }) => ( + +); + +// ── KPI Tile ───────────────────────────────────────────────────────────────── +const KpiTile = ({ icon, value, label, color }) => ( +
+
{label}
+
+ {icon && {icon}}{value} +
+
+); + +// ── SettingField ───────────────────────────────────────────────────────────── +const SettingField = ({ label, value, onChange }) => ( + +); + +// ── FilterButton ───────────────────────────────────────────────────────────── +const FilterButton = ({ active, onClick, children }) => ( + +); + +// ── 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)]", + }; + return ( + + ); +}; + +// ── Table helpers (desktop) ────────────────────────────────────────────────── +const TableHeader = ({ cols }) => ( +
c.width).join(" ") }} + > + {cols.map((c, i) => ( +
{c.label}
+ ))} +
+); + +const TableRow = ({ cols, onClick, children }) => ( +
c.width).join(" ") }} + > + {children} +
+); + +// ── Mobile card row ─────────────────────────────────────────────────────────── +const MobileCard = ({ onClick, children, className = "" }) => ( +
+ {children} +
+); + +const MobileRow = ({ label, value, color }) => ( +
+ {label} + {value} +
+); + +// ── 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 [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 [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 () => { - setIsLoading(true); setError(null); try { - // Load logs + 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); + + 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); - } - + if (filter !== "all") query = query.eq("status", filter); const { data: logData, error: logError } = await query; if (logError) throw logError; - setLogs(logData || []); - // Load settings - const { data: settingsData, error: settingsError } = await supabase - .from("sms_campaign_settings") - .select("*") - .eq("campaign_type", "first_sms") - .single(); - if (settingsError && settingsError.code !== "PGRST116") throw settingsError; - setSettings(settingsData || null); + // 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); } - }, [filter]); + }, [activeCampaign, filter, viewMode, showSmsPreview]); - useEffect(() => { loadData(); }, [loadData]); + useEffect(() => { setIsLoading(true); loadData(); }, [loadData]); + useEffect(() => { + if (autoRefresh) { + refreshTimer.current = setInterval(() => loadData(), 15000); + return () => clearInterval(refreshTimer.current); + } + }, [autoRefresh, loadData]); // ── Save settings ────────────────────────────────────────────────────────── const handleSaveSettings = async () => { @@ -129,7 +633,7 @@ export const SmsCampaignPanel = () => { .eq("id", id); if (updateError) throw updateError; setSettingsSaved(true); - setTimeout(() => setSettingsSaved(false), 2000); + setTimeout(() => setSettingsSaved(false), 3000); } catch (e) { setError(`Ошибка сохранения: ${e.message}`); } finally { @@ -141,221 +645,1141 @@ export const SmsCampaignPanel = () => { setSettings(prev => prev ? { ...prev, [key]: value } : prev); }; - // ── Stats summary ───────────────────────────────────────────────────────── - const stats = logs.reduce((acc, log) => { - acc[log.status] = (acc[log.status] || 0) + 1; - return acc; - }, {}); + 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); } + }; - // ── Render ────────────────────────────────────────────────────────────────── - if (isLoading) { + 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) => { + 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 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) => { + if (!s) return { label: "—", color: "var(--color-text-muted)" }; + if (!s.enabled) return { label: "⏸ Остановлена", color: "var(--color-text-muted)" }; + if (!s.last_run_at) return { label: "⚠️ Не запускалась", color: "var(--color-danger)" }; + const lastRun = new Date(s.last_run_at).getTime(); + const diffMin = Math.floor((Date.now() - lastRun) / 60000); + if (diffMin < 10) return { label: `🟢 Работает (${diffMin}м назад)`, color: "#22c55e" }; + if (diffMin < 60) return { label: `🟡 ${diffMin}м назад`, color: "var(--color-warning)" }; + if (diffMin < 1440) return { label: `🔴 ${Math.floor(diffMin/60)}ч назад`, color: "var(--color-danger)" }; + return { label: `🔴 ${Math.floor(diffMin/1440)}д назад`, 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 ( - -
Загрузка SMS-логов…
+ +
+ + Загрузка данных кампании… +
); } - if (error) { - return ( - -
Ошибка: {error}
- -
- ); - } + // 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 (
- {/* Settings panel */} - {settings && ( - -
-

Настройки кампании

- {settingsSaved && ( - ✓ Сохранено - )} + {/* ── Balance banner ─────────────────────────────────────────────────── */} + +
+
+
+ 💰 +
+
+
Баланс sms.ru
+
+ {balance != null ? `${Number(balance).toLocaleString("ru-RU")} ₽` : "—"} +
+
-
- updateSetting("wait_between_checks_seconds", parseInt(v) || 25)} - /> - updateSetting("max_check_duration_minutes", parseInt(v) || 90)} - /> - updateSetting("max_attempts", parseInt(v) || 2)} - /> - updateSetting("second_sms_delay_hours", parseInt(v) || 3)} - /> - updateSetting("auto_manual_after_hours", parseInt(v) || 3)} - /> - updateSetting("telegram_chat_id", v)} - /> +
+
+ 📊 +
+
+
SMS отправлено (боевые)
+
+ {Object.values(campaignCounts).reduce((a, c) => a + (c?.total || 0), 0)} +
+
-
- -
+ + + {/* ── Campaign cards ────────────────────────────────────────────────── */} +
+ {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); + const isToggling = togglingCampaign === c.key; + const isTimerToggling = togglingTimer === c.key; + const isTestSending = testSending === c.key; + const isManual = c.key === MANUAL_CAMPAIGN; + return ( +
{ setActiveCampaign(c.key); setFilter("all"); setViewMode(c.key === "manual" ? "queue" : "logs"); }} > - {savingSettings ? "Сохранение…" : "Сохранить"} + {/* Header */} +
+
+ {c.icon} + {c.label} +
+ toggleCampaignEnabled(c.key)} + disabled={isToggling} + size="sm" + /> +
+ + {/* Status badges */} +
+ + {!isEnabled ? "⏸ Выкл" : isManual ? "🔧 Ручное" : isTest ? "🧪 Тест" : "🚀 Боевой"} + + {runner.label} +
+ + {/* Action buttons */} +
e.stopPropagation()}> + + {!isManual && isTest && ( + handleTestSend(c.key)} + disabled={isTestSending} + tone="warning" + > + {isTestSending ? "…" : "🧪 Тест"} + + )} + handleRestart(c.key)} + disabled={restarting === c.key} + tone="accent" + > + {restarting === c.key ? "…" : "🔄 Сброс"} + +
+ + {/* Stats grid — 2×2 KPI tiles (real only, test excluded) */} +
+ + + + 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)"} + /> +
+
+ ); + })} +
+ + {error && ( + +
+
⚠ {error}
+
)} - {/* Stats summary */} - -
- Всего: {logs.length} - {Object.entries(STATUS_LABELS).map(([status, label]) => { - const count = stats[status] || 0; - if (count === 0) return null; - return ( - - {label}: {count} - - ); - })} -
-
- - {/* Filter buttons */} -
- setFilter("all")}> - Все ({logs.length}) - - {Object.entries(STATUS_LABELS).map(([status, label]) => { - const count = stats[status] || 0; - if (count === 0) return null; - return ( - setFilter(status)}> - {label} ({count}) - - ); - })} + {/* ── View mode toggle ──────────────────────────────────────────────── */} +
+ setViewMode("logs")}>📋 Журнал отправок + setViewMode("queue")}>⏳ Очередь отправки
- {/* Log table */} - -
-
- {/* Header */} -
-
Телефон
-
SMS ID
-
Статус
-
Код
-
Попытка
-
Создано
+ {/* ── Queue view ────────────────────────────────────────────────────── */} + {viewMode === "queue" && ( + <> + + {/* Date + link filters */} +
+ 📅 Добавлен: + {[ + { v: "all", l: "Все" }, + { v: "today", l: "Сегодня" }, + { v: "yesterday", l: "Вчера" }, + { v: "7d", l: "7 дней" }, + ].map(opt => ( + setDateFilter(opt.v)}>{opt.l} + ))} + 🔗 Ссылка: + {[ + { v: "all", l: "Все" }, + { v: "opened", l: "Открыта" }, + { v: "not_opened", l: "Не открыта" }, + ].map(opt => ( + setLinkFilter(opt.v)}>{opt.l} + ))} +
+ + {/* Desktop table */} +
+
+ + {queueData.length === 0 ? ( +
+ Очередь пуста — нет групп, ожидающих отправки SMS +
+ ) : ( + queueData.filter((g) => { + const inv = g._invitation; + const opened = inv && inv.opened_at; + return passesDateFilter(g.created_at, dateFilter) && passesLinkFilter(opened, linkFilter); + }).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 ( + handleOpenGroup(g.id)} + > +
+ {g.customer_name || g.group_key || "—"} +
+
{fmtPhone(g.customer_phone)}
+
+ + {NOTIF_LABELS[g.notification_status] || g.notification_status || "—"} + + {g.sms_attempts > 0 &&
попыток: {g.sms_attempts}
} +
+
+ {(() => { const a = fmtAgreement(g); return ( + {a.text} + ); })()} +
+
+
в базу
+
{fmtTimeShort(g.created_at)}
+
{fmtElapsed(g.created_at)}
+ {g.sms_sent_at && ( + <> +
SMS старт
+
{fmtTimeShort(g.sms_sent_at)}
+
{fmtElapsed(g.sms_sent_at)}
+ + )} +
+
+ {activeCampaign === "manual" ? ( + manualTransfer ? ( +
+
2-я SMS: {fmtTimeShort(g.second_sms_sent_at)}
+
+ {fmtTimeShort(manualTransfer.toISOString())} +
+
+ {isOverdue ? "⏰ просрочено!" : fmtCountdown(manualTransfer.toISOString())} +
+ {g.manual_confirmation_at && ( +
+ 🔧 В ручном: {fmtTimeShort(g.manual_confirmation_at)} +
+ )} +
+ ) : g.manual_confirmation_at ? ( +
+
🔧 В ручном
+
{fmtTimeShort(g.manual_confirmation_at)}
+
{fmtElapsed(g.manual_confirmation_at)}
+
+ ) : ( + + ) + ) : nextSend ? ( +
+
{fmtTimeShort(nextSend)}
+
{fmtCountdown(nextSend)}
+
+ ) : ( + + )} +
+
+ {fmtTimeShort(g.next_notification_check_at)} + {g.next_notification_check_at &&
{fmtCountdown(g.next_notification_check_at)}
} +
+
+ {opened ? ( +
+ ✅ Открыта + {inv.last_accessed_at && ( +
+ {accessCount > 0 ? `${accessCount}× ` : ""}{fmtElapsed(inv.last_accessed_at)} +
+ )} +
+ ) : inv ? ( + — не открыта + ) : ( + — нет ссылки + )} +
+
+ ); + }) + )}
- {/* Rows */} - {logs.length === 0 ? ( -
- Нет записей в логе +
+ + {/* Mobile cards */} +
+ {queueData.length === 0 ? ( +
+ Очередь пуста — нет групп, ожидающих отправки SMS
) : ( - logs.map((entry) => ( -
-
- {fmtPhone(entry.customer_phone)} -
-
- {entry.sms_id || "—"} -
-
- - {STATUS_LABELS[entry.status] || entry.status} - -
-
- {entry.sms_code || "—"} - {entry.sms_code && SMS_CODE_LABELS[entry.sms_code] && ( -
{SMS_CODE_LABELS[entry.sms_code]}
- )} -
-
- {entry.attempts || 0} -
-
- {fmtTime(entry.created_at)} - {entry.error_message && ( -
{entry.error_message.slice(0, 80)}
- )} -
-
- )) + queueData.filter((g) => { + const inv = g._invitation; + const opened = inv && inv.opened_at; + return passesDateFilter(g.created_at, dateFilter) && passesLinkFilter(opened, linkFilter); + }).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 ( + handleOpenGroup(g.id)}> + {/* Header: name + phone */} +
+
+ {g.customer_name || g.group_key || "—"} +
+ + {NOTIF_LABELS[g.notification_status] || g.notification_status || "—"} + +
+
{fmtPhone(g.customer_phone)}
+ {/* Info rows */} +
+ {(() => { const a = fmtAgreement(g); return ( + + ); })()} + + {g.sms_sent_at && ( + + )} + {activeCampaign === "manual" && manualTransfer ? ( + + ) : nextSend ? ( + + ) : null} + {g.next_notification_check_at && ( + + )} + {g.sms_attempts > 0 && ( + + )} + 0 ? ` ${accessCount}×` : ""}${inv?.last_accessed_at ? ` ${fmtElapsed(inv.last_accessed_at)}` : ""}` : inv ? "— не открыта" : "— нет ссылки"} + color={opened ? "#22c55e" : "var(--color-text-muted)"} + /> +
+
+ ); + }) )}
-
-
+ + {(() => { + const filteredQueue = queueData.filter((g) => { + const inv = g._invitation; + const opened = inv && inv.opened_at; + return passesDateFilter(g.created_at, dateFilter) && passesLinkFilter(opened, linkFilter); + }); + const totalPages = Math.ceil(filteredQueue.length / PAGE_SIZE); + if (totalPages <= 1) return null; + return ; + })()} + + )} - {/* Refresh button */} + {/* ── Logs view ─────────────────────────────────────────────────────── */} + {viewMode === "logs" && ( + <> + + + {showSettings && settings && ( +
+ + {showSettingsPanel && ( + +
+

+ {CAMPAIGNS.find(c => c.key === activeCampaign)?.icon} {CAMPAIGNS.find(c => c.key === activeCampaign)?.label} +

+ {settingsSaved && ( + + ✓ Сохранено + + )} +
+ + {/* Test/Battle mode toggle */} + {!isManualCampaign && ( +
+ + {settings.test_mode && ( +
+ updateSetting("test_phone", v)} /> +
+ )} +
+ )} + + {/* SMS text editor */} + {!isManualCampaign && ( +
+
+ + +
+