const normalizeDate = (value) => (value ? String(value) : ""); // Слот заказа: у самовывоза дата и время выдачи лежат в pickup_*, у доставки — в delivery_*. // Панели и фильтры раньше смотрели только на delivery_*, поэтому у самовывоза колонка даты // пустовала, а фильтр по периоду выкидывал такие заказы. export const isPickupGroup = (group) => (group?.deliveryType || group?.delivery_type) === "pickup" || (group?.deliveryStatus || group?.delivery_status) === "pickup"; export const getGroupSlotDate = (group) => normalizeDate( isPickupGroup(group) ? group?.pickupDate || group?.pickup_date || "" : group?.deliveryDate || group?.delivery_date || "", ); export const getGroupSlotTime = (group) => (isPickupGroup(group) ? group?.pickupTimeSlot || group?.pickup_time_slot || "" : group?.deliveryTime || group?.delivery_time || "") || ""; // Дата, по которой группируют и фильтруют реестр: согласованный слот, иначе дата заказа. const getDeliveryDate = (group) => normalizeDate(getGroupSlotDate(group) || group.customerDate || ""); export const DELIVERY_GROUP_STATUS_LABELS = { pending_confirmation: "Ожидает согласования", manual_confirmation_required: "Взят в ручное управление", requires_address: "Требуется адрес", agreed: "Согласовано", driver_assigned: "Назначен водитель", loaded: "Загружено", on_route: "В пути", delivered: "Доставлено", picked_up: "Вывезено", problem: "Проблема", paid_storage: "Платное хранение", pickup: "Самовывоз", cancelled: "Отменено", }; export const NOTIFICATION_STATUS_LABELS = { not_started: "", link_ready: "Ссылка готова", first_sms_sent: "1-е приглашение отправлено", second_sms_sent: "2-е приглашение отправлено", send_failed: "Ошибка отправки", confirmed: "Согласовано", manual_required: "Требуется ручное подтверждение", }; export const DRIVER_VISIBLE_DELIVERY_STATUSES = [ "agreed", "driver_assigned", "loaded", "on_route", "problem", "delivered", "picked_up", "paid_storage", ]; export const DRIVER_ACTIVE_DELIVERY_STATUSES = ["driver_assigned", "problem"]; const HALF_DAY_LABELS = { morning: "Первая половина дня", afternoon: "Вторая половина дня", }; const normalizeDeliveryHalfDayLabel = (value) => { const normalized = normalizeDate(value).trim(); if (!normalized) { return ""; } const lower = normalized.toLowerCase(); if (lower.includes("до обеда") || lower.includes("первая половина дня") || lower.includes("утро")) { return HALF_DAY_LABELS.morning; } if (lower.includes("после обеда") || lower.includes("вторая половина дня") || lower.includes("вечер")) { return HALF_DAY_LABELS.afternoon; } return ""; }; const parseJsonIfNeeded = (value) => { if (typeof value !== "string") { return value; } try { return JSON.parse(value); } catch { return value; } }; const findDeliveryHalfDayInValue = (value) => { const parsedValue = parseJsonIfNeeded(value); if (Array.isArray(parsedValue)) { for (const item of parsedValue) { const match = findDeliveryHalfDayInValue(item); if (match) { return match; } } return ""; } if (parsedValue && typeof parsedValue === "object") { const candidates = [ parsedValue.deliveryTime, parsedValue.delivery_time, parsedValue.time, parsedValue.deliveryHalfDay, parsedValue.delivery_half_day, parsedValue.window, parsedValue.deliveryWindow, parsedValue.delivery_window, parsedValue.slot?.time, parsedValue.deliverySlot?.time, ]; for (const candidate of candidates) { const match = normalizeDeliveryHalfDayLabel(candidate); if (match) { return match; } } for (const nestedValue of Object.values(parsedValue)) { const match = findDeliveryHalfDayInValue(nestedValue); if (match) { return match; } } } return normalizeDeliveryHalfDayLabel(parsedValue); }; export const getOrderGroupDeliveryHalfDay = (group) => normalizeDeliveryHalfDayLabel( group?.deliveryHalfDay || group?.deliveryTime || group?.deliveryWindow || findDeliveryHalfDayInValue(group?.sourceOrders), ); export const isOrderGroupAgreedForDelivery = (group) => { if (!group) { return false; } return isOrderGroupVisibleToDriver(group); }; export const getOrderGroupDeliveryStatusLabel = (status) => DELIVERY_GROUP_STATUS_LABELS[status] || (status ? `Неизвестно (${status})` : "Неизвестно"); // Status values that represent a delivery state (not SMS/notification state) const DELIVERY_STATUS_VALUES = new Set([ "pending_confirmation", "agreed", "driver_assigned", "loaded", "on_route", "delivered", "picked_up", "pickup", "requires_address", "problem", "cancelled", "paid_storage", "address_required", "manual_confirmation_required", ]); export const getOrderGroupDisplayStatusLabel = (group) => { const statusCol = group?.status; const notificationStatus = group?.notificationStatus || group?.notification_status; const deliveryStatus = group?.deliveryStatus || group?.delivery_status; // When auto-SMS failed and logistics hasn't taken action yet → show as a todo item const isManualRequired = notificationStatus === "manual_required"; const isStillPending = !deliveryStatus || deliveryStatus === "pending_confirmation" || deliveryStatus === "manual_confirmation_required"; if (isManualRequired && isStillPending) { return "Требуется ручное управление"; } // Primary: status column (now synced with delivery_status) if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") { return getOrderGroupDeliveryStatusLabel(statusCol); } // Fallback: delivery_status (for pending/manual_confirmation groups) if (deliveryStatus && deliveryStatus !== "pending_confirmation" && deliveryStatus !== "manual_confirmation_required") { return getOrderGroupDeliveryStatusLabel(deliveryStatus); } const notificationLabel = NOTIFICATION_STATUS_LABELS[notificationStatus]; if (notificationLabel && notificationStatus !== "link_ready" && notificationStatus !== "not_started") { return notificationLabel; } return getOrderGroupStatusLabel(statusCol); }; export const getOrderGroupDisplayStatusValue = (group) => { const statusCol = group?.status; const notificationStatus = group?.notificationStatus || group?.notification_status; const deliveryStatus = group?.deliveryStatus || group?.delivery_status; // Unify manual_required into a single bucket regardless of delivery_status detail const isManualRequired = notificationStatus === "manual_required"; const isStillPending = !deliveryStatus || deliveryStatus === "pending_confirmation" || deliveryStatus === "manual_confirmation_required"; if (isManualRequired && isStillPending) { return "status:manual_required"; } // Primary: status column (now synced with delivery_status) if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") { return `delivery:${statusCol}`; } // Fallback: delivery_status if (deliveryStatus && deliveryStatus !== "pending_confirmation" && deliveryStatus !== "manual_confirmation_required") { return `delivery:${deliveryStatus}`; } return `status:${statusCol || "unknown"}`; }; export const isOrderGroupVisibleToDriver = (group) => { const deliveryStatus = group?.deliveryStatus || group?.delivery_status || "pending_confirmation"; return DRIVER_VISIBLE_DELIVERY_STATUSES.includes(deliveryStatus); }; export const parseGroupDate = (value) => { const normalized = normalizeDate(value); if (!normalized) { return null; } const isoDateMatch = normalized.match(/^(\d{4})-(\d{2})-(\d{2})$/); if (isoDateMatch) { return new Date(normalized); } const shortDateMatch = normalized.match(/^(\d{2})\.(\d{2})\.(\d{2})$/); if (shortDateMatch) { const [, day, month, year] = shortDateMatch; return new Date(Date.UTC(Number(`20${year}`), Number(month) - 1, Number(day))); } const fullDateMatch = normalized.match(/^(\d{2})\.(\d{2})\.(\d{4})$/); if (fullDateMatch) { const [, day, month, year] = fullDateMatch; return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day))); } const dotSeparatedMatch = normalized.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/); if (dotSeparatedMatch) { const [, day, month, year] = dotSeparatedMatch; return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day))); } const parsed = new Date(normalized); return Number.isNaN(parsed.getTime()) ? null : parsed; }; /** * SMS status used for both table highlighting and filtering. * Returns "not_sent" | "sent" | "delivered" | "error". */ export const getSmsStatus = (group) => { if (!group) return "not_sent"; const firstSms = group.firstSmsSentAt || group.first_sms_sent_at; const secondSms = group.secondSmsSentAt || group.second_sms_sent_at; const lastError = group.lastSmsError; // Sent an SMS (1st or 2nd) and it has a recorded delivery error → error. if ((firstSms || secondSms) && lastError) return "error"; // SMS should be expected but not sent at all yet. if (!firstSms && !secondSms) return "not_sent"; // Sent but no error recorded — assume delivered / in progress. return "delivered"; }; // Для обратной совместимости export const getSmsProblemTone = (group) => { const s = getSmsStatus(group); if (s === "error") return "not_delivered"; if (s === "not_sent") return "not_sent"; return "ok"; }; // ─── Статус SMS: человеческие значения для фильтра ───────────────── export const SMS_STATUS_META = { not_sent: { label: "Не отправлено", tone: "neutral" }, sent: { label: "Отправлено", tone: "info" }, delivered: { label: "Доставлено", tone: "accent" }, error: { label: "Ошибка", tone: "danger" }, }; // ─── Причина неудачной отправки SMS ──────────────────────────────── // Коды SMS-сервиса (первая строка ответа) → человеческая причина. // В логе лежит только цифра, поэтому расшифровываем её здесь. export const SMS_ERROR_REASONS = { "100": "сообщение в очереди SMS-сервиса", "101": "сообщение передано оператору", "102": "сообщение в пути", "104": "истёк срок доставки сообщения", "105": "сообщение удалено оператором", "106": "сбой на телефоне абонента", "107": "оператор не сообщил причину", "108": "оператор отклонил сообщение", "130": "лимит сообщений на этот номер в сутки", "131": "лимит одинаковых сообщений в минуту", "132": "лимит одинаковых сообщений в сутки", "200": "неверный ключ доступа к SMS-сервису", "201": "на счёте SMS-сервиса закончились средства", "202": "неверный номер получателя", "203": "пустой текст сообщения", "230": "исчерпан общий суточный лимит аккаунта", "231": "лимит одинаковых сообщений в минуту", "232": "лимит одинаковых сообщений в сутки", }; // Код нехватки средств — по нему показываем отдельное предупреждение. export const SMS_FUNDS_ERROR_CODE = "201"; const smsErrorCode = (log) => { if (!log) return ""; const direct = String(log.sms_code ?? "").trim(); if (direct) return direct.match(/\d{3}/)?.[0] || direct; const fromText = String(log.error_message ?? "").trim(); return fromText.match(/\d{3}/)?.[0] || ""; }; /** * Причина, по которой SMS не ушла — словами. Принимает строку лога * (sms_campaign_log) либо значение lastSmsError у группы. */ export const getSmsFailureReason = (log) => { if (!log) return null; // В некоторых местах причина лежит просто строкой: "201" или "201\n" if (typeof log === "string") { const text = log.trim(); const onlyCode = text.match(/^(\d{3})$/)?.[1]; if (onlyCode && SMS_ERROR_REASONS[onlyCode]) return SMS_ERROR_REASONS[onlyCode]; // Технические строки из планировщика вида «Not delivered after 2 attempts // (code=106)» переводим в человеческие: код объясняем словами. const embedded = text.match(/code\s*=\s*(\d{3})/i)?.[1]; if (embedded && SMS_ERROR_REASONS[embedded]) { return `${SMS_ERROR_REASONS[embedded]} (код ${embedded})`; } // «Не доставлено после N попыток» без кода — говорим так, как есть. const attempts = text.match(/after\s+(\d+)\s+attempts?/i); if (/not delivered|delivery failed|failed after/i.test(text)) { return attempts ? `клиент не получил сообщение за ${attempts[1]} попытки — телефон недоступен или оператор не доставил` : "клиент не получил сообщение — телефон недоступен или оператор не доставил"; } if (/limit exceeded/i.test(text)) { return embedded && SMS_ERROR_REASONS[embedded] ? `${SMS_ERROR_REASONS[embedded]} (код ${embedded})` : "сработал лимит отправки — SMS-сервис временно блокирует рассылку"; } return text.slice(0, 120) || null; } const code = smsErrorCode(log); if (code && SMS_ERROR_REASONS[code]) return SMS_ERROR_REASONS[code]; const raw = String(log.error_message ?? "").trim(); if (code) return `код ${code}`; if (!raw) return null; return raw.slice(0, 120); }; /** Нехватка средств на счёте SMS-сервиса — самая частая и заметная причина. */ export const isSmsFundsError = (log) => smsErrorCode(log) === SMS_FUNDS_ERROR_CODE; // ─── Автодозвон: единый человеческий статус (по аналогии со статусом SMS) ── // Принимает массив autocall_log (отсортированный по created_at desc) или null. // ВАЖНО: те же значения приходят из базы (функция autocall_status(order_groups)) — // набор ключей менять только вместе с ней, иначе фильтр и бейдж разойдутся. // Это единственный источник подписей: их берут и бейдж в таблице, и фильтры. export const AUTOCALL_STATUS_META = { not_called: { label: "Не звонили", short: "звонка не было", tone: "neutral", icon: "🤖" }, confirmed: { label: "Согласовано", short: "согласовано", tone: "accent", icon: "✅" }, no_answer: { label: "Не дозвонились", short: "не дозвонились", tone: "danger", icon: "📵" }, no_input_day: { label: "Не выбрал день", short: "не выбрал день", tone: "warning", icon: "📅" }, no_input_half: { label: "Не выбрал время", short: "не выбрал время", tone: "warning", icon: "🕐" }, no_input_both: { 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_day", label: "Не выбрал день" }, { value: "no_input_half", label: "Не выбрал время" }, { value: "no_input_both", label: "Не выбрал день и время" }, { value: "manual", label: "Нужен менеджер" }, { value: "opt_out", label: "Не звонить (запрет вручную)" }, { value: "excluded", label: "Не звоним (исключение)" }, ]; /** * Что именно клиент не выбрал: день, время или и то и другое. * * Считаем по всем звонкам заказа, а не только по последнему: если в одном * звонке клиент не выбрал день, а в другом день выбрал, но не успел время — * честнее сказать «не выбрал день и время», чем показывать половину картины. */ export const resolveNoInputKind = (calls = []) => { const outcomes = new Set((calls || []).map((c) => c?.outcome)); const day = outcomes.has("no_input_day"); const half = outcomes.has("no_input_half"); if (day && half) return "no_input_both"; if (half) return "no_input_half"; if (day) return "no_input_day"; return null; }; export const resolveAutocallStatus = (calls = [], { optOut = false, exclusionReason = null } = {}) => { if (optOut) return "opt_out"; if (exclusionReason) return "excluded"; if (!Array.isArray(calls) || calls.length === 0) return "not_called"; const last = calls[0]; if (["confirmed", "confirmed_silent", "confirmed_kept_previous"].includes(last.outcome)) return "confirmed"; if (last.outcome === "no_answer") return "no_answer"; if (["no_input_day", "no_input_half"].includes(last.outcome)) { return resolveNoInputKind(calls) || "no_input_day"; } if (["operator", "error", "invalid_number"].includes(last.outcome)) return "manual"; return "not_called"; }; // ─── Как согласовали дату: канал подтверждения ────────────────────────────── // Владелец: «нужно видеть, согласование прошло через пуш / смс / звонок / вручную // через менеджера» и «в колонке с именем клиента пишется просто «Согласовано» — // непонятно, как и кем». Это единственный источник подписей канала: их берут // таблица реестра, карточка заказа и воронка на «Аналитике». // // Источник правды — order_groups.delivery_date_source. Его пишут: // manual — логист в админке (orderGroupRepository.assignDeliveryDate); // autocall — робот, когда клиент выбрал слот в звонке (edge fn autocall-result); // push/sms/messenger/client — клиент на странице /delivery/{hash}?src=... // (до 29.09.2026 канал клиента НЕ записывался — такие строки разбираем по // отметке приглашения: confirmed_at, ключ 'link'). export const CONFIRM_CHANNEL_META = { push: { label: "Push", short: "push", tone: "accent", icon: "🔔" }, sms: { label: "SMS-ссылка", short: "SMS", tone: "ok", icon: "✉️" }, call: { label: "Звонок робота", short: "звонок", tone: "info", icon: "📞" }, manual: { label: "Логист", short: "логист", tone: "accent", icon: "🧑💼" }, messenger: { label: "Мессенджер", short: "мессенджер", tone: "info", icon: "💬" }, link: { label: "По ссылке", short: "ссылка", tone: "ok", icon: "🔗" }, }; // Порядок для легенды и воронки: от автоматических каналов к ручным. export const CONFIRM_CHANNEL_ORDER = ["push", "sms", "call", "link", "messenger", "manual"]; /** * Каким каналом согласовали дату. null — дата не проставлена или канал неизвестен. * Принимает строку группы (camelCase из orderGroupRepository) или snake_case из БД. */ export const resolveConfirmChannel = (group = {}) => { if (!group) return null; // Без даты канала быть не может: иначе реестр напишет «согласовано логистом» // там, где даты ещё нет вовсе. const hasDate = !!( group.deliveryDate || group.delivery_date || group.pickupDate || group.pickup_date ); if (!hasDate) return null; const source = String(group.deliveryDateSource ?? group.delivery_date_source ?? "").trim().toLowerCase(); if (source === "autocall") return "call"; if (source === "manual") return "manual"; if (source === "push") return "push"; if (source === "sms") return "sms"; if (source === "messenger" || source === "client") return "messenger"; // Источник пуст → старые записи (канал клиента не писался). Смотрим отметку приглашения. const invited = group.invitationConfirmedAt ?? group.invitation_confirmed_at ?? group.deliveryInvitationConfirmedAt ?? group.delivery_invitation_confirmed_at ?? group.deliveryInvitation?.confirmedAt; if (invited) return "link"; const opened = group.invitationOpenedAt ?? group.invitation_opened_at; if (opened) return "link"; if (group.manualConfirmationAt ?? group.manual_confirmation_at) return "manual"; if (group.called ?? group.called_at) return "call"; return null; }; /** Подпись канала для интерфейса; null, если канал неизвестен. */ export const confirmChannelMeta = (group = {}) => { const key = resolveConfirmChannel(group); return key ? { key, ...CONFIRM_CHANNEL_META[key] } : null; }; export const filterOrderGroups = (groups, filters = {}) => { const query = normalizeDate(filters.query).trim().toLowerCase(); const status = filters.status || "all"; const displayStatus = normalizeDate(filters.displayStatus || "all"); const deliveryStatus = normalizeDate(filters.deliveryStatus || "all"); const dateFrom = normalizeDate(filters.dateFrom); const dateTo = normalizeDate(filters.dateTo); const deliveryHalfDay = normalizeDate(filters.deliveryHalfDay || filters.timeSlot || "all"); const smsStatus = filters.smsStatus || "all"; const autocallStatus = filters.autocallStatus || "all"; const isWithinDateRange = (group) => { const deliveryDate = parseGroupDate(getDeliveryDate(group)); if (!deliveryDate) { return !dateFrom && !dateTo; } if (dateFrom) { const fromDate = parseGroupDate(dateFrom); if (fromDate && deliveryDate < fromDate) { return false; } } if (dateTo) { const toDate = parseGroupDate(dateTo); if (toDate && deliveryDate > toDate) { return false; } } return true; }; const getSearchHaystack = (group) => (group.searchText || [ group.groupKey, group.displayTitle, group.customerName, group.customerPhone, group.customerDate, Array.isArray(group.orderNumbers) ? group.orderNumbers.join(" ") : "", group.status, getOrderGroupStatusLabel(group.status), group.deliveryStatus, getOrderGroupDeliveryStatusLabel(group.deliveryStatus), group.city, group.customerAddress, ] .filter(Boolean) .join(" ")) .toLowerCase(); return (groups || []).filter((group) => { if (status !== "all" && group.status !== status) { return false; } if (displayStatus !== "all" && getOrderGroupDisplayStatusValue(group) !== displayStatus) { return false; } if (deliveryStatus !== "all") { const groupDeliveryStatus = group.deliveryStatus || group.delivery_status || "pending_confirmation"; if (groupDeliveryStatus !== deliveryStatus) { return false; } } if (!isWithinDateRange(group)) { return false; } if (deliveryHalfDay !== "all") { const groupDeliveryHalfDay = getOrderGroupDeliveryHalfDay(group); if (deliveryHalfDay === "unknown") { if (groupDeliveryHalfDay) { return false; } } else if (groupDeliveryHalfDay !== HALF_DAY_LABELS[deliveryHalfDay]) { return false; } } const cityFilter = (filters.city || "").trim().toLowerCase(); if (cityFilter) { const groupCity = (group.city || "").toLowerCase(); if (!groupCity.includes(cityFilter) && cityFilter !== groupCity) { return false; } } const deliveryTypeFilter = (filters.deliveryType || "").trim().toLowerCase(); if (deliveryTypeFilter && group.deliveryType !== deliveryTypeFilter) { return false; } if (smsStatus !== "all") { const status = getSmsStatus(group); if (smsStatus === "not_sent" && status !== "not_sent") return false; if (smsStatus === "sent" && status !== "sent") return false; if (smsStatus === "delivered" && status !== "delivered") return false; if (smsStatus === "error" && status !== "error") return false; } // Фильтр «Статус автодозвона» if (autocallStatus !== "all") { const acStatus = group.autocall_status || group.autocallStatus || resolveAutocallStatus([], { optOut: group.autocall_opt_out, exclusionReason: group.autocall_exclusion_reason, }); if (acStatus !== autocallStatus) return false; } if (!query) { return true; } return getSearchHaystack(group).includes(query); }); }; export const ORDER_GROUP_STATUS_LABELS = { ready_for_notification: "Готово к уведомлению", sms_sent: "SMS отправлены", manual_work: "Нужна ручная работа", ready_to_launch: "Готово к запуску", first_sms_sent: "1-е приглашение отправлено", second_sms_sent: "2-е приглашение отправлено", draft: "Черновик", }; export const ORDER_GROUP_DISPLAY_STATUS_OPTIONS = [ { value: "all", label: "Все статусы" }, { value: "delivery:pending_confirmation", label: DELIVERY_GROUP_STATUS_LABELS.pending_confirmation }, { value: "delivery:manual_confirmation_required", label: DELIVERY_GROUP_STATUS_LABELS.manual_confirmation_required }, { value: "delivery:agreed", label: DELIVERY_GROUP_STATUS_LABELS.agreed }, { value: "delivery:driver_assigned", label: DELIVERY_GROUP_STATUS_LABELS.driver_assigned }, { value: "delivery:loaded", label: DELIVERY_GROUP_STATUS_LABELS.loaded }, { value: "delivery:delivered", label: DELIVERY_GROUP_STATUS_LABELS.delivered }, { value: "delivery:picked_up", label: DELIVERY_GROUP_STATUS_LABELS.picked_up }, { 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:ready_for_notification", label: "Готово к уведомлению" }, { value: "status:ready_to_launch", label: "Готово к запуску" }, { value: "status:first_sms_sent", label: NOTIFICATION_STATUS_LABELS.first_sms_sent }, { value: "status:second_sms_sent", label: NOTIFICATION_STATUS_LABELS.second_sms_sent }, { value: "status:send_failed", label: NOTIFICATION_STATUS_LABELS.send_failed }, { value: "status:link_ready", label: NOTIFICATION_STATUS_LABELS.link_ready }, ]; export const getOrderGroupStatusLabel = (status) => ORDER_GROUP_STATUS_LABELS[status] || (status ? `Неизвестно (${status})` : "Неизвестно"); export const getOrderGroupDeliveryStatusTone = (status) => { switch (status) { case "pending_confirmation": return "neutral"; case "manual_confirmation_required": return "warning"; case "agreed": return "accent"; case "driver_assigned": return "info"; case "loaded": return "info"; case "on_route": return "warning"; case "delivered": return "accent"; case "picked_up": return "accent"; case "paid_storage": return "warning"; case "problem": return "danger"; case "cancelled": return "danger"; default: return "neutral"; } }; export const groupOrderGroupsByDate = (groups) => { const buckets = (groups || []).reduce((accumulator, group) => { const date = getDeliveryDate(group) || "Без даты"; accumulator[date] = accumulator[date] || []; accumulator[date].push(group); return accumulator; }, {}); return Object.entries(buckets) .sort(([leftDate], [rightDate]) => { const leftTime = parseGroupDate(leftDate)?.getTime(); const rightTime = parseGroupDate(rightDate)?.getTime(); if (leftTime != null && rightTime != null && leftTime !== rightTime) { return leftTime - rightTime; } return leftDate.localeCompare(rightDate); }) .map(([date, items]) => ({ date, items: [...items].sort((left, right) => { const leftCount = Number(left.ordersCount || 0); const rightCount = Number(right.ordersCount || 0); if (leftCount !== rightCount) { return rightCount - leftCount; } return (right.updatedAt || "").localeCompare(left.updatedAt || ""); }), })); }; const getBucketKey = (group) => { const notificationStatus = group?.notificationStatus || group?.notification_status; if (notificationStatus === "manual_required") { return "manual_work"; } if (group.smsSentAt) { return "sms_sent"; } if ((group.readyCount || 0) > 0 && (group.notReadyCount || 0) > 0) { return "manual_work"; } if ((group.notReadyCount || 0) > 0) { return "manual_work"; } if (group.status === "ready_for_notification" || (group.readyCount || 0) >= (group.ordersCount || 0)) { return "ready_to_launch"; } return "manual_work"; }; export const ORDER_GROUP_BUCKET_LABELS = { ready_to_launch: "Готовы к уведомлению", sms_sent: "Уведомления отправлены", manual_work: "Нужна ручная работа", }; export const ORDER_GROUP_DELIVERY_HALF_DAY_OPTIONS = [ { value: "all", label: "Все интервалы" }, { value: "morning", label: HALF_DAY_LABELS.morning }, { value: "afternoon", label: HALF_DAY_LABELS.afternoon }, { value: "unknown", label: "Без времени" }, ]; export const buildOrderGroupBuckets = (groups) => { const buckets = { ready_to_launch: [], sms_sent: [], manual_work: [], }; for (const group of groups || []) { const bucketKey = getBucketKey(group); buckets[bucketKey].push(group); } return buckets; }; export const getOrderGroupStatusTone = (group) => { const deliveryStatus = group?.deliveryStatus || group?.delivery_status; const statusCol = group?.status; // Highlight groups with delivery problems if (group?.hasDeliveryProblem) return "warning"; // Priority: if status column already holds a delivery-level value, use it if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") { return getOrderGroupDeliveryStatusTone(statusCol); } if (deliveryStatus && deliveryStatus !== "pending_confirmation") { return getOrderGroupDeliveryStatusTone(deliveryStatus); } const notificationStatus = group?.notificationStatus || group?.notification_status; const groupStatus = group?.status; if (notificationStatus === "send_failed" || notificationStatus === "manual_required") { return "warning"; } if (notificationStatus === "first_sms_sent" || notificationStatus === "second_sms_sent") { return "accent"; } if (groupStatus === "first_sms_sent" || groupStatus === "second_sms_sent") { return "accent"; } if (group.smsSentAt) { return "accent"; } if ((group.notReadyCount || 0) > 0) { return "warning"; } return "neutral"; };