supersam/src/services/orderGroupViews.js

813 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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";
};