feat: manual SMS campaign shows groups in manual mode + pagination on all delivery pages
This commit is contained in:
parent
93d3ce4ca8
commit
858d36e5b0
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
Loading…
Reference in New Issue