194 lines
8.1 KiB
JavaScript
194 lines
8.1 KiB
JavaScript
import React from "react";
|
|
import {
|
|
filterOrderGroups,
|
|
getOrderGroupDisplayStatusLabel,
|
|
getOrderGroupDisplayStatusValue,
|
|
getOrderGroupStatusTone,
|
|
ORDER_GROUP_DISPLAY_STATUS_OPTIONS,
|
|
} from "../../services/orderGroupViews";
|
|
import { Badge } from "../UI/Badge";
|
|
import { Panel } from "../UI/Panel";
|
|
import { SkeletonPage } from "../UI/Loading";
|
|
import { OrderFilters } from "../orders/OrderFilters";
|
|
import { 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 [collapsedSections, setCollapsedSections] = React.useState(new Set());
|
|
|
|
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 statusGroups = React.useMemo(() => {
|
|
const map = new Map();
|
|
for (const group of filteredGroups) {
|
|
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;
|
|
}, [filteredGroups]);
|
|
|
|
const FUNNEL_ORDER = [
|
|
"status:ready_for_notification",
|
|
"delivery:pending_confirmation",
|
|
"status:manual_required",
|
|
"status:first_sms_sent",
|
|
"status:second_sms_sent",
|
|
"delivery:agreed",
|
|
"delivery:driver_assigned",
|
|
"delivery:loaded",
|
|
"delivery:on_route",
|
|
"delivery:delivered",
|
|
"delivery:paid_storage",
|
|
"delivery:problem",
|
|
"delivery:cancelled",
|
|
];
|
|
|
|
const totalGroups = filteredGroups.length;
|
|
|
|
const TableHeader = () => (
|
|
<thead className="bg-[var(--color-surface-strong)] text-left text-xs uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
|
<tr>
|
|
<th className="px-4 py-3 font-medium">Клиент</th>
|
|
<th className="px-4 py-3 font-medium hidden sm:table-cell">Город</th>
|
|
<th className="px-4 py-3 font-medium hidden md:table-cell">Дата доставки</th>
|
|
<th className="px-4 py-3 font-medium hidden lg:table-cell">Водитель</th>
|
|
<th className="px-4 py-3 font-medium">Статус</th>
|
|
<th className="px-4 py-3 font-medium hidden md:table-cell">Обновлён</th>
|
|
</tr>
|
|
</thead>
|
|
);
|
|
|
|
if (isLoading) {
|
|
return <SkeletonPage panels={3} />;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<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>
|
|
</div>
|
|
<Badge tone="neutral">{totalGroups} групп</Badge>
|
|
</div>
|
|
|
|
<OrderFilters
|
|
filters={filters}
|
|
setFilters={setFilters}
|
|
statusOptions={statusOptions}
|
|
cities={cities}
|
|
/>
|
|
</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>
|
|
) : (
|
|
<div className="grid gap-4">
|
|
{Array.from(statusGroups.entries()).sort(([a], [b]) => {
|
|
const idxA = FUNNEL_ORDER.indexOf(a);
|
|
const idxB = FUNNEL_ORDER.indexOf(b);
|
|
if (idxA === -1 && idxB === -1) return a.localeCompare(b);
|
|
if (idxA === -1) return 1;
|
|
if (idxB === -1) return -1;
|
|
return idxA - idxB;
|
|
}).map(([statusValue, { label, groups }]) => {
|
|
const isCollapsed = collapsedSections.has(statusValue);
|
|
|
|
return (
|
|
<Panel key={statusValue} className="overflow-hidden p-0">
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center justify-between px-5 py-3 text-left transition hover:bg-[var(--color-accent-soft)]"
|
|
onClick={() => {
|
|
setCollapsedSections((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(statusValue)) {
|
|
next.delete(statusValue);
|
|
} else {
|
|
next.add(statusValue);
|
|
}
|
|
return next;
|
|
});
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-semibold">{label}</h3>
|
|
<Badge tone={groups.length > 0 ? "neutral" : "muted"}>{groups.length}</Badge>
|
|
</div>
|
|
<svg
|
|
className="h-4 w-4 text-[var(--color-text-muted)] transition-transform"
|
|
style={{ transform: isCollapsed ? "rotate(-90deg)" : "rotate(0deg)" }}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
|
|
{!isCollapsed && (
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full border-collapse border-t border-[var(--color-border)]">
|
|
<TableHeader />
|
|
<tbody>
|
|
{groups.map((group) => (
|
|
<tr
|
|
key={group.id}
|
|
className="cursor-pointer border-t border-[var(--color-border)] transition hover:bg-[var(--color-accent-soft)]"
|
|
onClick={() => { if (onSelectSet) onSelectSet(group.id); }}
|
|
>
|
|
<td className="px-4 py-2.5">
|
|
<div className="font-medium">{group.displayTitle || group.customerName || group.groupKey}</div>
|
|
<div className="text-xs text-[var(--color-text-muted)] sm:hidden">{group.customerPhone || "—"}</div>
|
|
<div className="text-xs text-[var(--color-text-muted)] md:hidden">{group.deliveryDate || "—"}</div>
|
|
</td>
|
|
<td className="px-4 py-2.5 text-sm hidden sm:table-cell">
|
|
{group.city || group.customerAddress || "—"}
|
|
</td>
|
|
<td className="px-4 py-2.5 text-sm hidden md:table-cell">
|
|
{group.deliveryDate
|
|
? <span>{group.deliveryDate}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
|
|
: <span className="text-[var(--color-text-muted)]">—</span>
|
|
}
|
|
</td>
|
|
<td className="px-4 py-2.5 text-sm hidden lg:table-cell">
|
|
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]">—</span>}
|
|
</td>
|
|
<td className="px-4 py-2.5">
|
|
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
|
|
</td>
|
|
<td className="px-4 py-2.5 text-sm text-[var(--color-text-muted)] hidden md:table-cell">
|
|
{formatDateTime(group.updatedAt)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</Panel>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}; |