571 lines
22 KiB
JavaScript
571 lines
22 KiB
JavaScript
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 }) => (
|
|
<div className={`grid ${COLS} gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]`}>
|
|
{TABLE_COLUMNS.map((column) => {
|
|
const active = sort.key === column.key;
|
|
return (
|
|
<button
|
|
key={column.key}
|
|
type="button"
|
|
onClick={() => onSort(column.key)}
|
|
className={`flex min-w-0 items-center gap-1 whitespace-nowrap px-3 py-1.5 text-left font-bold transition hover:text-[var(--color-text)] ${active ? "text-[var(--color-text)]" : ""}`}
|
|
title={`Сортировать: ${column.label.toLowerCase()}`}
|
|
aria-label={`Сортировать по колонке «${column.label}»`}
|
|
>
|
|
<span>{column.label}</span>
|
|
<span className="flex-shrink-0" aria-hidden="true">{active ? (sort.direction === "asc" ? "↑" : "↓") : "↕"}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
|
|
// 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 (
|
|
<button
|
|
type="button"
|
|
key={g.id}
|
|
onClick={() => onSelectSet(g.id)}
|
|
className={`grid ${COLS} ${MIN_W} w-full border-t border-[var(--color-border)] text-left transition hover:bg-[var(--color-accent-soft)] ${
|
|
notAgreedHighlight || (stale ? "bg-[rgba(245,158,11,0.06)]" : "")
|
|
}`}
|
|
>
|
|
<div className="min-w-0 px-3 py-2.5">
|
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
<span className="text-sm font-medium text-[var(--color-text)] truncate">
|
|
{g.customerName || g.groupKey || "—"}
|
|
</span>
|
|
{smsTag && (
|
|
<span className={`inline-flex items-center whitespace-nowrap rounded-full px-2 py-0.5 text-[10px] font-semibold ${smsTag.cls}`} title={smsTag.label}>
|
|
{smsTag.label}
|
|
</span>
|
|
)}
|
|
<CallStatusBadge group={g} />
|
|
{stale && (
|
|
<span title="Обновлён >24ч назад" className="inline-block h-2 w-2 flex-shrink-0 rounded-full bg-[var(--color-warning)]"></span>
|
|
)}
|
|
</div>
|
|
<div className="mt-0.5 text-xs text-[var(--color-text-muted)] truncate">
|
|
{(g.allBillNumbers || g.orderNumbers || []).join(", ") || "—"}
|
|
</div>
|
|
</div>
|
|
<div className="px-3 py-2.5 text-sm text-[var(--color-text-muted)]">{g.city || "—"}</div>
|
|
<div className="px-3 py-2.5 text-xs">
|
|
<span className={`inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2 py-0.5 font-medium ${
|
|
g.deliveryType === "pickup"
|
|
? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
|
|
: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]"
|
|
}`}>
|
|
{g.deliveryType === "pickup" ? "🏪 Самовывоз" : "🚚 Доставка"}
|
|
</span>
|
|
</div>
|
|
<div className="px-3 py-2.5 text-sm text-[var(--color-text-muted)]">
|
|
{g.deliveryDate ? fmtDate(g.deliveryDate) : "—"}
|
|
{g.deliveryTime ? <span className="ml-1 text-xs text-[var(--color-text-muted)]">{g.deliveryTime}</span> : null}
|
|
</div>
|
|
<div className="px-3 py-2.5 text-sm text-[var(--color-text-muted)]">{g.assignedDriverName || "—"}</div>
|
|
<div className="px-3 py-2.5">
|
|
<Badge tone={statusTone}>{getOrderGroupDisplayStatusLabel(g)}</Badge>
|
|
</div>
|
|
<div className="px-3 py-2.5 text-xs text-[var(--color-text-muted)]">{formatDateTime(g.updatedAt)}</div>
|
|
<div className={`px-3 py-2.5 text-xs ${databaseAge.textClass}`}>
|
|
<div>{formatDateTime(g.createdAt)}</div>
|
|
{databaseAge.days !== null ? <div className="mt-0.5">{databaseAge.days === 0 ? "сегодня" : `${databaseAge.days} дн. в базе`}</div> : null}
|
|
</div>
|
|
</button>
|
|
);
|
|
};
|
|
|
|
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 (
|
|
<div ref={setNodeRef} style={style} className="rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">
|
|
<div className="flex items-center gap-2 border-b border-[var(--color-border)] px-4 py-3">
|
|
<button
|
|
type="button"
|
|
{...attributes}
|
|
{...listeners}
|
|
className="cursor-grab touch-none text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
|
aria-label="Перетащить секцию"
|
|
>
|
|
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
|
<path d="M7 4a1 1 0 11-2 0 1 1 0 012 0zM7 10a1 1 0 11-2 0 1 1 0 012 0zM7 16a1 1 0 11-2 0 1 1 0 012 0zM15 4a1 1 0 11-2 0 1 1 0 012 0zM15 10a1 1 0 11-2 0 1 1 0 012 0zM15 16a1 1 0 11-2 0 1 1 0 012 0z" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onToggle}
|
|
className="flex flex-1 items-center justify-between text-left"
|
|
>
|
|
<span className="text-sm font-semibold text-[var(--color-text)]">{label}</span>
|
|
<span className="flex items-center gap-2">
|
|
<Badge tone="neutral">{groups.length}</Badge>
|
|
<span className="text-xs text-[var(--color-text-muted)]">{isCollapsed ? "▶" : "▼"}</span>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
{!isCollapsed && (
|
|
<div className="overflow-x-auto">
|
|
<div className={MIN_W}>
|
|
<TableHeader sort={sort} onSort={onSort} />
|
|
{groups.map((g) => renderRow(g, onSelectSet))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 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 <SkeletonPage panels={3} />;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 fs-zone-table">
|
|
<Panel className="space-y-4 p-5">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<h2 className="text-lg font-semibold">Наборы доставки</h2>
|
|
<p className="text-xs text-[var(--color-text-muted)] mt-0.5">
|
|
Перетаскивайте секции за ручку слева, чтобы изменить порядок отображения.
|
|
</p>
|
|
</div>
|
|
<Badge tone="neutral">{totalGroupsProp ?? totalGroups} групп</Badge>
|
|
</div>
|
|
|
|
<OrderFilters
|
|
filters={filters}
|
|
setFilters={setFilters}
|
|
statusOptions={statusOptions}
|
|
cities={cities}
|
|
hideCompleted={hideCompleted}
|
|
onToggleHideCompleted={onToggleHideCompleted}
|
|
/>
|
|
|
|
{!hasActiveFilters && totalGroups > PAGE_SIZE && (
|
|
<p className="text-xs text-[var(--color-text-muted)]">
|
|
Показано {Math.min(currentPage * PAGE_SIZE, totalGroups)} из {totalGroups} · по {PAGE_SIZE} на странице
|
|
</p>
|
|
)}
|
|
</Panel>
|
|
|
|
{!totalGroups ? (
|
|
<div className="rounded-[28px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
|
|
По этому поиску ничего не найдено.
|
|
</div>
|
|
) : (
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext
|
|
items={sortedEntries.map(([id]) => id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
<div className="space-y-4">
|
|
{sortedEntries.map(([statusValue, { label, groups }]) => {
|
|
const isCollapsed = collapsedSections.has(statusValue);
|
|
|
|
return (
|
|
<SortableSection
|
|
key={statusValue}
|
|
statusValue={statusValue}
|
|
label={label}
|
|
groups={groups}
|
|
isCollapsed={isCollapsed}
|
|
onToggle={() => {
|
|
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}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
)}
|
|
|
|
{!hasActiveFilters && totalGroups > PAGE_SIZE && (
|
|
<Pagination
|
|
page={currentPage}
|
|
totalPages={totalPages}
|
|
onChange={setPage}
|
|
itemsPerPage={PAGE_SIZE}
|
|
totalItems={totalGroups}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}; |