diff --git a/src/components/UI/CallStatusBadge.jsx b/src/components/UI/CallStatusBadge.jsx index d9d0794..9ba1185 100644 --- a/src/components/UI/CallStatusBadge.jsx +++ b/src/components/UI/CallStatusBadge.jsx @@ -1,34 +1,66 @@ /** * @file CallStatusBadge.jsx - * @description Бейдж коммуникационного статуса для строк реестра групп. - * Показывает проблему автодозвона снаружи, не открывая карточку. - * Приоритет сверху вниз — один бейдж наружу. + * @description Бейдж автодозвона в строке реестра доставок. + * + * Раньше наружу выходили две безликие плашки: «Автодозвон ✅» и «Автодозвон ❌» — + * по ним нельзя было понять, не дозвонились, не нажали цифру или заказ ушёл + * менеджеру. Теперь у каждого состояния своя подпись, взятая из + * AUTOCALL_STATUS_META (тот же список, что в фильтрах, — не разъезжается). + * + * «Не звонили» показываем только по заказам, которые ещё ждут решения + * (pending_confirmation / manual_confirmation_required): в уже доставленных + * заказах такая плашка — просто шум. */ import React from "react"; -// Приоритет: отказ > исключение > неверный номер > менеджеру > не нажимает > не дозвонились > слот от робота -export const resolveCallBadge = (g) => { - if (!g) return null; +import { AUTOCALL_STATUS_META } from "../../services/orderGroupViews"; - // 1. Отказ от звонков - if (g.autocall_opt_out) { - return { label: "🚫 отказ от звонков", cls: "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]", title: "Клиент отказался от автозвонков" }; - } +/** Статусы, при которых заказ ещё ждёт решения клиента. */ +const AWAITING_DELIVERY_STATUSES = ["pending_confirmation", "manual_confirmation_required"]; - // 2. Исключение по настройке (префикс/номер) — не звоним по условию - if (g.autocall_exclusion_reason) { - return { label: "🚫 не звоним (исключение)", cls: "bg-[rgba(191,123,33,0.15)] text-[var(--color-warning)]", title: `Исключение автодозвона: ${g.autocall_exclusion_reason}` }; - } +const STYLES = { + confirmed: "bg-[rgba(18,128,92,0.14)] text-[var(--color-accent)]", + no_answer: "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]", + no_input: "bg-[rgba(245,158,11,0.14)] text-[var(--color-warning)]", + manual: "bg-[rgba(245,158,11,0.14)] text-[var(--color-warning)]", + opt_out: "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]", + excluded: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]", + not_called: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]", +}; - // 3. Результат автодозвона: ✅ согласовано / ❌ ошибка (не дозвонились, не нажал и т.п.) - if (g.autocallStatus === "confirmed") { - return { label: "🤖 Автодозвон ✅", cls: "bg-[rgba(18,128,92,0.14)] text-[var(--color-accent)]", title: "Клиент согласовал слот по автодозвону" }; - } - if (g.autocallStatus && g.autocallStatus !== "not_called") { - return { label: "🤖 Автодозвон ❌", cls: "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]", title: "Автодозвон не удался" }; - } +const TITLES = { + confirmed: "Клиент согласовал день и половину дня по звонку робота", + no_answer: "Робот звонил, трубку не взяли", + no_input: "Трубку взяли, но цифру в меню не нажали", + manual: "Нужен менеджер: неверный номер или ошибка звонка", + opt_out: "Звонки по этому клиенту запрещены вручную", + excluded: "Исключение: по этому номеру или городу не звоним", + not_called: "Автодозвона по заказу ещё не было", +}; - return null; +/** Ждёт ли заказ решения (тогда «не звонили» стоит показать). */ +export const isAwaitingDecision = (group) => { + if (!group) return false; + const status = group.deliveryStatus || group.delivery_status || ""; + return AWAITING_DELIVERY_STATUSES.includes(status); +}; + +/** Что показать в строке: null — показывать нечего. */ +export const resolveCallBadge = (group) => { + if (!group) return null; + const status = group.autocallStatus || group.autocall_status || "not_called"; + // Запрет и исключение важны всегда, независимо от стадии заказа. + if (status === "not_called" && !isAwaitingDecision(group)) return null; + const meta = AUTOCALL_STATUS_META[status]; + if (!meta) return null; + return { + status, + label: `${meta.icon} ${meta.short}`, + cls: STYLES[status] || STYLES.not_called, + title: status === "excluded" && group.autocall_exclusion_reason + ? `Исключение автодозвона: ${group.autocall_exclusion_reason}` + : TITLES[status] || meta.label, + }; }; export const CallStatusBadge = ({ group }) => { diff --git a/src/components/UI/CallStatusBadge.test.jsx b/src/components/UI/CallStatusBadge.test.jsx new file mode 100644 index 0000000..18cc365 --- /dev/null +++ b/src/components/UI/CallStatusBadge.test.jsx @@ -0,0 +1,87 @@ +/** + * @file CallStatusBadge.test.jsx + * @description Бейдж автодозвона в реестре доставок: по плашке должно быть понятно, + * что с заказом — не звонили, согласовано, не дозвонились, не нажал цифру, + * нужен менеджер, запрет или исключение. Без «Автодозвон ❌» на всё подряд. + */ +import { describe, it, expect } from "vitest"; + +import { CallStatusBadge, isAwaitingDecision, resolveCallBadge } from "./CallStatusBadge"; +import { AUTOCALL_STATUS_META, AUTOCALL_STATUS_OPTIONS } from "../../services/orderGroupViews"; + +const group = (over = {}) => ({ id: "g1", deliveryStatus: "pending_confirmation", ...over }); + +/** Текст из разметки без служебных пробелов — как в остальных тестах проекта. */ +const plain = (text) => String(text).replace(/[\u00a0\u202f]/g, " "); + +describe("resolveCallBadge — что видно в строке", () => { + it("не звонили и заказ ещё ждёт решения — плашка есть", () => { + const badge = resolveCallBadge(group({ autocallStatus: "not_called" })); + expect(badge.label).toContain("звонка не было"); + expect(badge.title).toContain("Автодозвона по заказу ещё не было"); + }); + + it("не звонили, но заказ уже доставлен — плашки нет (не засоряем таблицу)", () => { + expect(resolveCallBadge(group({ autocallStatus: "not_called", deliveryStatus: "delivered" }))).toBeNull(); + }); + + it("у каждого исхода своя подпись, а не одна общая «Автодозвон ❌»", () => { + const labels = ["confirmed", "no_answer", "no_input", "manual"].map( + (status) => resolveCallBadge(group({ autocallStatus: status })).label, + ); + expect(new Set(labels).size).toBe(labels.length); + expect(labels[0]).toContain("согласовано"); + expect(labels[1]).toContain("не дозвонились"); + expect(labels[2]).toContain("не нажал цифру"); + expect(labels[3]).toContain("нужен менеджер"); + }); + + it("запрет звонков и исключение видны даже в завершённом заказе", () => { + const stop = resolveCallBadge(group({ autocallStatus: "opt_out", deliveryStatus: "delivered" })); + expect(stop.label).toContain("не звонить"); + const excluded = resolveCallBadge(group({ + autocallStatus: "excluded", + deliveryStatus: "delivered", + autocall_exclusion_reason: "МТС-диапазон", + })); + expect(excluded.title).toContain("МТС-диапазон"); + }); + + it("неизвестный статус не рисуем", () => { + expect(resolveCallBadge(group({ autocallStatus: "какой-то-старый" }))).toBeNull(); + }); + + it("в разметке стоит именно подпись статуса", () => { + const html = CallStatusBadge({ group: group({ autocallStatus: "no_answer" }) }); + expect(plain(html.props.className)).toContain("rounded-full"); + expect(plain(html.props.children)).toContain("не дозвонились"); + }); +}); + +describe("isAwaitingDecision", () => { + it("ждёт решения только ожидающий подтверждения заказ", () => { + expect(isAwaitingDecision({ deliveryStatus: "pending_confirmation" })).toBe(true); + expect(isAwaitingDecision({ deliveryStatus: "manual_confirmation_required" })).toBe(true); + expect(isAwaitingDecision({ deliveryStatus: "delivered" })).toBe(false); + expect(isAwaitingDecision({ delivery_status: "agreed" })).toBe(false); + expect(isAwaitingDecision(null)).toBe(false); + }); +}); + +describe("единый список статусов", () => { + it("фильтр и подписи не расходятся: у каждого пункта фильтра есть подпись", () => { + for (const option of AUTOCALL_STATUS_OPTIONS) { + expect(AUTOCALL_STATUS_META[option.value]).toBeTruthy(); + } + expect(AUTOCALL_STATUS_OPTIONS).toHaveLength(Object.keys(AUTOCALL_STATUS_META).length); + }); + + it("в списках нет формулировок из удалённых сценариев", () => { + const all = [ + ...AUTOCALL_STATUS_OPTIONS.map((o) => o.label), + ...Object.values(AUTOCALL_STATUS_META).map((m) => `${m.label} ${m.short}`), + ].join(" ").toLowerCase(); + expect(all).not.toContain("отказ от звонков"); + expect(all).not.toContain("требует ручного управления"); + }); +}); diff --git a/src/components/admin/autocall/AutocallChannelTab.jsx b/src/components/admin/autocall/AutocallChannelTab.jsx index 0d507da..3c2e132 100644 --- a/src/components/admin/autocall/AutocallChannelTab.jsx +++ b/src/components/admin/autocall/AutocallChannelTab.jsx @@ -29,8 +29,6 @@ const OUTCOME_META = { 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: "⚠️" }, }; diff --git a/src/components/admin/autocall/TestCallPanel.jsx b/src/components/admin/autocall/TestCallPanel.jsx index 821c866..56e6a20 100644 --- a/src/components/admin/autocall/TestCallPanel.jsx +++ b/src/components/admin/autocall/TestCallPanel.jsx @@ -20,8 +20,6 @@ const OUTCOME_META = { 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: "⚠️" }, }; diff --git a/src/components/orders/AutocallStatusCard.jsx b/src/components/orders/AutocallStatusCard.jsx index e7f65c3..42e8d66 100644 --- a/src/components/orders/AutocallStatusCard.jsx +++ b/src/components/orders/AutocallStatusCard.jsx @@ -16,8 +16,6 @@ const OUTCOME_META = { 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: "⚠️" }, }; diff --git a/src/components/orders/OrderFilters.jsx b/src/components/orders/OrderFilters.jsx index 7769ec5..aad74fc 100644 --- a/src/components/orders/OrderFilters.jsx +++ b/src/components/orders/OrderFilters.jsx @@ -4,6 +4,7 @@ import { Input } from "../UI/Input"; import { Panel } from "../UI/Panel"; import { DateRangePicker } from "../UI/DateRangePicker"; import { Switch } from "../UI/Switch"; +import { AUTOCALL_STATUS_OPTIONS } from "../../services/orderGroupViews"; const DropdownSelect = ({ value, options, onChange, placeholder }) => { const [isOpen, setIsOpen] = React.useState(false); @@ -131,13 +132,7 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities = const autocallStatusOptions = [ { value: "all", label: "Статус автодозвона" }, - { value: "not_called", label: "Не звонили" }, - { value: "confirmed", label: "Согласовано" }, - { value: "no_answer", label: "Не дозвонились" }, - { value: "no_input", label: "Не нажал в меню" }, - { value: "manual", label: "Требует ручного управления" }, - { value: "opt_out", label: "Отказ от звонков" }, - { value: "excluded", label: "Не звоним (исключение)" }, + ...AUTOCALL_STATUS_OPTIONS, ]; return ( diff --git a/src/services/orderGroupViews.js b/src/services/orderGroupViews.js index fa2fbc7..c7cbaeb 100644 --- a/src/services/orderGroupViews.js +++ b/src/services/orderGroupViews.js @@ -338,16 +338,30 @@ export const isSmsFundsError = (log) => smsErrorCode(log) === SMS_FUNDS_ERROR_CO // ─── Автодозвон: единый человеческий статус (по аналогии со статусом SMS) ── // Принимает массив autocall_log (отсортированный по created_at desc) или null. +// ВАЖНО: те же значения приходят из базы (функция autocall_status(order_groups)) — +// набор ключей менять только вместе с ней, иначе фильтр и бейдж разойдутся. +// Это единственный источник подписей: их берут и бейдж в таблице, и фильтры. export const AUTOCALL_STATUS_META = { - not_called: { label: "Не звонили", tone: "neutral" }, - confirmed: { label: "Согласовано", tone: "accent" }, - no_answer: { label: "Не дозвонились", tone: "danger" }, - no_input: { label: "Не нажал в меню", tone: "warning" }, - manual: { label: "Требует ручного управления", tone: "warning" }, - opt_out: { label: "Отказ от звонков", tone: "danger" }, - excluded: { label: "Не звоним (исключение)", tone: "warning" }, + not_called: { label: "Не звонили", short: "звонка не было", tone: "neutral", icon: "🤖" }, + confirmed: { label: "Согласовано", short: "согласовано", tone: "accent", icon: "✅" }, + no_answer: { label: "Не дозвонились", short: "не дозвонились", tone: "danger", icon: "📵" }, + no_input: { label: "Не нажал в меню", short: "не нажал цифру", tone: "warning", icon: "🤐" }, + manual: { label: "Нужен менеджер", short: "нужен менеджер", tone: "warning", icon: "🆘" }, + opt_out: { label: "Не звонить", short: "не звонить", tone: "danger", icon: "🚫" }, + excluded: { label: "Не звоним (исключение)", short: "исключение", tone: "neutral", icon: "🚫" }, }; +/** Порядок для выпадающих списков — один список на всё приложение. */ +export const AUTOCALL_STATUS_OPTIONS = [ + { value: "not_called", label: "Не звонили" }, + { value: "confirmed", label: "Согласовано" }, + { value: "no_answer", label: "Не дозвонились" }, + { value: "no_input", label: "Не нажал в меню" }, + { value: "manual", label: "Нужен менеджер" }, + { value: "opt_out", label: "Не звонить (запрет вручную)" }, + { value: "excluded", label: "Не звоним (исключение)" }, +]; + export const resolveAutocallStatus = (calls = [], { optOut = false, exclusionReason = null } = {}) => { if (optOut) return "opt_out"; if (exclusionReason) return "excluded"; @@ -508,7 +522,7 @@ export const ORDER_GROUP_DISPLAY_STATUS_OPTIONS = [ { value: "delivery:problem", label: DELIVERY_GROUP_STATUS_LABELS.problem }, { value: "delivery:paid_storage", label: DELIVERY_GROUP_STATUS_LABELS.paid_storage }, { value: "delivery:cancelled", label: DELIVERY_GROUP_STATUS_LABELS.cancelled }, - { value: "status:manual_required", label: "Требует ручной обработки" }, + { value: "status:manual_required", label: "Нужен менеджер" }, { value: "status:ready_for_notification", label: "Готово к уведомлению" }, { value: "status:ready_to_launch", label: "Готово к запуску" }, { value: "status:first_sms_sent", label: NOTIFICATION_STATUS_LABELS.first_sms_sent },