diff --git a/public/service-worker.js b/public/service-worker.js index adbfca9..8d5f0f4 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -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"]; diff --git a/src/components/orders/OrderHistoryTimeline.jsx b/src/components/orders/OrderHistoryTimeline.jsx index 1f5a8b1..90727c7 100644 --- a/src/components/orders/OrderHistoryTimeline.jsx +++ b/src/components/orders/OrderHistoryTimeline.jsx @@ -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 (