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";
|
||||
|
||||
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"];
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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