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