supersam/src/components/orders/AutocallStatusCard.jsx

220 lines
10 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 } from "react";
import { Badge } from "../UI/Badge";
import { supabase } from "../../supabaseClient";
import { resolveAutocallStatus, AUTOCALL_STATUS_META } from "../../services/orderGroupViews";
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: "🤐" },
operator: { label: "Просил менеджера", tone: "danger", icon: "👤" },
opt_out: { label: "Отказ от звонков", tone: "danger", 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 [loading, setLoading] = useState(false);
const [toggling, setToggling] = useState(false);
const [queued, setQueued] = 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]);
useEffect(() => { loadCalls(); }, [loadCalls]);
const handleCallNow = async () => {
if (!order?.id) return;
setQueued(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;
setTimeout(() => setQueued(false), 3000);
} catch (e) {
console.error("Queue call error:", e);
setQueued(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>
)}
{/* История звонков — как «История 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>
<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={queued || 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"
>
{queued ? "✓ В очереди" : "📞 Позвонить сейчас"}
</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>
);
};