389 lines
13 KiB
JavaScript
389 lines
13 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,
|
|
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 { 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";
|
|
|
|
// 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
|
|
}
|
|
};
|
|
|
|
// 7 columns: Клиент | Город | Тип | Дата | Водитель | Статус | Обновлён
|
|
const COLS = "grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(90px,1fr)_minmax(100px,1fr)_minmax(90px,0.8fr)]";
|
|
const MIN_W = "min-w-[1080px]";
|
|
|
|
const TableHeader = () => (
|
|
<div className={`grid ${COLS} 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>
|
|
);
|
|
|
|
const renderRow = (group, onSelectSet) => (
|
|
<button
|
|
key={group.id}
|
|
type="button"
|
|
className={`grid ${COLS} gap-0 w-full border-t border-[var(--color-border)] text-left transition hover:bg-[var(--color-accent-soft)]`}
|
|
onClick={() => { if (onSelectSet) onSelectSet(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 text-xs text-[var(--color-text-muted)]">
|
|
{group.city || group.customerAddress || "—"}
|
|
</div>
|
|
<div className="px-3 py-1.5 text-xs">
|
|
<span className="inline-flex items-center gap-1 whitespace-nowrap">
|
|
{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.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">
|
|
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]">—</span>}
|
|
</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 text-[var(--color-text-muted)]">
|
|
{formatDateTime(group.updatedAt)}
|
|
</div>
|
|
</button>
|
|
);
|
|
|
|
// Sortable section wrapper
|
|
const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, onSelectSet }) => {
|
|
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"
|
|
>
|
|
{/* Section header — drag handle + collapse toggle */}
|
|
<div className="flex w-full items-center justify-between">
|
|
{/* Drag handle */}
|
|
<button
|
|
type="button"
|
|
className="flex items-center px-3 py-3 cursor-grab active:cursor-grabbing text-[var(--color-text)] hover:bg-[var(--color-accent-soft)] rounded-l-[28px] touch-none"
|
|
title="Перетащите для изменения порядка"
|
|
{...attributes}
|
|
{...listeners}
|
|
>
|
|
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24" style={{ opacity: 0.6 }}>
|
|
<circle cx="9" cy="5" r="1.8" />
|
|
<circle cx="15" cy="5" r="1.8" />
|
|
<circle cx="9" cy="12" r="1.8" />
|
|
<circle cx="15" cy="12" r="1.8" />
|
|
<circle cx="9" cy="19" r="1.8" />
|
|
<circle cx="15" cy="19" r="1.8" />
|
|
</svg>
|
|
</button>
|
|
|
|
{/* Collapse toggle */}
|
|
<button
|
|
type="button"
|
|
className="flex flex-1 items-center justify-between py-3 pr-5 text-left transition hover:bg-[var(--color-surface-strong)]"
|
|
onClick={onToggle}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="text-sm 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>
|
|
</div>
|
|
|
|
{!isCollapsed && (
|
|
<div className="overflow-x-auto">
|
|
<div className={MIN_W}>
|
|
<TableHeader />
|
|
{groups.map((g) => renderRow(g, onSelectSet))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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(() => loadCollapsedSections());
|
|
const [sectionOrder, setSectionOrder] = React.useState(() => {
|
|
const custom = loadCustomOrder();
|
|
return custom || [...DEFAULT_FUNNEL_ORDER];
|
|
});
|
|
|
|
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 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 totalGroups = filteredGroups.length;
|
|
|
|
// 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">{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>
|
|
) : (
|
|
<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}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
)}
|
|
</div>
|
|
);
|
|
}; |