feat: manual SMS campaign shows groups in manual mode + pagination on all delivery pages

This commit is contained in:
root 2026-06-30 07:30:12 +00:00
parent 93d3ce4ca8
commit 858d36e5b0
4 changed files with 2048 additions and 410 deletions

View File

@ -0,0 +1,80 @@
import React from "react";
/**
* Universal pagination control.
* Usage: <Pagination page={1} totalPages={5} onChange={setPage} />
*/
export const Pagination = ({ page, totalPages, onChange, itemsPerPage, totalItems }) => {
if (totalPages <= 1) return null;
const from = (page - 1) * (itemsPerPage || 0) + 1;
const to = Math.min(page * (itemsPerPage || 0), totalItems || 0);
const pages = [];
const maxButtons = 7;
let start = Math.max(1, page - 3);
let end = Math.min(totalPages, start + maxButtons - 1);
if (end - start < maxButtons - 1) start = Math.max(1, end - maxButtons + 1);
for (let i = start; i <= end; i++) pages.push(i);
return (
<div className="flex flex-wrap items-center justify-between gap-2 px-4 py-3 border-t border-[var(--color-border)]">
<span className="text-xs text-[var(--color-text-muted)]">
{totalItems != null && itemsPerPage
? `${from}${to} из ${totalItems}`
: `Стр. ${page} из ${totalPages}`}
</span>
<div className="flex items-center gap-1.5">
<button
type="button"
disabled={page <= 1}
onClick={() => onChange(page - 1)}
className="rounded-lg border border-[var(--color-border)] px-2.5 py-1 text-xs font-medium text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-strong)] disabled:opacity-30 disabled:cursor-not-allowed"
>
</button>
{start > 1 && (
<>
<button
type="button"
onClick={() => onChange(1)}
className="rounded-lg px-2.5 py-1 text-xs font-medium text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] transition"
>1</button>
{start > 2 && <span className="text-xs text-[var(--color-text-muted)] px-1"></span>}
</>
)}
{pages.map((p) => (
<button
key={p}
type="button"
onClick={() => onChange(p)}
className={`rounded-lg px-2.5 py-1 text-xs font-medium transition ${
p === page
? "bg-[var(--color-accent)] text-white shadow-sm"
: "text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] border border-[var(--color-border)]"
}`}
>{p}</button>
))}
{end < totalPages && (
<>
{end < totalPages - 1 && <span className="text-xs text-[var(--color-text-muted)] px-1"></span>}
<button
type="button"
onClick={() => onChange(totalPages)}
className="rounded-lg px-2.5 py-1 text-xs font-medium text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] transition"
>{totalPages}</button>
</>
)}
<button
type="button"
disabled={page >= totalPages}
onClick={() => onChange(page + 1)}
className="rounded-lg border border-[var(--color-border)] px-2.5 py-1 text-xs font-medium text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-strong)] disabled:opacity-30 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@ -10,12 +10,15 @@ import { Badge } from "../UI/Badge";
import { Button } from "../UI/Button";
import { Panel } from "../UI/Panel";
import { SkeletonPage } from "../UI/Loading";
import { Pagination } from "../UI/Pagination";
import { OrderFilters } from "../orders/OrderFilters";
import { formatDate, formatDateTime } from "../../utils/formatters";
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => {
const [filters, setFilters] = React.useState({ query: "", displayStatus: "all", city: "" });
const [page, setPage] = React.useState(1);
const [collapsedSections, setCollapsedSections] = React.useState(new Set());
const PAGE_SIZE = 30;
const cities = React.useMemo(() => {
const set = new Set();
@ -30,9 +33,16 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
[filters, orderGroups],
);
// Paginate
const paginatedGroups = React.useMemo(() => {
const start = (page - 1) * PAGE_SIZE;
return filteredGroups.slice(start, start + PAGE_SIZE);
}, [filteredGroups, page]);
const totalPages = Math.ceil(filteredGroups.length / PAGE_SIZE);
const statusGroups = React.useMemo(() => {
const map = new Map();
for (const group of filteredGroups) {
for (const group of paginatedGroups) {
const statusValue = getOrderGroupDisplayStatusValue(group);
if (!map.has(statusValue)) {
const label = getOrderGroupDisplayStatusLabel(group);
@ -41,7 +51,7 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
map.get(statusValue).groups.push(group);
}
return map;
}, [filteredGroups]);
}, [paginatedGroups]);
const FUNNEL_ORDER = [
"status:ready_for_notification",
@ -186,6 +196,9 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
})}
</div>
)}
{totalPages > 1 && (
<Pagination page={page} totalPages={totalPages} onChange={setPage} itemsPerPage={PAGE_SIZE} totalItems={filteredGroups.length} />
)}
</div>
);
};

