/** * @file SmsStatusCard.jsx * @description SMS status + restart button for a specific delivery group. * Shows: current notification status, when SMS was sent, countdown to next SMS, * and a "Restart SMS" button that resets this group's notification status. */ import React, { useState, useEffect, useCallback } from "react"; import { Panel } from "../UI/Panel"; import { Badge } from "../UI/Badge"; import { supabase } from "../../supabaseClient"; // ── Status labels ──────────────────────────────────────────────────────────── 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: "Платное хранение: отправлено", draft: "Черновик", confirmed: "Клиент согласовал дату", completed: "Завершено", }; const NOTIF_TONES = { not_started: "neutral", link_ready: "warning", sms_sending: "info", first_sms_sent: "info", second_sms_sending: "info", second_sms_sent: "accent", send_failed: "danger", manual_required: "warning", paid_storage_sending: "info", paid_storage_sent: "accent", draft: "neutral", confirmed: "accent", completed: "neutral", }; // ── Helpers ────────────────────────────────────────────────────────────────── const fmtTime = (ts) => { if (!ts) return "—"; try { return new Date(ts).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", }); } catch { return ts; } }; const fmtCountdown = (targetTs) => { if (!targetTs) return null; const diff = new Date(targetTs).getTime() - Date.now(); if (diff <= 0) return "готово к отправке"; const m = Math.floor(diff / 60000); const s = Math.floor((diff % 60000) / 1000); if (m >= 60) { const h = Math.floor(m / 60); const restM = m % 60; return `${h}ч ${restM}м`; } return `${m}м ${s}с`; }; // ── SMS log entry (expandable to show text) ────────────────────────────────── const SmsLogEntry = ({ log }) => { const [expanded, setExpanded] = useState(false); const hasText = !!log.sms_text; return (
setExpanded(e => !e) : undefined} > {fmtTime(log.created_at)} {log.status === "delivered" ? "доставлено" : log.status === "sent" ? "отправлено" : log.status === "checking" ? "проверка" : log.status === "expired" ? "истёк" : log.status} {log.campaign_type && ( {log.campaign_type === "first_sms" ? "1-е SMS" : log.campaign_type === "second_sms" ? "2-е SMS" : log.campaign_type === "manual" ? "ручное" : log.campaign_type === "paid_storage" ? "платное хранение" : log.campaign_type} )} {hasText && ( {expanded ? "▲" : "▼"} )}
{hasText && expanded && (
{log.sms_text}
)}
); }; // ── Component ──────────────────────────────────────────────────────────────── export const SmsStatusCard = ({ order, userRole }) => { const [restarting, setRestarting] = useState(false); const [restartDone, setRestartDone] = useState(false); const [now, setNow] = useState(Date.now()); const [smsLog, setSmsLog] = useState([]); const [loadingLog, setLoadingLog] = useState(false); // Tick every 1s for countdown useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(timer); }, []); // Load SMS log for this group const loadSmsLog = useCallback(async () => { if (!order?.id) return; setLoadingLog(true); try { const { data, error } = await supabase .from("sms_campaign_log") .select("*") .eq("order_group_id", order.id) .order("created_at", { ascending: false }) .limit(10); if (error) throw error; setSmsLog(data || []); } catch (e) { // Silent fail — not critical } finally { setLoadingLog(false); } }, [order?.id]); useEffect(() => { loadSmsLog(); }, [loadSmsLog]); // Can manage? const canManage = ["mega_admin", "admin"].includes(userRole); const notifStatus = order.notificationStatus || order.notification_status || "not_started"; const nextCheck = order.nextNotificationCheckAt || order.next_notification_check_at; const firstSmsAt = order.firstSmsSentAt || order.first_sms_sent_at; const secondSmsAt = order.secondSmsSentAt || order.second_sms_sent_at; const smsSentAt = order.smsSentAt || order.sms_sent_at; const smsAttempts = order.smsAttempts ?? order.sms_attempts ?? 0; const lastError = order.lastSmsError || order.last_sms_error; // Restart: reset this group's notification_status to link_ready const handleRestart = async () => { if (!order?.id) return; setRestarting(true); try { // Reset this group to link_ready — restart SMS flow from beginning const { error: updateError } = await supabase .from("order_groups") .update({ notification_status: "link_ready", sms_sent_at: null, first_sms_sent_at: null, second_sms_sent_at: null, next_notification_check_at: null, sms_attempts: 0, last_sms_error: null, }) .eq("id", order.id); if (updateError) throw updateError; setRestartDone(true); setTimeout(() => setRestartDone(false), 3000); // Reload log loadSmsLog(); } catch (e) { console.error("Restart SMS error:", e); } finally { setRestarting(false); } }; // Restart second SMS only (keep first SMS sent) const handleRestartSecond = async () => { if (!order?.id) return; setRestarting(true); try { const { error: updateError } = await supabase .from("order_groups") .update({ notification_status: "first_sms_sent", second_sms_sent_at: null, next_notification_check_at: null, sms_attempts: 0, last_sms_error: null, }) .eq("id", order.id); if (updateError) throw updateError; setRestartDone(true); setTimeout(() => setRestartDone(false), 3000); loadSmsLog(); } catch (e) { console.error("Restart second SMS error:", e); } finally { setRestarting(false); } }; const countdown = nextCheck ? fmtCountdown(nextCheck) : null; // Determine SMS display state from sms_log (source of truth) const firstSmsLog = smsLog.find(l => l.campaign_type === "first_sms"); const secondSmsLog = smsLog.find(l => l.campaign_type === "second_sms"); const isDelivered = (log) => log && (log.status === "delivered" || log.sms_code === "103"); const hasFirstSms = isDelivered(firstSmsLog); const hasSecondSms = isDelivered(secondSmsLog); const firstSmsFailed = firstSmsLog && ["expired", "error", "send_failed", "limit_exceeded"].includes(firstSmsLog.status); const secondSmsFailed = secondSmsLog && ["expired", "error", "send_failed", "limit_exceeded"].includes(secondSmsLog.status); return (

