supersam/src/components/logistics/LogisticsReadinessBoard.jsx

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>
);
};