feat(autocall): статус автодозвона в карточке (как у SMS) + фильтр «Прозвонить» в таблице + выравнивание переключателя

This commit is contained in:
Hermes 2026-09-08 13:04:01 +02:00
parent 1552c21d99
commit 64bcda8eed
5 changed files with 73 additions and 18 deletions

View File

@ -299,6 +299,7 @@ const isFilterActive = (filters) => {
if (filters.deliveryStatus && filters.deliveryStatus !== "all") return true; if (filters.deliveryStatus && filters.deliveryStatus !== "all") return true;
if (filters.deliveryHalfDay && filters.deliveryHalfDay !== "all") return true; if (filters.deliveryHalfDay && filters.deliveryHalfDay !== "all") return true;
if (filters.smsStatus && filters.smsStatus !== "all") return true; if (filters.smsStatus && filters.smsStatus !== "all") return true;
if (filters.callStatus && filters.callStatus !== "all") return true;
if (filters.dateFrom) return true; if (filters.dateFrom) return true;
if (filters.dateTo) return true; if (filters.dateTo) return true;
return false; return false;
@ -404,7 +405,7 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], totalGroups: totalGr
// Reset to page 1 when filters change // Reset to page 1 when filters change
React.useEffect(() => { React.useEffect(() => {
setPage(1); setPage(1);
}, [filters.query, filters.displayStatus, filters.city, filters.deliveryStatus, filters.deliveryHalfDay, filters.smsStatus, filters.dateFrom, filters.dateTo]); }, [filters.query, filters.displayStatus, filters.city, filters.deliveryStatus, filters.deliveryHalfDay, filters.smsStatus, filters.callStatus, filters.dateFrom, filters.dateTo]);
const totalGroups = rankedGroups.length; const totalGroups = rankedGroups.length;
const totalPages = Math.max(1, Math.ceil(totalGroups / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(totalGroups / PAGE_SIZE));

View File

@ -6,6 +6,7 @@
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { Badge } from "../UI/Badge"; import { Badge } from "../UI/Badge";
import { supabase } from "../../supabaseClient"; import { supabase } from "../../supabaseClient";
import { resolveAutocallStatus, AUTOCALL_STATUS_META } from "../../services/orderGroupViews";
const OUTCOME_META = { const OUTCOME_META = {
confirmed: { label: "Согласовал", tone: "accent", icon: "✅" }, confirmed: { label: "Согласовал", tone: "accent", icon: "✅" },
@ -113,25 +114,19 @@ export const AutocallStatusCard = ({ order, userRole }) => {
const lastMeta = lastCall ? OUTCOME_META[lastCall.outcome] : null; const lastMeta = lastCall ? OUTCOME_META[lastCall.outcome] : null;
const lastSlot = lastCall ? slotText(lastCall) : null; const lastSlot = lastCall ? slotText(lastCall) : null;
// Единый человеческий статус (по аналогии со статусом SMS)
const statusKey = resolveAutocallStatus(calls, { optOut, exclusionReason });
const statusMeta = AUTOCALL_STATUS_META[statusKey] || AUTOCALL_STATUS_META.not_called;
return ( return (
<div className="p-4"> <div className="p-4">
<div className="mb-3"> <div className="mb-3">
<h3 className="text-sm font-semibold text-[var(--color-text)]">📞 Автодозвон</h3> <h3 className="text-sm font-semibold text-[var(--color-text)]">📞 Автодозвон</h3>
</div> </div>
{/* Итог */} {/* Единый статус — как у SMS */}
<div className="mb-3 flex items-center gap-2"> <div className="mb-3 flex items-center gap-2">
{optOut ? ( <Badge tone={statusMeta.tone}>{statusMeta.label}</Badge>
<Badge tone="danger">🚫 Отказ от звонков</Badge>
) : exclusionReason ? (
<Badge tone="warning">🚫 Не звоним (исключение)</Badge>
) : isAutocall ? (
<Badge tone="accent">🤖 Согласовано роботом</Badge>
) : calls.length > 0 ? (
<Badge tone={lastMeta?.tone || "neutral"}>{lastMeta?.icon} {lastMeta?.label}</Badge>
) : (
<Badge tone="neutral">Не звонили</Badge>
)}
{lastSlot && <span className="text-xs text-[var(--color-text)]">{lastSlot}</span>} {lastSlot && <span className="text-xs text-[var(--color-text)]">{lastSlot}</span>}
</div> </div>

View File

@ -127,6 +127,13 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
{ value: "problem_not_delivered", label: "SMS не доставлено" }, { value: "problem_not_delivered", label: "SMS не доставлено" },
]; ];
const callStatusOptions = [
{ value: "all", label: "Прозвонить" },
{ value: "need_call", label: "Нужно прозвонить (SMS/автодозвон)" },
{ value: "sms_problem", label: "Проблема с SMS" },
{ value: "autocall_problem", label: "Проблема с автодозвоном" },
];
return ( return (
<Panel className="p-4"> <Panel className="p-4">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@ -205,10 +212,10 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
/> />
</div> </div>
{/* Row 2: Date range + SMS problems (width matches its text) + hide-completed toggle. {/* Row 2: Date range + SMS problems + прозвонить + hide-completed toggle.
Align by the bottom edge of the input fields, not by center — dates have a Выравнивание по верхней линии полей ввода — чтобы переключатель
label above the field, so items-center would lift SMS/toggle above them. */} «Скрыть завершённые» стоял на уровне лейблов, а не прижимался к низу. */}
<div className="flex flex-wrap items-end gap-3"> <div className="flex flex-wrap items-start gap-3">
<DatePicker <DatePicker
value={filters.dateFrom || ""} value={filters.dateFrom || ""}
onChange={(v) => updateFilter("dateFrom", v)} onChange={(v) => updateFilter("dateFrom", v)}
@ -240,6 +247,13 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
placeholder="Проблемы SMS" placeholder="Проблемы SMS"
/> />
<DropdownSelect
value={filters.callStatus || "all"}
options={callStatusOptions}
onChange={(v) => updateFilter("callStatus", v)}
placeholder="Прозвонить"
/>
{typeof onToggleHideCompleted === "function" && ( {typeof onToggleHideCompleted === "function" && (
<Switch <Switch
checked={hideCompleted} checked={hideCompleted}

View File

@ -261,6 +261,30 @@ export const getSmsProblemTone = (group) => {
return "ok"; return "ok";
}; };
// ─── Автодозвон: единый человеческий статус (по аналогии со статусом SMS) ──
// Принимает массив autocall_log (отсортированный по created_at desc) или null.
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" },
};
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 "no_input";
if (["operator", "error", "invalid_number"].includes(last.outcome)) return "manual";
return "not_called";
};
export const filterOrderGroups = (groups, filters = {}) => { export const filterOrderGroups = (groups, filters = {}) => {
const query = normalizeDate(filters.query).trim().toLowerCase(); const query = normalizeDate(filters.query).trim().toLowerCase();
const status = filters.status || "all"; const status = filters.status || "all";
@ -270,6 +294,7 @@ export const filterOrderGroups = (groups, filters = {}) => {
const dateTo = normalizeDate(filters.dateTo); const dateTo = normalizeDate(filters.dateTo);
const deliveryHalfDay = normalizeDate(filters.deliveryHalfDay || filters.timeSlot || "all"); const deliveryHalfDay = normalizeDate(filters.deliveryHalfDay || filters.timeSlot || "all");
const smsStatus = filters.smsStatus || "all"; const smsStatus = filters.smsStatus || "all";
const callStatus = filters.callStatus || "all";
const isWithinDateRange = (group) => { const isWithinDateRange = (group) => {
const deliveryDate = parseGroupDate(getDeliveryDate(group)); const deliveryDate = parseGroupDate(getDeliveryDate(group));
@ -367,6 +392,20 @@ export const filterOrderGroups = (groups, filters = {}) => {
if (smsStatus === "problem_not_delivered" && tone !== "not_delivered") return false; if (smsStatus === "problem_not_delivered" && tone !== "not_delivered") return false;
} }
// Фильтр «прозвонить»: SMS + автодозвон
if (callStatus !== "all") {
const smsTone = getSmsProblemTone(group);
const autocallStatus = group.autocall_status || group.autocallStatus || resolveAutocallStatus([], {
optOut: group.autocall_opt_out,
exclusionReason: group.autocall_exclusion_reason,
});
const smsNotDone = smsTone === "not_sent" || smsTone === "not_delivered";
const callNotDone = ["not_called", "no_answer", "no_input", "manual"].includes(autocallStatus);
if (callStatus === "need_call" && !(smsNotDone || callNotDone)) return false;
if (callStatus === "sms_problem" && !smsNotDone) return false;
if (callStatus === "autocall_problem" && !callNotDone) return false;
}
if (!query) { if (!query) {
return true; return true;
} }

View File

@ -231,6 +231,12 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
deliveryDate, deliveryDate,
deliveryTime, deliveryTime,
deliveryDateSource: row.delivery_date_source || null, deliveryDateSource: row.delivery_date_source || null,
autocallOptOut: !!row.autocall_opt_out,
autocall_opt_out: !!row.autocall_opt_out,
autocallStatus: row.autocall_status || null,
autocall_status: row.autocall_status || null,
autocallExclusionReason: row.autocall_exclusion_reason || null,
autocall_exclusion_reason: row.autocall_exclusion_reason || null,
deliveryType: effectiveDeliveryType, deliveryType: effectiveDeliveryType,
pickupDate: row.pickup_date || null, pickupDate: row.pickup_date || null,
pickupTimeSlot: row.pickup_time_slot || null, pickupTimeSlot: row.pickup_time_slot || null,
@ -294,7 +300,7 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
}; };
const ORDER_GROUP_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, delivery_link_code, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, autocall_opt_out, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, delivery_invitation:delivery_invitations!order_groups_delivery_invitation_id_fkey(opened_at, access_count, last_accessed_at), called, called_at, called_comment, order_history(id, action, old_status, new_status, user_id, metadata, created_at)`; const ORDER_GROUP_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, delivery_link_code, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, autocall_opt_out, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, delivery_invitation:delivery_invitations!order_groups_delivery_invitation_id_fkey(opened_at, access_count, last_accessed_at), called, called_at, called_comment, order_history(id, action, old_status, new_status, user_id, metadata, created_at)`;
export const ORDER_GROUP_LIST_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, notification_status, sms_attempts, last_sms_error, first_sms_sent_at, second_sms_sent_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, autocall_opt_out, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, called, called_at, called_comment, autocall_exclusion_reason`; export const ORDER_GROUP_LIST_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, notification_status, sms_attempts, last_sms_error, first_sms_sent_at, second_sms_sent_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, autocall_opt_out, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, called, called_at, called_comment, autocall_exclusion_reason, autocall_status`;
// Delivery statuses treated as "finished" — hidden by default in the board. // Delivery statuses treated as "finished" — hidden by default in the board.
const COMPLETED_DELIVERY_STATUSES = ["delivered", "picked_up", "cancelled"]; const COMPLETED_DELIVERY_STATUSES = ["delivered", "picked_up", "cancelled"];