191 lines
7.9 KiB
JavaScript
191 lines
7.9 KiB
JavaScript
/**
|
||
* @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(5);
|
||
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)
|
||
const { error } = await supabase.from("autocall_queue").insert({
|
||
order_group_id: order.id,
|
||
phone: order.customer_phone || order.customerPhone || "",
|
||
scenario: "A",
|
||
});
|
||
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>
|
||
)}
|
||
|
||
{/* Попытки */}
|
||
{calls.length > 0 ? (
|
||
<div className="space-y-1.5">
|
||
{calls.map((c) => {
|
||
const m = OUTCOME_META[c.outcome] || { label: c.outcome, tone: "neutral", icon: "•" };
|
||
const slot = slotText(c);
|
||
return (
|
||
<div key={c.id} className="flex items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1.5 text-[11px]">
|
||
<span className="text-[var(--color-text-muted)]">{fmtTime(c.created_at)}</span>
|
||
<Badge tone={m.tone}>{m.icon} {m.label}</Badge>
|
||
{slot && <span className="text-[var(--color-text)]">{slot}</span>}
|
||
{c.was_test_mode && <span className="text-[10px] text-[var(--color-warning)]">🧪 тест</span>}
|
||
</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>
|
||
);
|
||
};
|