feat(client): шаг «Звонок» в цепочке заказа
В блоке «История действий по заказу» автодозвон не был виден в цепочке шагов — там были только «Добавлен», «SMS», «Согласовано» и далее, хотя робот звонит по этим же заказам. Теперь шаг «Звонок» идёт сразу после SMS в обеих цепочках (доставка и самовывоз) и имеет собственное состояние: ✓ пройден, если робот согласовал слот; янтарный — если звонили без результата; серый — если звонков не было. Шаг не считается пройденным только потому, что заказ уже согласован (согласовать могли по ссылке из SMS, без звонка). Подсказка при наведении — последний исход и число звонков. SW: v119 -> v120.
This commit is contained in:
parent
6d8992dfad
commit
16141fe5b4
|
|
@ -1,7 +1,7 @@
|
||||||
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
|
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
|
||||||
|
|
||||||
if (!isLocalhost) {
|
if (!isLocalhost) {
|
||||||
const STATIC_CACHE = "construction-delivery-static-v119";
|
const STATIC_CACHE = "construction-delivery-static-v120";
|
||||||
const RUNTIME_CACHE = "construction-delivery-runtime-v119";
|
const RUNTIME_CACHE = "construction-delivery-runtime-v119";
|
||||||
const APP_SHELL_URLS = ["/", "/client", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
|
const APP_SHELL_URLS = ["/", "/client", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -170,9 +170,17 @@ const getFieldChangeLabel = (changes) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Progress Stepper ────────────────────────────────────────────────
|
// ─── Progress Stepper ────────────────────────────────────────────────
|
||||||
|
// Цепочка шагов заказа. Шаг «Звонок» стоит сразу за SMS: автодозвон — такой же
|
||||||
|
// канал связи с клиентом, как SMS, и в цепочке он обязан быть виден (иначе по
|
||||||
|
// карточке нельзя понять, что робот звонил). Состояние этого шага СВОЁ: он не
|
||||||
|
// считается пройденным только потому, что заказ уже согласован, — согласовать
|
||||||
|
// могли по ссылке из SMS, вообще без звонка.
|
||||||
|
const AUTOCALL_CONFIRMED_OUTCOMES = ["confirmed", "confirmed_silent", "confirmed_kept_previous"];
|
||||||
|
|
||||||
const STAGES_DELIVERY = [
|
const STAGES_DELIVERY = [
|
||||||
{ key: "created", icon: "📥", label: "Добавлен" },
|
{ key: "created", icon: "📥", label: "Добавлен" },
|
||||||
{ key: "sms", icon: "📨", label: "SMS" },
|
{ key: "sms", icon: "📨", label: "SMS" },
|
||||||
|
{ key: "autocall", icon: "🤖", label: "Звонок" },
|
||||||
{ key: "agreed", icon: "✅", label: "Согласовано" },
|
{ key: "agreed", icon: "✅", label: "Согласовано" },
|
||||||
{ key: "driver", icon: "🚚", label: "Водитель" },
|
{ key: "driver", icon: "🚚", label: "Водитель" },
|
||||||
{ key: "delivered", icon: "📦", label: "Доставлено" },
|
{ key: "delivered", icon: "📦", label: "Доставлено" },
|
||||||
|
|
@ -181,10 +189,31 @@ const STAGES_DELIVERY = [
|
||||||
const STAGES_PICKUP = [
|
const STAGES_PICKUP = [
|
||||||
{ key: "created", icon: "📥", label: "Добавлен" },
|
{ key: "created", icon: "📥", label: "Добавлен" },
|
||||||
{ key: "sms", icon: "📨", label: "SMS" },
|
{ key: "sms", icon: "📨", label: "SMS" },
|
||||||
|
{ key: "autocall", icon: "🤖", label: "Звонок" },
|
||||||
{ key: "agreed", icon: "✅", label: "Согласовано" },
|
{ key: "agreed", icon: "✅", label: "Согласовано" },
|
||||||
{ key: "picked_up", icon: "📦", label: "Вывезено" },
|
{ key: "picked_up", icon: "📦", label: "Вывезено" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Состояние шага «Звонок» по журналу автодозвона:
|
||||||
|
// done — робот согласовал слот; warn — звонили, слот не согласован; empty — звонков не было.
|
||||||
|
export const getAutocallStepState = (autocallLogs = []) => {
|
||||||
|
if (!Array.isArray(autocallLogs) || autocallLogs.length === 0) return "empty";
|
||||||
|
if (autocallLogs.some((l) => AUTOCALL_CONFIRMED_OUTCOMES.includes(l?.outcome))) return "done";
|
||||||
|
return "warn";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Подсказка к шагу (title на десктопе): сколько было звонков и чем кончился последний.
|
||||||
|
export const autocallStepHint = (autocallLogs = [], order = null) => {
|
||||||
|
if (!Array.isArray(autocallLogs) || autocallLogs.length === 0) return "Звонков не было";
|
||||||
|
const last = autocallLogs[0];
|
||||||
|
const meta = AUTOCALL_OUTCOME_LABELS[last?.outcome];
|
||||||
|
const what = meta ? meta.label.replace(/^Автодозвон:\s*/, "") : (last?.outcome || "—");
|
||||||
|
const slot = autocallSlotText(last, order);
|
||||||
|
return `Звонков: ${autocallLogs.length} · последний: ${what}${slot ? ` (${slot})` : ""}${last?.was_test_mode ? " · 🧪 тест" : ""}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stageIndex = (stages, key) => stages.findIndex((s) => s.key === key);
|
||||||
|
|
||||||
const getActiveStageIndex = (order, smsLogs = [], autocallLogs = []) => {
|
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 || "";
|
||||||
|
|
@ -203,32 +232,33 @@ const getActiveStageIndex = (order, smsLogs = [], autocallLogs = []) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isPickup && ["driver_assigned", "loaded", "on_route"].includes(deliveryStatus)) {
|
if (!isPickup && ["driver_assigned", "loaded", "on_route"].includes(deliveryStatus)) {
|
||||||
return 3;
|
return stageIndex(stages, "driver");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deliveryStatus === "agreed" || notifStatus === "confirmed") {
|
if (deliveryStatus === "agreed" || notifStatus === "confirmed") {
|
||||||
return 2;
|
return stageIndex(stages, "agreed");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Автодозвон: если робот согласовал слот — это уже «Согласовано»
|
// Автодозвон: если робот согласовал слот — это уже «Согласовано»
|
||||||
const hasAutocallConfirmed = autocallLogs.some((l) => ["confirmed", "confirmed_silent", "confirmed_kept_previous"].includes(l.outcome));
|
const hasAutocallConfirmed = autocallLogs.some((l) => AUTOCALL_CONFIRMED_OUTCOMES.includes(l.outcome));
|
||||||
if (hasAutocallConfirmed) {
|
if (hasAutocallConfirmed) {
|
||||||
return 2;
|
return stageIndex(stages, "agreed");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)) {
|
||||||
return 1;
|
return stageIndex(stages, "sms");
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ProgressStepper = ({ order, smsLogs = [], autocallLogs = [] }) => {
|
export 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, autocallLogs);
|
const activeIdx = getActiveStageIndex(order, smsLogs, autocallLogs);
|
||||||
|
const autocallState = getAutocallStepState(autocallLogs);
|
||||||
|
|
||||||
if (activeIdx === -1) {
|
if (activeIdx === -1) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -255,35 +285,50 @@ const ProgressStepper = ({ order, smsLogs = [], autocallLogs = [] }) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const circleStyles = {
|
||||||
|
done: "bg-[var(--color-accent-soft)] text-[var(--color-accent)]",
|
||||||
|
active: "bg-[var(--color-accent)] text-white ring-2 ring-[var(--color-accent)] ring-offset-2 ring-offset-[var(--color-surface)]",
|
||||||
|
warn: "bg-[rgba(245,158,11,0.14)] text-[var(--color-warning)] ring-1 ring-[rgba(245,158,11,0.45)]",
|
||||||
|
idle: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]",
|
||||||
|
};
|
||||||
|
const labelStyles = {
|
||||||
|
done: "font-medium text-[var(--color-accent)]",
|
||||||
|
active: "font-bold text-[var(--color-text)]",
|
||||||
|
warn: "font-semibold text-[var(--color-warning)]",
|
||||||
|
idle: "text-[var(--color-text-muted)]",
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{stages.map((stage, idx) => {
|
{stages.map((stage, idx) => {
|
||||||
const isDone = idx < activeIdx;
|
const isAutocall = stage.key === "autocall";
|
||||||
const isActive = idx === activeIdx;
|
const isActive = idx === activeIdx;
|
||||||
|
// У шага «Звонок» своё состояние — «пройден» он только при согласованном роботом слоте.
|
||||||
|
const visual = isAutocall
|
||||||
|
? (autocallState === "done" ? "done" : autocallState === "warn" ? "warn" : "idle")
|
||||||
|
: isActive
|
||||||
|
? "active"
|
||||||
|
: idx < activeIdx
|
||||||
|
? "done"
|
||||||
|
: "idle";
|
||||||
|
const lineDone = idx <= activeIdx;
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={stage.key}>
|
<React.Fragment key={stage.key}>
|
||||||
{idx > 0 && (
|
{idx > 0 && (
|
||||||
<div className={`h-0.5 flex-1 min-w-[12px] rounded-full transition-colors ${
|
<div className={`h-0.5 flex-1 min-w-[6px] rounded-full transition-colors ${
|
||||||
isDone ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"
|
lineDone ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"
|
||||||
}`} />
|
}`} />
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col items-center gap-1 flex-shrink-0">
|
<div
|
||||||
|
className="flex flex-col items-center gap-1 flex-shrink-0"
|
||||||
|
title={isAutocall ? autocallStepHint(autocallLogs, order) : undefined}
|
||||||
|
>
|
||||||
<div className={`flex h-7 w-7 items-center justify-center rounded-full text-xs transition-all ${
|
<div className={`flex h-7 w-7 items-center justify-center rounded-full text-xs transition-all ${
|
||||||
isActive
|
circleStyles[visual]
|
||||||
? "bg-[var(--color-accent)] text-white ring-2 ring-[var(--color-accent)] ring-offset-2 ring-offset-[var(--color-surface)]"
|
|
||||||
: isDone
|
|
||||||
? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
|
|
||||||
: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]"
|
|
||||||
}`}>
|
}`}>
|
||||||
{isDone ? "✓" : stage.icon}
|
{visual === "done" ? "✓" : stage.icon}
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-[10px] whitespace-nowrap ${
|
<span className={`text-[10px] whitespace-nowrap ${labelStyles[visual]}`}>
|
||||||
isActive
|
|
||||||
? "font-bold text-[var(--color-text)]"
|
|
||||||
: isDone
|
|
||||||
? "font-medium text-[var(--color-accent)]"
|
|
||||||
: "text-[var(--color-text-muted)]"
|
|
||||||
}`}>
|
|
||||||
{stage.label}
|
{stage.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
import React from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { ProgressStepper, getAutocallStepState, autocallStepHint } from "./OrderHistoryTimeline";
|
||||||
|
|
||||||
|
const orderDelivery = {
|
||||||
|
id: "g-1",
|
||||||
|
deliveryType: "delivery",
|
||||||
|
deliveryStatus: "pending_confirmation",
|
||||||
|
notificationStatus: "first_sms_sent",
|
||||||
|
firstSmsSentAt: "2026-09-27T10:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const orderPickup = {
|
||||||
|
id: "g-2",
|
||||||
|
deliveryType: "pickup",
|
||||||
|
deliveryStatus: "pending_confirmation",
|
||||||
|
notificationStatus: "first_sms_sent",
|
||||||
|
firstSmsSentAt: "2026-09-27T10:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const call = (outcome, extra = {}) => ({
|
||||||
|
id: Math.random().toString(36).slice(2),
|
||||||
|
outcome,
|
||||||
|
attempt_no: 1,
|
||||||
|
created_at: "2026-09-28T10:00:00Z",
|
||||||
|
was_test_mode: false,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("шаг «Звонок» в цепочке заказа", () => {
|
||||||
|
it("цепочка доставки содержит шаг звонка сразу после SMS", () => {
|
||||||
|
const markup = renderToStaticMarkup(<ProgressStepper order={orderDelivery} smsLogs={[]} autocallLogs={[]} />);
|
||||||
|
const labels = ["Добавлен", "SMS", "Звонок", "Согласовано", "Водитель", "Доставлено"];
|
||||||
|
let prev = -1;
|
||||||
|
for (const l of labels) {
|
||||||
|
const at = markup.indexOf(`>${l}</span>`);
|
||||||
|
expect(at, `шаг «${l}» есть в цепочке`).toBeGreaterThan(-1);
|
||||||
|
expect(at, `шаг «${l}» идёт по порядку`).toBeGreaterThan(prev);
|
||||||
|
prev = at;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("цепочка самовывоза тоже содержит звонок и заканчивается «Вывезено»", () => {
|
||||||
|
const markup = renderToStaticMarkup(<ProgressStepper order={orderPickup} smsLogs={[]} autocallLogs={[]} />);
|
||||||
|
expect(markup).toContain(">Звонок</span>");
|
||||||
|
expect(markup).toContain(">Вывезено</span>");
|
||||||
|
expect(markup).not.toContain(">Водитель</span>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("без звонков шаг «Звонок» не отмечен пройденным", () => {
|
||||||
|
const markup = renderToStaticMarkup(<ProgressStepper order={orderDelivery} smsLogs={[]} autocallLogs={[]} />);
|
||||||
|
expect(getAutocallStepState([])).toBe("empty");
|
||||||
|
// ни один ✓ не должен стоять на шаге звонка: до звонка и на нём самом пройдены только Добавлен и SMS
|
||||||
|
const callSegment = markup.slice(markup.indexOf(">Звонок</span>") - 400, markup.indexOf(">Звонок</span>"));
|
||||||
|
expect(callSegment.split("✓").length - 1).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("согласованный роботом слот отмечает шаг «Звонок» пройденным", () => {
|
||||||
|
const logs = [call("confirmed")];
|
||||||
|
const markup = renderToStaticMarkup(<ProgressStepper order={{ ...orderDelivery, deliveryStatus: "agreed", notificationStatus: "confirmed" }} smsLogs={[]} autocallLogs={logs} />);
|
||||||
|
expect(getAutocallStepState(logs)).toBe("done");
|
||||||
|
const callSegment = markup.slice(markup.indexOf(">Звонок</span>") - 400, markup.indexOf(">Звонок</span>"));
|
||||||
|
expect(callSegment).toContain("✓");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("неудачные звонки дают состояние «требует внимания», а не «пройдено»", () => {
|
||||||
|
const logs = [call("no_answer")];
|
||||||
|
expect(getAutocallStepState(logs)).toBe("warn");
|
||||||
|
const markup = renderToStaticMarkup(<ProgressStepper order={orderDelivery} smsLogs={[]} autocallLogs={logs} />);
|
||||||
|
const callSegment = markup.slice(markup.indexOf(">Звонок</span>") - 400, markup.indexOf(">Звонок</span>"));
|
||||||
|
expect(callSegment).not.toContain("✓");
|
||||||
|
expect(callSegment).toContain("🤖");
|
||||||
|
expect(callSegment).toContain("--color-warning");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("подсказка к шагу описывает последний звонок словами логиста", () => {
|
||||||
|
expect(autocallStepHint([])).toBe("Звонков не было");
|
||||||
|
expect(autocallStepHint([call("no_answer")])).toBe("Звонков: 1 · последний: не взял трубку");
|
||||||
|
expect(autocallStepHint([call("confirmed", { was_test_mode: true })]))
|
||||||
|
.toBe("Звонков: 1 · последний: клиент согласовал слот · 🧪 тест");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue