feat(client): шаг «Звонок» в цепочке заказа

В блоке «История действий по заказу» автодозвон не был виден в цепочке шагов —
там были только «Добавлен», «SMS», «Согласовано» и далее, хотя робот звонит по
этим же заказам. Теперь шаг «Звонок» идёт сразу после SMS в обеих цепочках
(доставка и самовывоз) и имеет собственное состояние: ✓ пройден, если робот
согласовал слот; янтарный — если звонили без результата; серый — если звонков
не было. Шаг не считается пройденным только потому, что заказ уже согласован
(согласовать могли по ссылке из SMS, без звонка). Подсказка при наведении —
последний исход и число звонков.

SW: v119 -> v120.
This commit is contained in:
Hermes 2026-09-28 14:56:17 +02:00
parent 6d8992dfad
commit 16141fe5b4
3 changed files with 152 additions and 24 deletions

View File

@ -1,7 +1,7 @@
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
if (!isLocalhost) {
const STATIC_CACHE = "construction-delivery-static-v119";
const STATIC_CACHE = "construction-delivery-static-v120";
const RUNTIME_CACHE = "construction-delivery-runtime-v119";
const APP_SHELL_URLS = ["/", "/client", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];

View File

@ -170,9 +170,17 @@ const getFieldChangeLabel = (changes) => {
};
// ─── Progress Stepper ────────────────────────────────────────────────
// Цепочка шагов заказа. Шаг «Звонок» стоит сразу за SMS: автодозвон — такой же
// канал связи с клиентом, как SMS, и в цепочке он обязан быть виден (иначе по
// карточке нельзя понять, что робот звонил). Состояние этого шага СВОЁ: он не
// считается пройденным только потому, что заказ уже согласован, — согласовать
// могли по ссылке из SMS, вообще без звонка.
const AUTOCALL_CONFIRMED_OUTCOMES = ["confirmed", "confirmed_silent", "confirmed_kept_previous"];
const STAGES_DELIVERY = [
{ key: "created", icon: "📥", label: "Добавлен" },
{ key: "sms", icon: "📨", label: "SMS" },
{ key: "autocall", icon: "🤖", label: "Звонок" },
{ key: "agreed", icon: "✅", label: "Согласовано" },
{ key: "driver", icon: "🚚", label: "Водитель" },
{ key: "delivered", icon: "📦", label: "Доставлено" },
@ -181,10 +189,31 @@ const STAGES_DELIVERY = [
const STAGES_PICKUP = [
{ key: "created", icon: "📥", label: "Добавлен" },
{ key: "sms", icon: "📨", label: "SMS" },
{ key: "autocall", icon: "🤖", label: "Звонок" },
{ key: "agreed", 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 deliveryStatus = order?.deliveryStatus || order?.delivery_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)) {
return 3;
return stageIndex(stages, "driver");
}
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) {
return 2;
return stageIndex(stages, "agreed");
}
// 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)) {
return 1;
return stageIndex(stages, "sms");
}
return 0;
};
const ProgressStepper = ({ order, smsLogs = [], autocallLogs = [] }) => {
export 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, autocallLogs);
const autocallState = getAutocallStepState(autocallLogs);
if (activeIdx === -1) {
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 (
<div className="flex items-center gap-1">
{stages.map((stage, idx) => {
const isDone = idx < activeIdx;
const isAutocall = stage.key === "autocall";
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 (
<React.Fragment key={stage.key}>
{idx > 0 && (
<div className={`h-0.5 flex-1 min-w-[12px] rounded-full transition-colors ${
isDone ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"
<div className={`h-0.5 flex-1 min-w-[6px] rounded-full transition-colors ${
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 ${
isActive
? "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)]"
circleStyles[visual]
}`}>
{isDone ? "✓" : stage.icon}
{visual === "done" ? "✓" : stage.icon}
</div>
<span className={`text-[10px] whitespace-nowrap ${
isActive
? "font-bold text-[var(--color-text)]"
: isDone
? "font-medium text-[var(--color-accent)]"
: "text-[var(--color-text-muted)]"
}`}>
<span className={`text-[10px] whitespace-nowrap ${labelStyles[visual]}`}>
{stage.label}
</span>
</div>

View File

@ -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 · последний: клиент согласовал слот · 🧪 тест");
});
});