supersam/src/components/orders/SmsStatusCard.jsx

335 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @file 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 (
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1.5">
<div
className={`flex items-center gap-2 text-[11px] ${hasText ? "cursor-pointer" : ""}`}
onClick={hasText ? () => setExpanded(e => !e) : undefined}
>
<span className="text-[var(--color-text-muted)]">{fmtTime(log.created_at)}</span>
<Badge tone={log.status === "delivered" ? "accent" : log.status === "sent" || log.status === "checking" ? "info" : "danger"}>
{log.status === "delivered" ? "доставлено" : log.status === "sent" ? "отправлено" : log.status === "checking" ? "проверка" : log.status === "expired" ? "истёк" : log.status}
</Badge>
{log.campaign_type && (
<span className="text-[var(--color-text-muted)]">
{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}
</span>
)}
{hasText && (
<span className="ml-auto text-[var(--color-accent)] text-[10px]">{expanded ? "▲" : "▼"}</span>
)}
</div>
{hasText && expanded && (
<div className="mt-1.5 rounded-md bg-[var(--color-surface-strong)] px-2.5 py-2 text-[11px] text-[var(--color-text)] leading-relaxed whitespace-pre-wrap">
{log.sms_text}
</div>
)}
</div>
);
};
// ── 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 (
<Panel className="p-4">
<div className="mb-3">
<h3 className="text-sm font-semibold text-[var(--color-text)]">📱 SMS-уведомления</h3>
</div>
{/* Timeline */}
<div className="space-y-2 text-xs">
{/* 1st SMS */}
<div className="flex items-start gap-2">
<span className={`mt-0.5 h-2 w-2 rounded-full ${hasFirstSms ? "bg-[#22c55e]" : firstSmsFailed ? "bg-[var(--color-danger)]" : firstSmsAt ? "bg-[var(--color-accent)]" : notifStatus === "link_ready" || notifStatus === "not_started" ? "bg-[var(--color-warning)]" : "bg-[var(--color-border)]"}`} />
<div className="flex-1">
<div className="text-[var(--color-text)]">1-е SMS</div>
{hasFirstSms ? (
<div className="text-[var(--color-text-muted)]">{fmtTime(firstSmsLog.created_at)} получено клиентом</div>
) : firstSmsFailed ? (
<div className="text-[var(--color-danger)]">{fmtTime(firstSmsLog.created_at)} {firstSmsLog.status === "expired" ? "срок доставки истёк" : firstSmsLog.status}</div>
) : firstSmsAt ? (
<div className="text-[var(--color-text-muted)]">{fmtTime(firstSmsAt)} · отправлено, ждём ответ оператора</div>
) : notifStatus === "link_ready" ? (
<div className="text-[var(--color-text-muted)]">в очереди на отправку</div>
) : (
<div className="text-[var(--color-text-muted)]">не отправлено</div>
)}
</div>
</div>
{/* 2nd SMS */}
<div className="flex items-start gap-2">
<span className={`mt-0.5 h-2 w-2 rounded-full ${hasSecondSms ? "bg-[#22c55e]" : secondSmsFailed ? "bg-[var(--color-danger)]" : notifStatus === "first_sms_sent" ? "bg-[var(--color-warning)]" : "bg-[var(--color-border)]"}`} />
<div className="flex-1">
<div className="text-[var(--color-text)]">2-е SMS</div>
{hasSecondSms ? (
<div className="text-[var(--color-text-muted)]">{fmtTime(secondSmsLog.created_at)} получено клиентом</div>
) : secondSmsFailed ? (
<div className="text-[var(--color-danger)]">{fmtTime(secondSmsLog.created_at)} {secondSmsLog.status === "expired" ? "срок доставки истёк" : secondSmsLog.status}</div>
) : notifStatus === "first_sms_sent" && countdown ? (
<div className="text-[var(--color-text-muted)]">
отправка через <span className="font-mono text-[var(--color-accent)]">{countdown}</span>
</div>
) : (
<div className="text-[var(--color-text-muted)]"></div>
)}
</div>
</div>
{/* Next check countdown */}
{countdown && notifStatus !== "second_sms_sent" && (
<div className="flex items-center gap-2 rounded-lg bg-[var(--color-surface-strong)] px-2 py-1.5">
<span className="text-[var(--color-text-muted)]"> Следующая проверка:</span>
<span className="font-mono text-[var(--color-accent)]">{countdown}</span>
</div>
)}
{/* Error */}
{lastError && (
<div className="rounded-lg bg-[rgba(239,68,68,0.08)] px-2 py-1.5 text-[var(--color-danger)]">
{lastError}
</div>
)}
{/* Attempts */}
{smsAttempts > 0 && (
<div className="text-[var(--color-text-muted)]">Попыток отправки: {smsAttempts}</div>
)}
</div>
{/* SMS log for this group */}
{smsLog.length > 0 && (
<div className="mt-3 border-t border-[var(--color-border)] pt-3">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">История SMS</div>
<div className="space-y-1.5">
{smsLog.map((log) => (
<SmsLogEntry key={log.id} log={log} />
))}
</div>
</div>
)}
{/* Client page access info */}
{(order.invitationAccessCount > 0 || order.invitationOpenedAt) ? (
<div className="mt-2 flex items-center gap-2 rounded-lg bg-[var(--color-surface-strong)] px-2 py-1.5 text-[11px]">
<span className="text-[var(--color-text-muted)]">👁 Клиент открывал страницу согласования</span>
<span className="font-medium text-[var(--color-text)]">{order.invitationAccessCount || 1} раз</span>
{(order.invitationLastAccessedAt || order.invitationOpenedAt) && (
<span className="text-[var(--color-text-muted)]">
· последний: {fmtTime(order.invitationLastAccessedAt || order.invitationOpenedAt)}
</span>
)}
</div>
) : (
<div className="mt-2 flex items-center gap-2 rounded-lg bg-[var(--color-surface-strong)] px-2 py-1.5 text-[11px] text-[var(--color-text-muted)]">
📭 Клиент ещё не открывал страницу согласования
</div>
)}
{/* Restart buttons */}
{canManage && (
<div className="mt-3 flex gap-2 border-t border-[var(--color-border)] pt-3">
<button
type="button"
onClick={handleRestart}
disabled={restarting}
className="rounded-xl border border-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-[var(--color-accent)] hover:bg-[var(--color-accent-soft)] disabled:opacity-50"
>
{restarting ? "Сброс…" : restartDone ? "✓ Сброшено" : "🔄 Перезапустить SMS"}
</button>
{hasFirstSms && !hasSecondSms && (
<button
type="button"
onClick={handleRestartSecond}
disabled={restarting}
className="rounded-xl border border-[var(--color-border)] px-3 py-1.5 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)] disabled:opacity-50"
>
Только 2-ю SMS
</button>
)}
</div>
)}
</Panel>
);
};