import React from "react"; import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, } from "@dnd-kit/core"; import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { filterOrderGroups, getOrderGroupDisplayStatusLabel, getOrderGroupDisplayStatusValue, getOrderGroupStatusTone, getSmsProblemTone, ORDER_GROUP_DISPLAY_STATUS_OPTIONS, } from "../../services/orderGroupViews"; import { Badge } from "../UI/Badge"; import { Panel } from "../UI/Panel"; import { SkeletonPage } from "../UI/Loading"; import { Pagination } from "../UI/Pagination"; import { OrderFilters } from "../orders/OrderFilters"; import { CallStatusBadge } from "../UI/CallStatusBadge"; import { formatDate, formatDateTime } from "../../utils/formatters"; const fmtDate = (d) => { if (!d) return ""; const [y, m, day] = d.split("-"); if (!y || !m || !day) return d; return `${day}.${m}.${y}`; }; // Default priority: agreed first, manual_required second, then funnel const DEFAULT_FUNNEL_ORDER = [ "delivery:agreed", "status:manual_required", "status:ready_for_notification", "delivery:pending_confirmation", "status:first_sms_sent", "status:second_sms_sent", "delivery:driver_assigned", "delivery:loaded", "delivery:on_route", "delivery:delivered", "delivery:picked_up", "delivery:paid_storage", "delivery:problem", "delivery:cancelled", ]; const STORAGE_KEY = "logistics-section-order"; const COLLAPSED_KEY = "logistics-section-collapsed"; const PAGE_KEY = "logistics-board-page"; const SORT_KEY = "logistics-board-sort"; const PAGE_SIZE = 20; // Load custom order from localStorage, merge with defaults const loadCustomOrder = () => { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return null; const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : null; } catch { return null; } }; const saveCustomOrder = (order) => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(order)); } catch { // ignore } }; // Load collapsed sections from localStorage const loadCollapsedSections = () => { try { const raw = localStorage.getItem(COLLAPSED_KEY); if (!raw) return new Set(); const parsed = JSON.parse(raw); return new Set(Array.isArray(parsed) ? parsed : []); } catch { return new Set(); } }; const saveCollapsedSections = (collapsedSet) => { try { localStorage.setItem(COLLAPSED_KEY, JSON.stringify([...collapsedSet])); } catch { // ignore } }; // 8 columns: Клиент | Город | Тип | Дата | Водитель | Статус | Обновлён | Добавлен в базу const COLS = "grid-cols-[minmax(110px,1.5fr)_minmax(80px,1fr)_minmax(95px,1fr)_minmax(95px,1fr)_minmax(90px,1fr)_minmax(95px,1fr)_minmax(90px,1fr)_minmax(90px,1fr)]"; const MIN_W = "min-w-[1060px]"; const TABLE_COLUMNS = [ { key: "customer", label: "Клиент" }, { key: "city", label: "Город" }, { key: "deliveryType", label: "Тип" }, { key: "deliveryDate", label: "Доставка" }, { key: "driver", label: "Водитель" }, { key: "status", label: "Статус" }, { key: "updatedAt", label: "Обновлён" }, { key: "createdAt", label: "Добавлен" }, ]; const TableHeader = ({ sort, onSort }) => (
{TABLE_COLUMNS.map((column) => { const active = sort.key === column.key; return ( ); })}
); // Stale = updatedAt > 24h and not in agreed/delivered/picked_up/cancelled const STALE_STATUSES = ["delivery:agreed", "delivery:driver_assigned", "delivery:loaded", "delivery:on_route", "delivery:delivered", "delivery:picked_up", "delivery:pickup", "delivery:cancelled"]; const isStale = (group) => { const sv = getOrderGroupDisplayStatusValue(group); if (STALE_STATUSES.includes(sv)) return false; if (!group.updatedAt) return false; const diff = Date.now() - new Date(group.updatedAt).getTime(); return diff > 24 * 60 * 60 * 1000; }; const getDatabaseAge = (group) => { if (!group.createdAt) return { days: null, rowClass: "", textClass: "text-[var(--color-text-muted)]" }; const created = new Date(group.createdAt).getTime(); if (!Number.isFinite(created)) return { days: null, rowClass: "", textClass: "text-[var(--color-text-muted)]" }; const days = Math.max(0, Math.floor((Date.now() - created) / 86400000)); return { days, rowClass: "", textClass: "text-[var(--color-text-muted)]" }; }; // Not agreed = still waiting for customer confirmation / no delivery settled. // These statuses mean the order has not been agreed yet. const NOT_AGREED_STATUSES = new Set([ "delivery:pending_confirmation", "delivery:manual_confirmation_required", "status:manual_required", "status:not_started", "status:link_ready", "status:pending_confirmation", "status:unknown", ]); const isNotAgreed = (group) => NOT_AGREED_STATUSES.has(getOrderGroupDisplayStatusValue(group)); // Red highlight: not agreed and 2+ days since the order was added to the system. const getNotAgreedHighlight = (group) => { if (!isNotAgreed(group)) return ""; const created = group.createdAt ? new Date(group.createdAt).getTime() : 0; if (!Number.isFinite(created)) return ""; const days = Math.max(0, Math.floor((Date.now() - created) / 86400000)); return days >= 2 ? "bg-[rgba(239,68,68,0.12)]" : ""; }; const SMS_TAG = { not_sent: { label: "SMS не отправлено", cls: "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]" }, not_delivered: { label: "SMS не доставлено", cls: "bg-[rgba(245,158,11,0.14)] text-[var(--color-warning)]" }, }; const renderRow = (g, onSelectSet) => { const statusTone = getOrderGroupStatusTone(g); const stale = isStale(g); const databaseAge = getDatabaseAge(g); const smsTone = getSmsProblemTone(g); const smsTag = smsTone !== "ok" ? SMS_TAG[smsTone] : null; const notAgreedHighlight = getNotAgreedHighlight(g); return ( ); }; const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, onSelectSet, sort, onSort }) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: statusValue }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, }; return (
{!isCollapsed && (
{groups.map((g) => renderRow(g, onSelectSet))}
)}
); }; // Check if any filter is active const isFilterActive = (filters) => { if (!filters) return false; if (filters.query && filters.query.trim()) return true; if (filters.displayStatus && filters.displayStatus !== "all") return true; if (filters.city && filters.city.trim()) return true; if (filters.deliveryStatus && filters.deliveryStatus !== "all") return true; if (filters.deliveryHalfDay && filters.deliveryHalfDay !== "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.dateTo) return true; return false; }; export const LogisticsReadinessBoard = ({ orderGroups = [], totalGroups: totalGroupsProp, hideCompleted = true, onToggleHideCompleted, onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => { const FILTERS_KEY = 'logistics-board-filters'; const [filters, setFilters] = React.useState(() => { try { const raw = localStorage.getItem(FILTERS_KEY); if (raw) return { query: '', displayStatus: 'all', city: '', ...JSON.parse(raw) }; } catch {} return { query: '', displayStatus: 'all', city: '' }; }); React.useEffect(() => { try { localStorage.setItem(FILTERS_KEY, JSON.stringify(filters)); } catch {} }, [filters]); const [collapsedSections, setCollapsedSections] = React.useState(() => loadCollapsedSections()); const [sectionOrder, setSectionOrder] = React.useState(() => { const custom = loadCustomOrder(); return custom || [...DEFAULT_FUNNEL_ORDER]; }); const [sort, setSort] = React.useState(() => { try { const saved = JSON.parse(localStorage.getItem(SORT_KEY) || "null"); if (saved?.key && ["asc", "desc"].includes(saved.direction)) return saved; } catch {} return { key: "createdAt", direction: "desc" }; }); const handleSort = React.useCallback((key) => { setSort((current) => { const next = { key, direction: current.key === key && current.direction === "asc" ? "desc" : "asc", }; try { localStorage.setItem(SORT_KEY, JSON.stringify(next)); } catch {} return next; }); setPage(1); }, []); // Pagination state const [page, setPage] = React.useState(() => { try { const saved = localStorage.getItem(PAGE_KEY); if (saved) return Math.max(1, parseInt(saved, 10) || 1); } catch {} return 1; }); React.useEffect(() => { try { localStorage.setItem(PAGE_KEY, String(page)); } catch {} }, [page]); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), ); const cities = React.useMemo(() => { const set = new Set(); for (const g of orderGroups) { if (g.city) set.add(g.city); } return [...set].sort(); }, [orderGroups]); const filteredGroups = React.useMemo( () => filterOrderGroups(orderGroups, filters), [filters, orderGroups], ); const rankedGroups = React.useMemo(() => { const direction = sort.direction === "asc" ? 1 : -1; const text = (value) => String(value || "").trim().toLocaleLowerCase("ru"); const time = (value) => { const parsed = value ? new Date(value).getTime() : 0; return Number.isFinite(parsed) ? parsed : 0; }; const valueFor = (group) => { switch (sort.key) { case "customer": return text(group.customerName || group.groupKey); case "city": return text(group.city); case "deliveryType": return text(group.deliveryType === "pickup" ? "Самовывоз" : "Доставка"); case "deliveryDate": return time(group.deliveryDate); case "driver": return text(group.assignedDriverName); case "status": return text(getOrderGroupDisplayStatusLabel(group)); case "updatedAt": return time(group.updatedAt); case "createdAt": default: return time(group.createdAt); } }; return [...filteredGroups].sort((a, b) => { const aValue = valueFor(a); const bValue = valueFor(b); const result = typeof aValue === "string" ? aValue.localeCompare(bValue, "ru", { numeric: true, sensitivity: "base" }) : aValue - bValue; return result * direction; }); }, [filteredGroups, sort]); // Determine if filters are active — if so, show all results (no pagination) const hasActiveFilters = isFilterActive(filters); // Reset to page 1 when filters change React.useEffect(() => { setPage(1); }, [filters.query, filters.displayStatus, filters.city, filters.deliveryStatus, filters.deliveryHalfDay, filters.smsStatus, filters.callStatus, filters.dateFrom, filters.dateTo]); const totalGroups = rankedGroups.length; const totalPages = Math.max(1, Math.ceil(totalGroups / PAGE_SIZE)); // Clamp page if totalGroups decreased const currentPage = Math.min(page, totalPages); // Paginate: when no active filters, slice to current page const paginatedGroups = React.useMemo(() => { if (hasActiveFilters) return rankedGroups; const start = (currentPage - 1) * PAGE_SIZE; return rankedGroups.slice(start, start + PAGE_SIZE); }, [rankedGroups, currentPage, hasActiveFilters]); const statusGroups = React.useMemo(() => { const map = new Map(); for (const group of paginatedGroups) { const statusValue = getOrderGroupDisplayStatusValue(group); if (!map.has(statusValue)) { const label = getOrderGroupDisplayStatusLabel(group); map.set(statusValue, { label, groups: [] }); } map.get(statusValue).groups.push(group); } return map; }, [paginatedGroups]); // Build sorted list: use sectionOrder for known statuses, append unknown ones at end const sortedEntries = React.useMemo(() => { const present = new Set(statusGroups.keys()); const result = []; // First: statuses in custom order that are present for (const statusValue of sectionOrder) { if (present.has(statusValue)) { const data = statusGroups.get(statusValue); result.push([statusValue, data]); } } // Then: any statuses not in sectionOrder (new statuses), sorted alphabetically for (const [statusValue, data] of statusGroups.entries()) { if (!sectionOrder.includes(statusValue)) { result.push([statusValue, data]); } } return result; }, [statusGroups, sectionOrder]); const handleDragEnd = (event) => { const { active, over } = event; if (!over || active.id === over.id) return; setSectionOrder((prevOrder) => { // Build the full order including any new statuses const allIds = sortedEntries.map(([id]) => id); const oldIndex = allIds.indexOf(active.id); const newIndex = allIds.indexOf(over.id); if (oldIndex === -1 || newIndex === -1) return prevOrder; const newAllOrder = arrayMove(allIds, oldIndex, newIndex); // Merge: replace positions of known statuses, keep unknown at end // Save the full new order so it persists saveCustomOrder(newAllOrder); return newAllOrder; }); }; if (isLoading) { return ; } return (

Наборы доставки

Перетаскивайте секции за ручку слева, чтобы изменить порядок отображения.

{totalGroupsProp ?? totalGroups} групп
{!hasActiveFilters && totalGroups > PAGE_SIZE && (

Показано {Math.min(currentPage * PAGE_SIZE, totalGroups)} из {totalGroups} · по {PAGE_SIZE} на странице

)}
{!totalGroups ? (
По этому поиску ничего не найдено.
) : ( id)} strategy={verticalListSortingStrategy} >
{sortedEntries.map(([statusValue, { label, groups }]) => { const isCollapsed = collapsedSections.has(statusValue); return ( { setCollapsedSections((prev) => { const next = new Set(prev); if (next.has(statusValue)) { next.delete(statusValue); } else { next.add(statusValue); } saveCollapsedSections(next); return next; }); }} onSelectSet={onSelectSet} sort={sort} onSort={handleSort} /> ); })}
)} {!hasActiveFilters && totalGroups > PAGE_SIZE && ( )}
); };