📱 SMS-уведомления

{/* Timeline */}
{/* 1st SMS */}
1-е SMS
{hasFirstSms ? (
{fmtTime(firstSmsLog.created_at)} ✓ получено клиентом
) : firstSmsFailed ? (
{fmtTime(firstSmsLog.created_at)} ⚠ {firstSmsLog.status === "expired" ? "срок доставки истёк" : firstSmsLog.status}
) : firstSmsAt ? (
{fmtTime(firstSmsAt)} · отправлено, ждём ответ оператора…
) : notifStatus === "link_ready" ? (
в очереди на отправку
) : (
не отправлено
)}
{/* 2nd SMS */}
2-е SMS
{hasSecondSms ? (
{fmtTime(secondSmsLog.created_at)} ✓ получено клиентом
) : secondSmsFailed ? (
{fmtTime(secondSmsLog.created_at)} ⚠ {secondSmsLog.status === "expired" ? "срок доставки истёк" : secondSmsLog.status}
) : notifStatus === "first_sms_sent" && countdown ? (
отправка через {countdown}
) : (
)}
{/* Next check countdown */} {countdown && notifStatus !== "second_sms_sent" && (
⏱ Следующая проверка: {countdown}
)} {/* Error */} {lastError && (
⚠ {lastError}
)} {/* Attempts */} {smsAttempts > 0 && (
Попыток отправки: {smsAttempts}
)}
{/* SMS log for this group */} {smsLog.length > 0 && (
История SMS
{smsLog.map((log) => ( ))}
)} {/* Client page access info */} {(order.invitationAccessCount > 0 || order.invitationOpenedAt) ? (
👁 Клиент открывал страницу согласования {order.invitationAccessCount || 1} раз {(order.invitationLastAccessedAt || order.invitationOpenedAt) && ( · последний: {fmtTime(order.invitationLastAccessedAt || order.invitationOpenedAt)} )}
) : (
📭 Клиент ещё не открывал страницу согласования
)} {/* Restart buttons */} {canManage && (
{hasFirstSms && !hasSecondSms && ( )}
)}
); };