supersam/src/components/orders/AutocallStatusCard.jsx

366 lines
17 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 AutocallStatusCard.jsx
* @description Блок «Автодозвон» в карточке заказа.
* Показывает попытки звонков, слот, кнопки «Позвонить сейчас» / «Не звонить».
*/
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Badge } from "../UI/Badge";
import { supabase } from "../../supabaseClient";
import { resolveAutocallStatus, AUTOCALL_STATUS_META } from "../../services/orderGroupViews";
import { describeCallProcess } from "../../services/autocallProcess";
const OUTCOME_META = {
confirmed: { label: "Согласовал", tone: "accent", icon: "✅" },
confirmed_kept_previous:{ label: "Правка не доведена", tone: "warning", icon: "🔁" },
confirmed_silent: { label: "Подтвердил молчанием", tone: "accent", icon: "🤫" },
no_answer: { label: "Не взял трубку", tone: "neutral", icon: "📵" },
no_input_day: { label: "Взял, не выбрал день", tone: "warning", icon: "🤐" },
no_input_half: { label: "Взял, не выбрал время", tone: "warning", icon: "🤐" },
invalid_number: { label: "Неверный номер", tone: "danger", icon: "❌" },
error: { label: "Ошибка", tone: "danger", icon: "⚠️" },
};
const DAY_LABEL = { 1: "завтра", 2: "послезавтра" };
const HALF_LABEL = { 1: "первая половина дня", 2: "вторая половина дня" };
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 slotText = (l) => {
if (!l) return null;
const day = DAY_LABEL[l.dtmf_day];
const half = HALF_LABEL[l.dtmf_half];
if (day && half) return `${day}, ${half}`;
if (day) return day;
return null;
};
export const AutocallStatusCard = ({ order, userRole }) => {
const [calls, setCalls] = useState([]);
const [queueItem, setQueueItem] = useState(null);
const [settings, setSettings] = useState(null);
const [loading, setLoading] = useState(false);
const [toggling, setToggling] = useState(false);
const [sending, setSending] = useState(false);
const canManage = ["mega_admin", "logistician"].includes(userRole);
const optOut = order.autocall_opt_out ?? order.autocallOptOut ?? false;
const isAutocall = order.delivery_date_source === "autocall" || order.deliveryDateSource === "autocall";
const exclusionReason = order.autocall_exclusion_reason || order.autocallExclusionReason || null;
const loadCalls = useCallback(async () => {
if (!order?.id) return;
setLoading(true);
try {
const { data, error } = await supabase
.from("autocall_log")
.select("*")
.eq("order_group_id", order.id)
.order("created_at", { ascending: false })
.limit(10);
if (error) throw error;
setCalls(data || []);
} catch (e) {
// silent
} finally {
setLoading(false);
}
}, [order?.id]);
// Последняя заявка «Позвонить сейчас» по этому заказу: без неё в карточке было
// не видно, ушёл ли запрос и что с ним стало.
const loadQueue = useCallback(async () => {
if (!order?.id) return;
try {
const { data } = await supabase
.from("autocall_queue")
.select("id, requested_at, processed_at, is_test_call, call_type, phone")
.eq("order_group_id", order.id)
.order("requested_at", { ascending: false })
.limit(1);
setQueueItem(data?.[0] || null);
} catch {
// silent
}
}, [order?.id]);
// Настройки кампании: рабочие часы/дни и тестовый режим — из них понятно,
// когда именно робот возьмёт заявку.
const loadSettings = useCallback(async () => {
try {
const { data } = await supabase
.from("sms_campaign_settings")
.select("enabled, test_mode, test_phone, work_hours_start, work_hours_end, work_days")
.eq("campaign_type", "autocall_slot")
.maybeSingle();
setSettings(data || null);
} catch {
// silent
}
}, []);
useEffect(() => {
loadCalls();
loadQueue();
loadSettings();
}, [loadCalls, loadQueue, loadSettings]);
// Звонок, который робот сделал по нашей заявке (в пределах 15 минут после неё).
const callAfter = useMemo(() => {
if (!queueItem?.requested_at) return null;
const from = new Date(queueItem.requested_at).getTime() - 1000;
const to = from + 15 * 60 * 1000;
return (
calls.find((c) => {
const at = new Date(c.created_at).getTime();
return at >= from && at <= to;
}) || null
);
}, [calls, queueItem]);
// Заявка свежая (сутки) — иначе в карточке висел бы вчерашний процесс.
const recentQueue = useMemo(() => {
if (!queueItem?.requested_at) return null;
return Date.now() - new Date(queueItem.requested_at).getTime() <= 24 * 60 * 60 * 1000 ? queueItem : null;
}, [queueItem]);
// Заявка висит непринятой больше часа в рабочее время — планировщик молча
// пропускает её (окно звонка закрыто: по графику роботу нечего предлагать).
// Показываем честную причину вместо «в очереди».
const callUnreachable = useMemo(() => {
if (!recentQueue || recentQueue.processed_at || callAfter) return false;
const age = Date.now() - new Date(recentQueue.requested_at).getTime();
if (age < 60 * 60 * 1000) return false;
// Рабочее время сейчас?
const workDays = (settings?.work_days || "").toString().split(",").map((s) => parseInt(s, 10)).filter(Number.isFinite);
const startHour = Number(settings?.work_hours_start ?? 9);
const endHour = Number(settings?.work_hours_end ?? 20);
const msk = new Date(Date.now() + (3 * 60 + new Date().getTimezoneOffset()) * 60000);
const dayOk = workDays.length === 0 || workDays.includes(msk.getDay());
const hourOk = msk.getHours() >= startHour && msk.getHours() < endHour;
return dayOk && hourOk;
}, [recentQueue, callAfter, settings]);
const process = useMemo(
() => describeCallProcess({ queueItem: recentQueue, callAfter, settings: { ...(settings || {}), call_unreachable: callUnreachable } }),
[recentQueue, callAfter, settings, callUnreachable],
);
const processing = process?.phase === "pending" || process?.phase === "pending_later" || process?.phase === "dialing";
// Пока заявку не отработали (и в пределах 15 минут) — подтягиваем состояние каждые 7 секунд.
useEffect(() => {
if (!processing || !recentQueue) return undefined;
const age = Date.now() - new Date(recentQueue.requested_at).getTime();
if (age > 15 * 60 * 1000) return undefined;
const timer = setInterval(() => {
loadQueue();
loadCalls();
}, 7000);
return () => clearInterval(timer);
}, [processing, recentQueue, loadQueue, loadCalls]);
const handleCallNow = async () => {
if (!order?.id) return;
setSending(true);
try {
// Пишем в очередь — планировщик подхватит и создаст call-файл (без ssh из edge fn).
// Сценарий планировщик определит сам по дню добавления группы
// (день добавления → A: завтра/послезавтра; позже → B: только завтра).
const { error } = await supabase.from("autocall_queue").insert({
order_group_id: order.id,
phone: order.customer_phone || order.customerPhone || "",
// Тип озвучки: по самовывозу робот говорит «забрать», по доставке — «доставить».
// Если тип в объекте не пришёл, планировщик определит его по заказу сам.
...((order.delivery_type || order.deliveryType)
? { call_type: (order.delivery_type || order.deliveryType) === "pickup" ? "pkp" : "dlv" }
: {}),
});
if (error) throw error;
await loadQueue();
} catch (e) {
console.error("Queue call error:", e);
} finally {
setSending(false);
}
};
const handleToggleOptOut = async () => {
if (!order?.id) return;
setToggling(true);
try {
const { error } = await supabase
.from("order_groups")
.update({ autocall_opt_out: !optOut })
.eq("id", order.id);
if (error) throw error;
// Обновим локально
order.autocall_opt_out = !optOut;
setToggling(false);
} catch (e) {
console.error("Toggle opt-out error:", e);
setToggling(false);
}
};
const lastCall = calls[0];
const lastMeta = lastCall ? OUTCOME_META[lastCall.outcome] : null;
const lastSlot = lastCall ? slotText(lastCall) : null;
// Единый человеческий статус (по аналогии со статусом SMS)
const statusKey = resolveAutocallStatus(calls, { optOut, exclusionReason });
const statusMeta = AUTOCALL_STATUS_META[statusKey] || AUTOCALL_STATUS_META.not_called;
return (
<div className="p-4">
<div className="mb-3">
<h3 className="text-sm font-semibold text-[var(--color-text)]">📞 Автодозвон</h3>
</div>
{/* Единый статус — как у SMS */}
<div className="mb-3 flex items-center gap-2">
<Badge tone={statusMeta.tone}>{statusMeta.label}</Badge>
{lastSlot && <span className="text-xs text-[var(--color-text)]">{lastSlot}</span>}
</div>
{/* Пояснение исключения */}
{exclusionReason && !optOut && (
<div className="mb-3 rounded-lg bg-[rgba(191,123,33,0.08)] px-2.5 py-1.5 text-[11px] text-[var(--color-warning)]">
🚫 Робот не звонит на этот номер по настройке: {exclusionReason}
</div>
)}
{/* Счётчик попыток — как у SMS «Попыток отправки: N» */}
{calls.length > 0 && (
<div className="mb-2 text-[11px] text-[var(--color-text-muted)]">
Попыток звонков: {calls.length}
</div>
)}
{/* Состояние ручного звонка: заявка, набор, результат, «когда позвонят» */}
{process && (
<div
className={[
"mb-3 rounded-lg px-2.5 py-1.5 text-[11px]",
process.tone === "danger"
? "bg-[rgba(239,68,68,0.08)] text-[var(--color-danger)]"
: process.tone === "warning"
? "bg-[rgba(245,158,11,0.08)] text-[var(--color-warning)]"
: process.tone === "accent"
? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]",
].join(" ")}
>
<div className="flex items-center gap-1.5 font-medium">
{processing && (
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" aria-hidden="true" />
)}
<span>{process.title}</span>
</div>
<div className="mt-0.5 text-[10px] opacity-80">{process.hint}</div>
{process.phase === "unreachable" && (
<div className="mt-1.5">
<button
type="button"
onClick={handleCallNow}
disabled={sending || processing}
className="rounded-lg border border-[var(--color-warning)] px-2.5 py-1 text-[11px] font-medium text-[var(--color-warning)] hover:bg-[rgba(245,158,11,0.08)] disabled:opacity-50"
>
{sending ? "Отправляем заявку…" : "📞 Всё равно позвонить"}
</button>
</div>
)}
{process.phase === "done" && callAfter && (
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<Badge tone={(OUTCOME_META[callAfter.outcome] || {}).tone || "neutral"}>
{(OUTCOME_META[callAfter.outcome] || {}).icon || "•"} {(OUTCOME_META[callAfter.outcome] || {}).label || callAfter.outcome}
</Badge>
<span className="text-[10px] opacity-80">{fmtTime(callAfter.created_at)}</span>
{slotText(callAfter) && <span className="text-[10px] opacity-80">{slotText(callAfter)}</span>}
{callAfter.duration_sec > 0 && <span className="text-[10px] opacity-80">{Math.round(callAfter.duration_sec)} сек</span>}
</div>
)}
</div>
)}
{/* История звонков — как «История SMS» */}
{calls.length > 0 ? (
<div className="mt-1 border-t border-[var(--color-border)] pt-3">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">История звонков</div>
{loading && <div className="mb-1 text-[10px] text-[var(--color-text-muted)]">обновляем…</div>}
<div className="space-y-1.5">
{calls.map((c, idx) => {
const m = OUTCOME_META[c.outcome] || { label: c.outcome, tone: "neutral", icon: "•" };
const slot = slotText(c);
const attemptNo = calls.length - idx;
return (
<div key={c.id} className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1.5">
<div className="flex flex-wrap items-center gap-2 text-[11px]">
<span className="text-[var(--color-text-muted)]">{fmtTime(c.created_at)}</span>
<Badge tone={m.tone}>{m.icon} {m.label}</Badge>
{c.call_type === "pkp" && <span className="text-[10px] text-[var(--color-text-muted)]">🏪 самовывоз</span>}
{c.was_test_mode && <span className="text-[10px] text-[var(--color-warning)]">🧪 тест</span>}
</div>
{(slot || c.attempt_no > 1 || c.duration_sec > 0) && (
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-[var(--color-text-muted)]">
{c.attempt_no > 1 && <span>Попытка {c.attempt_no}</span>}
{slot && <span>Выбрано: {slot}</span>}
{c.duration_sec > 0 && <span>{Math.round(c.duration_sec)} сек</span>}
{c.scenario === "B" && <span>только завтра</span>}
{c.scenario === "A" && <span>завтра/послезавтра</span>}
{c.changed && <span className="text-[var(--color-warning)]">клиент правил выбор</span>}
</div>
)}
</div>
);
})}
</div>
</div>
) : (
<div className="text-xs text-[var(--color-text-muted)]">Звонков не было</div>
)}
{/* Подсказка о правке */}
{lastCall?.changed && (
<div className="mt-2 rounded-lg bg-[rgba(245,158,11,0.08)] px-2 py-1.5 text-[11px] text-[var(--color-warning)]">
⚠ Клиент правил выбор в звонке. При сомнении — перезвоните.
</div>
)}
{/* Кнопки */}
{canManage && (
<div className="mt-3 flex gap-2 border-t border-[var(--color-border)] pt-3">
<button
type="button"
onClick={handleCallNow}
disabled={sending || processing || optOut}
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"
>
{sending
? "Отправляем заявку…"
: process?.phase === "dialing"
? "📞 Звоним…"
: processing
? "✓ Заявка в очереди"
: "📞 Позвонить сейчас"}
</button>
<button
type="button"
onClick={handleToggleOptOut}
disabled={toggling}
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"
>
{optOut ? "🔔 Вернуть в автодозвон" : "🚫 Не звонить"}
</button>
</div>
)}
</div>
);
};