View File

@ -1,12 +1,15 @@
import { useState, useMemo } from "react";
import { formatDateTime } from "../../utils/formatters";
import { Badge } from "../UI/Badge";
import { Panel } from "../UI/Panel";
import { SkeletonTable } from "../UI/Loading";
import { Pagination } from "../UI/Pagination";
import { OrderFilters } from "./OrderFilters";
import {
getOrderGroupDisplayStatusLabel,
getOrderGroupStatusTone,
getOrderGroupDisplayStatusValue,
} from "../../services/orderGroupViews";
const MAX_VISIBLE_INVOICES = 2;
@ -18,110 +21,183 @@ const fmtDate = (d) => {
return `${day}.${m}.${y}`;
};
const getShipmentIssues = (group) => {
const data = group?.driverShipmentData;
if (!Array.isArray(data) || data.length === 0) return null;
const unshipped = data.filter((i) => !i.shipped);
if (unshipped.length === 0) return null;
return unshipped;
// Agreed delivery states client confirmed delivery/pickup
const AGREED_DELIVERY_STATES = ["agreed", "driver_assigned", "loaded", "on_route", "delivered", "picked_up", "pickup"];
const isAgreedDelivery = (group) => AGREED_DELIVERY_STATES.includes(group?.deliveryStatus || group?.delivery_status);
// Status group ordering
// Lower = higher priority (shown first)
const STATUS_ORDER = [
"delivery:agreed",
"delivery:pickup",
"delivery:driver_assigned",
"delivery:loaded",
"delivery:on_route",
"delivery:delivered",
"delivery:picked_up",
"delivery:requires_address",
"delivery:address_required",
"status:manual_required",
"delivery:paid_storage",
"delivery:problem",
"delivery:cancelled",
"status:ready_to_launch",
"status:first_sms_sent",
"status:second_sms_sent",
"status:link_ready",
"status:sms_sending",
"status:send_failed",
"status:not_started",
"status:unknown",
];
const getStatusOrder = (key) => {
const idx = STATUS_ORDER.indexOf(key);
return idx === -1 ? 999 : idx;
};
const buildGroupSummary = (group) => {
const orderCountLabel = `${group.ordersCount || 0} ${group.ordersCount === 1 ? "заказ" : group.ordersCount < 5 ? "заказа" : "заказов"}`;
const parts = [orderCountLabel];
if (group.deliveryDate) {
const datePart = group.deliveryTime ? `${fmtDate(group.deliveryDate)} · ${group.deliveryTime}` : fmtDate(group.deliveryDate);
parts.push(datePart);
}
if (group.assignedDriverName) {
parts.push(group.assignedDriverName);
}
// Collapsible status section
const StatusSection = ({ statusKey, groups, isOpen, onToggle, selectedOrderGroupId, onOpenOrder }) => {
const label = getOrderGroupDisplayStatusLabel(groups[0]);
const tone = getOrderGroupStatusTone(groups[0]);
const isAgreed = statusKey.startsWith("delivery:agreed") || statusKey.startsWith("delivery:pickup") ||
statusKey.startsWith("delivery:driver_assigned") || statusKey.startsWith("delivery:loaded") ||
statusKey.startsWith("delivery:on_route") || statusKey.startsWith("delivery:delivered") ||
statusKey.startsWith("delivery:picked_up");
return parts.join(" · ");
};
const renderOrderNumbers = (group) => {
const numbers = group.allBillNumbers || group.orderNumbers;
if (!Array.isArray(numbers) || !numbers.length) {
return "Номера не указаны";
}
if (numbers.length <= MAX_VISIBLE_INVOICES) {
return numbers.join(", ");
}
const visible = numbers.slice(0, MAX_VISIBLE_INVOICES);
const remaining = numbers.length - MAX_VISIBLE_INVOICES;
return `${visible.join(", ")} +${remaining}`;
};
const getDeliveryTypeLabel = (deliveryType) => {
if (deliveryType === "pickup") return "Самовывоз";
if (deliveryType === "delivery") return "Доставка";
return "—";
};
const renderMobileOrderNumbers = (group) => {
const numbers = group.allBillNumbers || group.orderNumbers;
if (!Array.isArray(numbers) || !numbers.length) {
return "Номера не указаны";
}
if (numbers.length <= MAX_VISIBLE_INVOICES) {
return numbers.join(", ");
}
const visible = numbers.slice(0, MAX_VISIBLE_INVOICES);
const remaining = numbers.length - MAX_VISIBLE_INVOICES;
return (
<>
{visible.join(", ")}
<span className="ml-1 rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-xs font-medium text-[var(--color-accent)]">+{remaining}</span>
</>
<div className="border-b border-[var(--color-border)] last:border-b-0">
{/* Section header */}
<button
type="button"
onClick={onToggle}
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left transition hover:bg-[var(--color-accent-soft)] ${
isAgreed ? "bg-[rgba(18,128,92,0.04)]" : ""
}`}
>
<svg
className="h-4 w-4 shrink-0 text-[var(--color-text-muted)] transition-transform"
style={{ transform: isOpen ? "rotate(90deg)" : "rotate(0deg)" }}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
<Badge tone={tone}>{label}</Badge>
<span className="text-xs text-[var(--color-text-muted)]">{groups.length}</span>
</button>
{/* Rows */}
{isOpen && (
<div>
{groups.map((group) => {
const hasProblem = group.hasDeliveryProblem;
const isAgreedRow = isAgreedDelivery(group);
const rowClassName = `grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(80px,0.8fr)_minmax(80px,1fr)_minmax(100px,1fr)_minmax(70px,0.7fr)_minmax(80px,0.8fr)] gap-0 w-full border-t text-left transition ${
isAgreedRow
? "border-[rgba(18,128,92,0.25)] bg-[rgba(18,128,92,0.07)] hover:bg-[rgba(18,128,92,0.12)]"
: hasProblem
? "border-[var(--color-border)] bg-[rgba(201,61,61,0.1)] hover:bg-[rgba(201,61,61,0.15)]"
: "border-[var(--color-border)] hover:bg-[var(--color-accent-soft)]"
} ${selectedOrderGroupId === group.id ? "bg-[var(--color-accent-soft)]" : ""}`;
const billNumbers = group.allBillNumbers || group.orderNumbers || [];
const primaryBill = billNumbers[0] || "—";
const totalBills = billNumbers.length;
return (
<button
key={group.id}
type="button"
className={rowClassName}
onClick={() => onOpenOrder(group.id)}
>
<div className="min-w-0 px-3 py-1.5">
<div className="text-xs font-medium leading-snug break-words" style={{display:"-webkit-box",WebkitLineClamp:2,WebkitBoxOrient:"vertical",overflow:"hidden"}}>{group.displayTitle || group.customerName || group.groupKey}</div>
<div className="mt-0.5 text-[11px] text-[var(--color-text-muted)]">
{group.customerPhone || ""}
</div>
</div>
<div className="px-3 py-1.5">
<div className="text-xs text-[var(--color-text)]">{primaryBill}</div>
{totalBills > 1 && (
<span className="inline-block mt-0.5 rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent)]">
{totalBills} сч.
</span>
)}
</div>
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
{group.city || "—"}
</div>
<div className="px-3 py-1.5 flex items-center justify-center">
<Badge tone={getOrderGroupStatusTone(group)}>
{getOrderGroupDisplayStatusLabel(group)}
</Badge>
</div>
<div className="px-3 py-1.5 text-xs">
{group.deliveryDate ? (
<span>{fmtDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
) : (
<span className="text-[var(--color-text-muted)]"></span>
)}
</div>
<div className="px-3 py-1.5 text-xs">
<span className="inline-flex items-center gap-1">
{group.deliveryType === "pickup" ? "🏪" : "🚚"}
<span className="text-[var(--color-text-muted)]">{group.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}</span>
</span>
</div>
<div className="px-3 py-1.5 text-xs">
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]"></span>}
</div>
</button>
);
})}
</div>
)}
</div>
);
};
export const OrdersTable = ({
orderGroups = [],
selectedOrderGroupId,
onOpenOrder,
filters,
setFilters,
statusOptions,
cities = [],
isLoading = false,
}) => {
if (isLoading) {
return <SkeletonTable rows={5} cols={5} />;
}
// Mobile collapsible section
const MobileStatusSection = ({ statusKey, groups, isOpen, onToggle, selectedOrderGroupId, onOpenOrder }) => {
const label = getOrderGroupDisplayStatusLabel(groups[0]);
const tone = getOrderGroupStatusTone(groups[0]);
const isAgreed = statusKey.startsWith("delivery:agreed") || statusKey.startsWith("delivery:pickup") ||
statusKey.startsWith("delivery:driver_assigned") || statusKey.startsWith("delivery:loaded") ||
statusKey.startsWith("delivery:on_route") || statusKey.startsWith("delivery:delivered") ||
statusKey.startsWith("delivery:picked_up");
return (
<Panel className="p-0">
<div className="space-y-4 border-b border-[var(--color-border)] px-5 py-4">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold">Группы доставки</h2>
<p className="text-sm text-[var(--color-text-muted)]">
Поиск по группе, клиенту, телефону и дате доставки.
</p>
</div>
<Badge tone="neutral">{orderGroups.length}</Badge>
</div>
{filters && setFilters ? (
<OrderFilters filters={filters} setFilters={setFilters} statusOptions={statusOptions} cities={cities} />
) : null}
</div>
<div className="space-y-3 p-4 md:hidden">
{!orderGroups.length ? (
<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>
) : null}
{orderGroups.map((group) => {
<div className="mb-3">
<button
type="button"
onClick={onToggle}
className={`flex w-full items-center gap-3 rounded-2xl border px-4 py-2.5 text-left transition ${
isAgreed
? "border-[rgba(18,128,92,0.25)] bg-[rgba(18,128,92,0.06)]"
: "border-[var(--color-border)] bg-[var(--color-surface-strong)]"
}`}
>
<svg
className="h-4 w-4 shrink-0 text-[var(--color-text-muted)] transition-transform"
style={{ transform: isOpen ? "rotate(90deg)" : "rotate(0deg)" }}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
<Badge tone={tone}>{label}</Badge>
<span className="text-xs text-[var(--color-text-muted)]">{groups.length}</span>
</button>
{isOpen && (
<div className="mt-2 space-y-3">
{groups.map((group) => {
const hasProblem = group.hasDeliveryProblem;
const isAgreedCard = isAgreedDelivery(group);
const baseClass = "w-full rounded-[22px] border text-left transition";
const selectedClass = selectedOrderGroupId === group.id
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
: isAgreedCard
? "border-[rgba(18,128,92,0.35)] bg-[rgba(18,128,92,0.08)]"
: hasProblem
? "border-[var(--color-danger)] bg-[rgba(201,61,61,0.1)]"
: "border-[var(--color-border)] bg-[var(--color-surface-strong)]";
@ -170,7 +246,107 @@ export const OrdersTable = ({
);
})}
</div>
)}
</div>
);
};
export const OrdersTable = ({
orderGroups = [],
selectedOrderGroupId,
onOpenOrder,
filters,
setFilters,
statusOptions,
cities = [],
isLoading = false,
}) => {
const [collapsedSections, setCollapsedSections] = useState({});
const [page, setPage] = useState(1);
const PAGE_SIZE = 30;
// Paginate groups before grouping
const paginatedGroups = useMemo(() => {
const start = (page - 1) * PAGE_SIZE;
return orderGroups.slice(start, start + PAGE_SIZE);
}, [orderGroups, page]);
const totalPages = Math.ceil(orderGroups.length / PAGE_SIZE);
// Group by status
const grouped = useMemo(() => {
const map = {};
for (const g of paginatedGroups) {
const key = getOrderGroupDisplayStatusValue(g);
if (!map[key]) map[key] = [];
map[key].push(g);
}
// Sort by priority
return Object.entries(map)
.map(([key, groups]) => ({ key, groups, order: getStatusOrder(key) }))
.sort((a, b) => a.order - b.order);
}, [orderGroups]);
// Default: first section open, rest collapsed (only if not manually toggled)
const effectiveCollapsed = useMemo(() => {
const result = {};
grouped.forEach((sec, i) => {
if (sec.key in collapsedSections) {
result[sec.key] = collapsedSections[sec.key];
} else {
result[sec.key] = i !== 0; // first section open by default
}
});
return result;
}, [grouped, collapsedSections]);
const toggleSection = (key) => {
setCollapsedSections(prev => ({ ...prev, [key]: !prev[key] }));
};
if (isLoading) {
return <SkeletonTable rows={5} cols={5} />;
}
return (
<Panel className="p-0">
<div className="space-y-4 border-b border-[var(--color-border)] px-5 py-4">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold">Группы доставки</h2>
<p className="text-sm text-[var(--color-text-muted)]">
Поиск по группе, клиенту, телефону и дате доставки.
</p>
</div>
<Badge tone="neutral">{orderGroups.length}</Badge>
</div>
{filters && setFilters ? (
<OrderFilters filters={filters} setFilters={setFilters} statusOptions={statusOptions} cities={cities} />
) : null}
</div>
{/* Mobile: collapsible sections */}
<div className="space-y-2 p-4 md:hidden">
{!orderGroups.length ? (
<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>
) : null}
{grouped.map(({ key, groups }) => (
<MobileStatusSection
key={key}
statusKey={key}
groups={groups}
isOpen={!effectiveCollapsed[key]}
onToggle={() => toggleSection(key)}
selectedOrderGroupId={selectedOrderGroupId}
onOpenOrder={onOpenOrder}
/>
))}
</div>
{/* Desktop: collapsible sections with table header */}
<div className="hidden md:block">
{!orderGroups.length ? (
<div className="px-5 py-6 text-sm text-[var(--color-text-muted)]">
@ -179,79 +355,24 @@ export const OrdersTable = ({
) : (
<div className="overflow-x-auto">
<div className="min-w-[920px]">
<div className="grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(80px,0.8fr)_minmax(80px,1fr)_minmax(100px,1fr)_minmax(70px,0.7fr)_minmax(80px,0.8fr)] gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.12em] text-[var(--color-text-muted)]">
<div className="px-3 py-1.5 font-medium">Группа / Клиент</div>
<div className="px-3 py-1.5 font-medium">Счета</div>
<div className="px-3 py-1.5 font-medium">Город</div>
<div className="px-3 py-1.5 font-medium">Статус</div>
<div className="px-3 py-1.5 font-medium">Дата доставки</div>
<div className="px-3 py-1.5 font-medium">Тип</div>
<div className="px-3 py-1.5 font-medium">Водитель</div>
</div>
{orderGroups.map((group) => {
const hasProblem = group.hasDeliveryProblem;
const rowClassName = `grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(80px,0.8fr)_minmax(80px,1fr)_minmax(100px,1fr)_minmax(70px,0.7fr)_minmax(80px,0.8fr)] gap-0 w-full border-t border-[var(--color-border)] text-left transition ${
hasProblem
? "bg-[rgba(201,61,61,0.1)] hover:bg-[rgba(201,61,61,0.15)]"
: "hover:bg-[var(--color-accent-soft)]"
} ${selectedOrderGroupId === group.id ? "bg-[var(--color-accent-soft)]" : ""}`;
const billNumbers = group.allBillNumbers || group.orderNumbers || [];
const primaryBill = billNumbers[0] || "—";
const totalBills = billNumbers.length;
return (
<button
key={group.id}
type="button"
className={rowClassName}
onClick={() => onOpenOrder(group.id)}
>
<div className="min-w-0 px-3 py-1.5">
<div className="text-xs font-medium leading-snug break-words" style={{display:"-webkit-box",WebkitLineClamp:2,WebkitBoxOrient:"vertical",overflow:"hidden"}}>{group.displayTitle || group.customerName || group.groupKey}</div>
<div className="mt-0.5 text-[11px] text-[var(--color-text-muted)]">
{group.customerPhone || ""}
</div>
</div>
<div className="px-3 py-1.5">
<div className="text-xs text-[var(--color-text)]">{primaryBill}</div>
{totalBills > 1 && (
<span className="inline-block mt-0.5 rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent)]">
{totalBills} сч.
</span>
)}
</div>
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
{group.city || "—"}
</div>
<div className="px-3 py-1.5">
<Badge tone={getOrderGroupStatusTone(group)}>
{getOrderGroupDisplayStatusLabel(group)}
</Badge>
</div>
<div className="px-3 py-1.5 text-xs">
{group.deliveryDate ? (
<span>{fmtDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
) : (
<span className="text-[var(--color-text-muted)]"></span>
)}
</div>
<div className="px-3 py-1.5 text-xs">
<span className="inline-flex items-center gap-1">
{group.deliveryType === "pickup" ? "🏪" : "🚚"}
<span className="text-[var(--color-text-muted)]">{group.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}</span>
</span>
</div>
<div className="px-3 py-1.5 text-xs">
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]"></span>}
</div>
</button>
);
})}
{grouped.map(({ key, groups }) => (
<StatusSection
key={key}
statusKey={key}
groups={groups}
isOpen={!effectiveCollapsed[key]}
onToggle={() => toggleSection(key)}
selectedOrderGroupId={selectedOrderGroupId}
onOpenOrder={onOpenOrder}
/>
))}
</div>
</div>
)}
</div>
{totalPages > 1 && (
<Pagination page={page} totalPages={totalPages} onChange={setPage} itemsPerPage={PAGE_SIZE} totalItems={orderGroups.length} />
)}
</Panel>
);
};