/**
* @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 && (
)}
)}
);
};