feat(autocall): автодозвон в карточке доставки — история действий, цепочка этапов, сводка статусов

This commit is contained in:
Hermes 2026-09-08 12:28:38 +02:00
parent 5dbeae84f4
commit 1552c21d99
1 changed files with 116 additions and 7 deletions

View File

@ -103,6 +103,32 @@ const SMS_STATUS_LABELS = {
pending: "в очереди",
};
// ─── Автодозвон: outcome → человеческий статус ─────────────────────
const AUTOCALL_OUTCOME_LABELS = {
confirmed: { label: "Автодозвон: клиент согласовал слот", tone: "ok", icon: "🤖" },
confirmed_silent: { label: "Автодозвон: подтвердил молчанием", tone: "ok", icon: "🤖" },
confirmed_kept_previous:{ label: "Автодозвон: клиент правил выбор", tone: "warning", icon: "🤖" },
no_answer: { label: "Автодозвон: не взял трубку", tone: "danger", icon: "🤖" },
no_input_day: { label: "Автодозвон: не нажал (день)", tone: "warning", icon: "🤖" },
no_input_half: { label: "Автодозвон: не нажал (время)", tone: "warning", icon: "🤖" },
operator: { label: "Автодозвон: клиент попросил менеджера", tone: "warning", icon: "🤖" },
opt_out: { label: "Автодозвон: клиент отказался от звонков", tone: "danger", icon: "🤖" },
invalid_number: { label: "Автодозвон: неверный номер", tone: "danger", icon: "🤖" },
error: { label: "Автодозвон: ошибка звонка", tone: "danger", icon: "🤖" },
};
const AUTOCALL_DAY_LABEL = { 1: "завтра", 2: "послезавтра" };
const AUTOCALL_HALF_LABEL = { 1: "первая половина дня", 2: "вторая половина дня" };
const autocallSlotText = (l) => {
if (!l) return null;
const day = AUTOCALL_DAY_LABEL[l.dtmf_day];
const half = AUTOCALL_HALF_LABEL[l.dtmf_half];
if (day && half) return `${day}, ${half}`;
if (day) return day;
return null;
};
const fmtTimestamp = (ts) => {
if (!ts) return "—";
try {
@ -172,7 +198,7 @@ const STAGES_PICKUP = [
{ key: "picked_up", icon: "📦", label: "Вывезено" },
];
const getActiveStageIndex = (order, smsLogs = []) => {
const getActiveStageIndex = (order, smsLogs = [], autocallLogs = []) => {
const deliveryStatus = order?.deliveryStatus || order?.delivery_status || "";
const notifStatus = order?.notificationStatus || order?.notification_status || "";
const firstSms = order?.firstSmsSentAt || order?.first_sms_sent_at;
@ -197,6 +223,12 @@ const getActiveStageIndex = (order, smsLogs = []) => {
return 2;
}
// Автодозвон: если робот согласовал слот — это уже «Согласовано»
const hasAutocallConfirmed = autocallLogs.some((l) => ["confirmed", "confirmed_silent", "confirmed_kept_previous"].includes(l.outcome));
if (hasAutocallConfirmed) {
return 2;
}
// SMS stage counts as done when any first_sms record exists (even failed), not just when delivered
const hasFirstSmsLog = smsLogs.some((l) => l.campaign_type === "first_sms");
if (firstSms || hasFirstSmsLog || ["first_sms_sent", "second_sms_sent", "confirmed"].includes(notifStatus)) {
@ -206,10 +238,10 @@ const getActiveStageIndex = (order, smsLogs = []) => {
return 0;
};
const ProgressStepper = ({ order, smsLogs = [] }) => {
const ProgressStepper = ({ order, smsLogs = [], autocallLogs = [] }) => {
const isPickup = (order?.deliveryType || order?.delivery_type) === "pickup";
const stages = isPickup ? STAGES_PICKUP : STAGES_DELIVERY;
const activeIdx = getActiveStageIndex(order, smsLogs);
const activeIdx = getActiveStageIndex(order, smsLogs, autocallLogs);
if (activeIdx === -1) {
return (
@ -276,7 +308,7 @@ const ProgressStepper = ({ order, smsLogs = [] }) => {
};
// ─── Current Status Summary (compact card above timeline) ───────────
const StatusSummary = ({ order, smsLogs = [] }) => {
const StatusSummary = ({ order, smsLogs = [], autocallLogs = [] }) => {
const isPickup = (order?.deliveryType || order?.delivery_type) === "pickup";
const deliveryStatus = order?.deliveryStatus || order?.delivery_status || "";
const notifStatus = order?.notificationStatus || order?.notification_status || "";
@ -350,6 +382,22 @@ const StatusSummary = ({ order, smsLogs = [] }) => {
items.push({ icon: "📞", label: "Звонок", value: "не звонили", tone: "muted" });
}
// Автодозвон — итог по последнему звонку робота
const lastAutocall = autocallLogs && autocallLogs.length > 0 ? autocallLogs[0] : null;
if (lastAutocall) {
const acMeta = AUTOCALL_OUTCOME_LABELS[lastAutocall.outcome] || { label: lastAutocall.outcome || "—", tone: "muted" };
const acSlot = autocallSlotText(lastAutocall);
const acValue = acSlot ? `${acMeta.label} · ${acSlot}` : acMeta.label;
items.push({
icon: "🤖",
label: "Автодозвон",
value: `${acValue}${lastAutocall.was_test_mode ? " · 🧪 тест" : ""}`,
tone: acMeta.tone,
});
} else {
items.push({ icon: "🤖", label: "Автодозвон", value: "не звонили", tone: "muted" });
}
// Driver (delivery only)
if (!isPickup) {
if (driverName) {
@ -408,6 +456,7 @@ const StatusSummary = ({ order, smsLogs = [] }) => {
export const OrderHistoryTimeline = ({ order, userRole }) => {
const [history, setHistory] = useState(null);
const [smsLogs, setSmsLogs] = useState([]);
const [autocallLogs, setAutocallLogs] = useState([]);
const [pushLogs, setPushLogs] = useState([]);
const [pushSubscriptions, setPushSubscriptions] = useState([]);
const [cityProfile, setCityProfile] = useState(null);
@ -440,6 +489,18 @@ export const OrderHistoryTimeline = ({ order, userRole }) => {
if (!smsError) setSmsLogs(smsData || []);
} catch { if (!cancelled) setSmsLogs([]); }
// Автодозвон — лог робо-звонков
try {
const { data: acData, error: acError } = await supabase
.from("autocall_log")
.select("id, outcome, attempt_no, scenario, dtmf_day, dtmf_half, changed, was_test_mode, sip_code, duration_sec, created_at")
.eq("order_group_id", order.id)
.order("created_at", { ascending: false })
.limit(50);
if (cancelled) return;
if (!acError) setAutocallLogs(acData || []);
} catch { if (!cancelled) setAutocallLogs([]); }
// Push is stored on the client phone, so it is available for every current and future order.
const phoneNormalized = String(order?.customerPhoneNormalized || order?.customer_phone_normalized || order?.customerPhone || order?.customer_phone || "").replace(/\D/g, "").replace(/^8/, "7");
if (phoneNormalized) {
@ -561,6 +622,26 @@ export const OrderHistoryTimeline = ({ order, userRole }) => {
}
}
if (autocallLogs && autocallLogs.length > 0) {
for (const ac of autocallLogs) {
const meta = AUTOCALL_OUTCOME_LABELS[ac.outcome] || { label: `Автодозвон: ${ac.outcome || "—"}`, tone: "muted", icon: "🤖" };
const slot = autocallSlotText(ac);
events.push({
id: `autocall-${ac.id}`,
created_at: ac.created_at,
type: "autocall",
label: meta.label,
tone: meta.tone,
slot,
attemptNo: ac.attempt_no,
wasTest: ac.was_test_mode,
changed: ac.changed,
sipCode: ac.sip_code,
durationSec: ac.duration_sec,
});
}
}
if (pushLogs && pushLogs.length > 0) {
for (const push of pushLogs) {
events.push({
@ -609,7 +690,7 @@ export const OrderHistoryTimeline = ({ order, userRole }) => {
events.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
return events;
}, [history, smsLogs, pushLogs, invitation, cityProfile]);
}, [history, smsLogs, autocallLogs, pushLogs, invitation, cityProfile]);
const activePushSubscriptions = pushSubscriptions.filter((sub) => sub.is_active);
const latestPush = pushLogs[0] || null;
@ -618,8 +699,8 @@ export const OrderHistoryTimeline = ({ order, userRole }) => {
return (
<div className="space-y-4">
<ProgressStepper order={order} smsLogs={smsLogs} />
<StatusSummary order={order} smsLogs={smsLogs} />
<ProgressStepper order={order} smsLogs={smsLogs} autocallLogs={autocallLogs} />
<StatusSummary order={order} smsLogs={smsLogs} autocallLogs={autocallLogs} />
<div className={`rounded-xl border px-3 py-2.5 ${
activePushSubscriptions.length > 0
? "border-[rgba(18,128,92,0.25)] bg-[var(--color-accent-soft)]"
@ -693,6 +774,34 @@ export const OrderHistoryTimeline = ({ order, userRole }) => {
</p>
)}
{ev.type === "autocall" && (
<>
<p className="mt-0.5 text-sm">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${
ev.tone === "ok"
? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
: ev.tone === "danger"
? "bg-[rgba(239,68,68,0.12)] text-[var(--color-danger)]"
: ev.tone === "warning"
? "bg-[rgba(245,158,11,0.12)] text-[var(--color-warning)]"
: "bg-[var(--color-surface)] text-[var(--color-text-muted)]"
}`}>
{ev.tone === "ok" ? "успешно" : ev.tone === "danger" ? "неуспешно" : ev.tone === "warning" ? "требует внимания" : "—"}
</span>
</p>
{ev.slot && <p className="mt-0.5 text-xs text-[var(--color-text)]">Слот: {ev.slot}</p>}
{ev.changed && (
<p className="mt-0.5 text-xs text-[var(--color-warning)]">⚠ Клиент правил выбор в звонке</p>
)}
<p className="mt-0.5 text-xs text-[var(--color-text-muted)]">
Попытка {ev.attemptNo || "—"}
{ev.durationSec ? ` · длительность ${ev.durationSec}с` : ""}
{ev.sipCode ? ` · SIP ${ev.sipCode}` : ""}
{ev.wasTest ? " · 🧪 тест" : ""}
</p>
</>
)}
{ev.type === "reason" && (
<p className={`mt-0.5 text-xs ${
ev.tone === "warning" ? "text-[var(--color-warning)]" : "text-[var(--color-text-muted)]"