- {/* Drag handle */}
+
+
-
- {/* Collapse toggle */}
-
{!isCollapsed && (
-
+
{groups.map((g) => renderRow(g, onSelectSet))}
@@ -254,6 +245,19 @@ const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, on
);
};
+// Check if any filter is active
+const isFilterActive = (filters) => {
+ if (!filters) return false;
+ if (filters.query && filters.query.trim()) return true;
+ if (filters.displayStatus && filters.displayStatus !== "all") return true;
+ if (filters.city && filters.city.trim()) return true;
+ if (filters.deliveryStatus && filters.deliveryStatus !== "all") return true;
+ if (filters.deliveryHalfDay && filters.deliveryHalfDay !== "all") return true;
+ if (filters.dateFrom) return true;
+ if (filters.dateTo) return true;
+ return false;
+};
+
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS, isLoading = false }) => {
const FILTERS_KEY = 'logistics-board-filters';
const [filters, setFilters] = React.useState(() => {
@@ -271,6 +275,33 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
const custom = loadCustomOrder();
return custom || [...DEFAULT_FUNNEL_ORDER];
});
+ const [createdSort, setCreatedSort] = React.useState(() => {
+ try {
+ return localStorage.getItem(SORT_KEY) === "asc" ? "asc" : "desc";
+ } catch {
+ return "desc";
+ }
+ });
+ const toggleCreatedSort = React.useCallback(() => {
+ setCreatedSort((current) => {
+ const next = current === "desc" ? "asc" : "desc";
+ try { localStorage.setItem(SORT_KEY, next); } catch {}
+ return next;
+ });
+ setPage(1);
+ }, []);
+
+ // Pagination state
+ const [page, setPage] = React.useState(() => {
+ try {
+ const saved = localStorage.getItem(PAGE_KEY);
+ if (saved) return Math.max(1, parseInt(saved, 10) || 1);
+ } catch {}
+ return 1;
+ });
+ React.useEffect(() => {
+ try { localStorage.setItem(PAGE_KEY, String(page)); } catch {}
+ }, [page]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
@@ -288,10 +319,39 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
() => filterOrderGroups(orderGroups, filters),
[filters, orderGroups],
);
+ const rankedGroups = React.useMemo(() => {
+ const direction = createdSort === "asc" ? 1 : -1;
+ return [...filteredGroups].sort((a, b) => {
+ const aTime = a.createdAt ? new Date(a.createdAt).getTime() : 0;
+ const bTime = b.createdAt ? new Date(b.createdAt).getTime() : 0;
+ return (aTime - bTime) * direction;
+ });
+ }, [filteredGroups, createdSort]);
+
+ // Determine if filters are active — if so, show all results (no pagination)
+ const hasActiveFilters = isFilterActive(filters);
+
+ // Reset to page 1 when filters change
+ React.useEffect(() => {
+ setPage(1);
+ }, [filters.query, filters.displayStatus, filters.city, filters.deliveryStatus, filters.deliveryHalfDay, filters.dateFrom, filters.dateTo]);
+
+ const totalGroups = rankedGroups.length;
+ const totalPages = Math.max(1, Math.ceil(totalGroups / PAGE_SIZE));
+
+ // Clamp page if totalGroups decreased
+ const currentPage = Math.min(page, totalPages);
+
+ // Paginate: when no active filters, slice to current page
+ const paginatedGroups = React.useMemo(() => {
+ if (hasActiveFilters) return rankedGroups;
+ const start = (currentPage - 1) * PAGE_SIZE;
+ return rankedGroups.slice(start, start + PAGE_SIZE);
+ }, [rankedGroups, currentPage, hasActiveFilters]);
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);
@@ -300,9 +360,7 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
map.get(statusValue).groups.push(group);
}
return map;
- }, [filteredGroups]);
-
- const totalGroups = filteredGroups.length;
+ }, [paginatedGroups]);
// Build sorted list: use sectionOrder for known statuses, append unknown ones at end
const sortedEntries = React.useMemo(() => {
@@ -370,6 +428,12 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
statusOptions={statusOptions}
cities={cities}
/>
+
+ {!hasActiveFilters && totalGroups > PAGE_SIZE && (
+
+ Показано {Math.min(currentPage * PAGE_SIZE, totalGroups)} из {totalGroups} · по {PAGE_SIZE} на странице
+
+ )}
{!totalGroups ? (
@@ -410,6 +474,8 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
});
}}
onSelectSet={onSelectSet}
+ createdSort={createdSort}
+ onToggleCreatedSort={toggleCreatedSort}
/>
);
})}
@@ -417,6 +483,16 @@ export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet, statusO
)}
+
+ {!hasActiveFilters && totalGroups > PAGE_SIZE && (
+
+ )}
);
};
\ No newline at end of file
diff --git a/src/components/orders/CalledPanel.jsx b/src/components/orders/CalledPanel.jsx
new file mode 100644
index 0000000..3e8971f
--- /dev/null
+++ b/src/components/orders/CalledPanel.jsx
@@ -0,0 +1,125 @@
+import React, { useState } from "react";
+import { supabase } from "../../supabaseClient";
+import { Button } from "../UI/Button";
+
+const CalledPanel = ({ order, onUpdate }) => {
+ const [busy, setBusy] = useState(false);
+ const [editing, setEditing] = useState(false);
+ const [comment, setComment] = useState(order?.called_comment || "");
+ const [callDate, setCallDate] = useState(() => {
+ if (!order?.called_at) return "";
+ const d = new Date(order.called_at);
+ return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
+ });
+ const [callTime, setCallTime] = useState(() => {
+ if (!order?.called_at) return "";
+ const d = new Date(order.called_at);
+ return `${String(d.getHours()).padStart(2,"0")}:${String(d.getMinutes()).padStart(2,"0")}`;
+ });
+
+ const isCalled = !!order?.called;
+
+ const formatCalledAt = (ts) => {
+ if (!ts) return "";
+ try {
+ return new Date(ts).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
+ } catch { return ""; }
+ };
+
+ const save = async (newCalled) => {
+ if (busy || !order?.id) return;
+ setBusy(true);
+ try {
+ let calledAt = null;
+ if (newCalled) {
+ if (callDate && callTime) {
+ calledAt = new Date(`${callDate}T${callTime}:00`).toISOString();
+ } else {
+ calledAt = new Date().toISOString();
+ }
+ }
+ const { error } = await supabase
+ .from("order_groups")
+ .update({ called: newCalled, called_at: calledAt, called_comment: newCalled ? comment.trim() : null })
+ .eq("id", order.id);
+ if (error) {
+ console.warn("[CalledPanel] update error", error);
+ } else {
+ setEditing(false);
+ if (onUpdate) await onUpdate();
+ }
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ // Form mode: date + time + comment
+ if (editing) {
+ return (
+
+
+
+
+
+
+
+ {isCalled && (
+
+ )}
+
+
+
+ );
+ }
+
+ // Default mode: buttons like status delivery
+ return (
+
+
+ {isCalled ? (
+ <>
+
+
+ {order?.called_at && (
+ {formatCalledAt(order.called_at)}
+ )}
+ {order?.called_comment && (
+ «{order.called_comment}»
+ )}
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ );
+};
+
+export { CalledPanel };
+export default CalledPanel;
\ No newline at end of file
diff --git a/src/components/orders/CalledToggle.jsx b/src/components/orders/CalledToggle.jsx
new file mode 100644
index 0000000..976cf0b
--- /dev/null
+++ b/src/components/orders/CalledToggle.jsx
@@ -0,0 +1,53 @@
+import React, { useState } from "react";
+import { supabase } from "../../supabaseClient";
+
+export const CalledToggle = ({ order, onUpdate }) => {
+ const [busy, setBusy] = useState(false);
+ const current = !!order?.called;
+
+ const toggle = async () => {
+ if (busy || !order?.id) return;
+ setBusy(true);
+ try {
+ const { error } = await supabase
+ .from("order_groups")
+ .update({ called: !current, called_at: new Date().toISOString() })
+ .eq("id", order.id);
+ if (error) {
+ console.warn("[CalledToggle] update error", error);
+ } else if (onUpdate) {
+ await onUpdate();
+ }
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ if (current) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+export default CalledToggle;
diff --git a/src/components/orders/OrderDetailPanel.jsx b/src/components/orders/OrderDetailPanel.jsx
index cdb8163..2e55990 100644
--- a/src/components/orders/OrderDetailPanel.jsx
+++ b/src/components/orders/OrderDetailPanel.jsx
@@ -77,6 +77,89 @@ import {
normalizeNom,
} from "../../utils/deliveryUtils";
import { SmsStatusCard } from "./SmsStatusCard";
+import { OrderHistoryTimeline } from "./OrderHistoryTimeline";
+import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
+import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+
+// ---- CollapsibleBlock wrapper (localStorage-persisted collapse state) ----
+const COLLAPSE_STORAGE_KEY = "supersam-block-collapsed";
+
+const CollapsibleBlock = ({ blockKey, title, children, defaultCollapsed = false, dragAttributes, dragListeners }) => {
+ const [collapsed, setCollapsed] = React.useState(() => {
+ try {
+ const saved = JSON.parse(localStorage.getItem(COLLAPSE_STORAGE_KEY) || "{}");
+ return saved[blockKey] ?? defaultCollapsed;
+ } catch { return defaultCollapsed; }
+ });
+ const toggle = () => {
+ setCollapsed(prev => {
+ const next = !prev;
+ try {
+ const saved = JSON.parse(localStorage.getItem(COLLAPSE_STORAGE_KEY) || "{}");
+ saved[blockKey] = next;
+ localStorage.setItem(COLLAPSE_STORAGE_KEY, JSON.stringify(saved));
+ } catch {}
+ return next;
+ });
+ };
+ return (
+
+
+
+
+
+ {!collapsed && {children}
}
+
+ );
+};
+
+// ---- SortableBlock wrapper (drag handle for reordering) ----
+const SortableBlock = ({ id, children }) => {
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1,
+ };
+ return (
+
+ {typeof children === "function" ? children({ dragAttributes: attributes, dragListeners: listeners }) : children}
+
+ );
+};
+
+// Default block order — same keys as CollapsibleBlock blockKeys
+const DEFAULT_BLOCK_ORDER = [
+ "manual_confirmation",
+ "driver_assignment",
+ "shipment",
+ "sms_status",
+ "status_actions",
+ "paid_storage",
+ "delivery_link",
+ "order_history",
+ "extra_data",
+];
+
+const BLOCK_TITLES = {
+ manual_confirmation: "Ручное согласование",
+ driver_assignment: "Назначение водителя",
+ shipment: "Отгрузка",
+ sms_status: "Статус SMS",
+ status_actions: "Действия по статусу",
+ paid_storage: "Платное хранение",
+ delivery_link: "Ссылка на согласование",
+ order_history: "История действий по заказу",
+ extra_data: "Дополнительные данные",
+};
+
+const ORDER_STORAGE_KEY = "supersam-block-order";
const fmtTime = (ts) => {
if (!ts) return "—";
@@ -451,10 +534,10 @@ const PaidStoragePanel = ({ order, onChangeDeliveryStatus, isSavingStatusChange,
if (isPaidStorage) {
return (
-
+
- Платное хранение
+ Заказ на платном хранении
{order.paidStorageAt && (
@@ -479,15 +562,14 @@ const PaidStoragePanel = ({ order, onChangeDeliveryStatus, isSavingStatusChange,
>
Отменить платное хранение
-
+
);
}
return (
-
+
-
Платное хранение
-
+
Переведите заказ в статус платного хранения, если клиент не забрал товар в срок.
@@ -533,7 +615,7 @@ const PaidStoragePanel = ({ order, onChangeDeliveryStatus, isSavingStatusChange,
Перевести в платное хранение
)}
-
+
);
};
@@ -599,6 +681,31 @@ export const OrderDetailPanel = ({
const [deliveryAddress, setDeliveryAddress] = React.useState(order?.originalDeliveryAddress || order?.deliveryAddress || order?.customerAddress || "");
const [confirmAction, setConfirmAction] = React.useState(null);
const [isEditingDate, setIsEditingDate] = React.useState(false);
+
+ // ---- Drag-and-drop block reordering state ----
+ const isLogisticsRole = ["manager", "logistician", "admin", "mega_admin"].includes(userRole);
+ const [blockOrder, setBlockOrder] = React.useState(() => {
+ try {
+ const saved = JSON.parse(localStorage.getItem(ORDER_STORAGE_KEY) || "[]");
+ if (Array.isArray(saved) && saved.length > 0) return saved;
+ } catch {}
+ return DEFAULT_BLOCK_ORDER;
+ });
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
+ );
+ const handleDragEnd = (event) => {
+ const { active, over } = event;
+ if (!over || active.id === over.id) return;
+ setBlockOrder(prev => {
+ const oldIndex = prev.indexOf(active.id);
+ const newIndex = prev.indexOf(over.id);
+ if (oldIndex === -1 || newIndex === -1) return prev;
+ const next = arrayMove(prev, oldIndex, newIndex);
+ try { localStorage.setItem(ORDER_STORAGE_KEY, JSON.stringify(next)); } catch {}
+ return next;
+ });
+ };
const handleShipmentChange = React.useCallback((state) => {
setShipmentState(state);
}, []);
@@ -942,217 +1049,418 @@ export const OrderDetailPanel = ({
+ {/* ===== Collapsible + sortable blocks (logistics/admin/manager only) ===== */}
+ {isLogisticsRole && order ? (
+