335 lines
15 KiB
JavaScript
335 lines
15 KiB
JavaScript
/**
|
||
* @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>
|
||
);
|
||
}; |