feat(autocall): автодозвон в карточке доставки — история действий, цепочка этапов, сводка статусов
This commit is contained in:
parent
5dbeae84f4
commit
1552c21d99
|
|
@ -103,6 +103,32 @@ const SMS_STATUS_LABELS = {
|
||||||
pending: "в очереди",
|
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) => {
|
const fmtTimestamp = (ts) => {
|
||||||
if (!ts) return "—";
|
if (!ts) return "—";
|
||||||
try {
|
try {
|
||||||
|
|
@ -172,7 +198,7 @@ const STAGES_PICKUP = [
|
||||||
{ key: "picked_up", icon: "📦", label: "Вывезено" },
|
{ key: "picked_up", icon: "📦", label: "Вывезено" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const getActiveStageIndex = (order, smsLogs = []) => {
|
const getActiveStageIndex = (order, smsLogs = [], autocallLogs = []) => {
|
||||||
const deliveryStatus = order?.deliveryStatus || order?.delivery_status || "";
|
const deliveryStatus = order?.deliveryStatus || order?.delivery_status || "";
|
||||||
const notifStatus = order?.notificationStatus || order?.notification_status || "";
|
const notifStatus = order?.notificationStatus || order?.notification_status || "";
|
||||||
const firstSms = order?.firstSmsSentAt || order?.first_sms_sent_at;
|
const firstSms = order?.firstSmsSentAt || order?.first_sms_sent_at;
|
||||||
|
|
@ -197,6 +223,12 @@ const getActiveStageIndex = (order, smsLogs = []) => {
|
||||||
return 2;
|
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
|
// 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");
|
const hasFirstSmsLog = smsLogs.some((l) => l.campaign_type === "first_sms");
|
||||||
if (firstSms || hasFirstSmsLog || ["first_sms_sent", "second_sms_sent", "confirmed"].includes(notifStatus)) {
|
if (firstSms || hasFirstSmsLog || ["first_sms_sent", "second_sms_sent", "confirmed"].includes(notifStatus)) {
|
||||||
|
|
@ -206,10 +238,10 @@ const getActiveStageIndex = (order, smsLogs = []) => {
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ProgressStepper = ({ order, smsLogs = [] }) => {
|
const ProgressStepper = ({ order, smsLogs = [], autocallLogs = [] }) => {
|
||||||
const isPickup = (order?.deliveryType || order?.delivery_type) === "pickup";
|
const isPickup = (order?.deliveryType || order?.delivery_type) === "pickup";
|
||||||
const stages = isPickup ? STAGES_PICKUP : STAGES_DELIVERY;
|
const stages = isPickup ? STAGES_PICKUP : STAGES_DELIVERY;
|
||||||
const activeIdx = getActiveStageIndex(order, smsLogs);
|
const activeIdx = getActiveStageIndex(order, smsLogs, autocallLogs);
|
||||||
|
|
||||||
if (activeIdx === -1) {
|
if (activeIdx === -1) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -276,7 +308,7 @@ const ProgressStepper = ({ order, smsLogs = [] }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Current Status Summary (compact card above timeline) ───────────
|
// ─── 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 isPickup = (order?.deliveryType || order?.delivery_type) === "pickup";
|
||||||
const deliveryStatus = order?.deliveryStatus || order?.delivery_status || "";
|
const deliveryStatus = order?.deliveryStatus || order?.delivery_status || "";
|
||||||
const notifStatus = order?.notificationStatus || order?.notification_status || "";
|
const notifStatus = order?.notificationStatus || order?.notification_status || "";
|
||||||
|
|
@ -350,6 +382,22 @@ const StatusSummary = ({ order, smsLogs = [] }) => {
|
||||||
items.push({ icon: "📞", label: "Звонок", value: "не звонили", tone: "muted" });
|
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)
|
// Driver (delivery only)
|
||||||
if (!isPickup) {
|
if (!isPickup) {
|
||||||
if (driverName) {
|
if (driverName) {
|
||||||
|
|
@ -408,6 +456,7 @@ const StatusSummary = ({ order, smsLogs = [] }) => {
|
||||||
export const OrderHistoryTimeline = ({ order, userRole }) => {
|
export const OrderHistoryTimeline = ({ order, userRole }) => {
|
||||||
const [history, setHistory] = useState(null);
|
const [history, setHistory] = useState(null);
|
||||||
const [smsLogs, setSmsLogs] = useState([]);
|
const [smsLogs, setSmsLogs] = useState([]);
|
||||||
|
const [autocallLogs, setAutocallLogs] = useState([]);
|
||||||
const [pushLogs, setPushLogs] = useState([]);
|
const [pushLogs, setPushLogs] = useState([]);
|
||||||
const [pushSubscriptions, setPushSubscriptions] = useState([]);
|
const [pushSubscriptions, setPushSubscriptions] = useState([]);
|
||||||
const [cityProfile, setCityProfile] = useState(null);
|
const [cityProfile, setCityProfile] = useState(null);
|
||||||
|
|
@ -440,6 +489,18 @@ export const OrderHistoryTimeline = ({ order, userRole }) => {
|
||||||
if (!smsError) setSmsLogs(smsData || []);
|
if (!smsError) setSmsLogs(smsData || []);
|
||||||
} catch { if (!cancelled) setSmsLogs([]); }
|
} 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.
|
// 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");
|
const phoneNormalized = String(order?.customerPhoneNormalized || order?.customer_phone_normalized || order?.customerPhone || order?.customer_phone || "").replace(/\D/g, "").replace(/^8/, "7");
|
||||||
if (phoneNormalized) {
|
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) {
|
if (pushLogs && pushLogs.length > 0) {
|
||||||
for (const push of pushLogs) {
|
for (const push of pushLogs) {
|
||||||
events.push({
|
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));
|
events.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||||
return events;
|
return events;
|
||||||
}, [history, smsLogs, pushLogs, invitation, cityProfile]);
|
}, [history, smsLogs, autocallLogs, pushLogs, invitation, cityProfile]);
|
||||||
|
|
||||||
const activePushSubscriptions = pushSubscriptions.filter((sub) => sub.is_active);
|
const activePushSubscriptions = pushSubscriptions.filter((sub) => sub.is_active);
|
||||||
const latestPush = pushLogs[0] || null;
|
const latestPush = pushLogs[0] || null;
|
||||||
|
|
@ -618,8 +699,8 @@ export const OrderHistoryTimeline = ({ order, userRole }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<ProgressStepper order={order} smsLogs={smsLogs} />
|
<ProgressStepper order={order} smsLogs={smsLogs} autocallLogs={autocallLogs} />
|
||||||
<StatusSummary order={order} smsLogs={smsLogs} />
|
<StatusSummary order={order} smsLogs={smsLogs} autocallLogs={autocallLogs} />
|
||||||
<div className={`rounded-xl border px-3 py-2.5 ${
|
<div className={`rounded-xl border px-3 py-2.5 ${
|
||||||
activePushSubscriptions.length > 0
|
activePushSubscriptions.length > 0
|
||||||
? "border-[rgba(18,128,92,0.25)] bg-[var(--color-accent-soft)]"
|
? "border-[rgba(18,128,92,0.25)] bg-[var(--color-accent-soft)]"
|
||||||
|
|
@ -693,6 +774,34 @@ export const OrderHistoryTimeline = ({ order, userRole }) => {
|
||||||
</p>
|
</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" && (
|
{ev.type === "reason" && (
|
||||||
<p className={`mt-0.5 text-xs ${
|
<p className={`mt-0.5 text-xs ${
|
||||||
ev.tone === "warning" ? "text-[var(--color-warning)]" : "text-[var(--color-text-muted)]"
|
ev.tone === "warning" ? "text-[var(--color-warning)]" : "text-[var(--color-text-muted)]"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue