From 60d603308525799cbdffd6a1606afd8af9da3f7a Mon Sep 17 00:00:00 2001 From: root Date: Thu, 6 Aug 2026 06:52:32 +0000 Subject: [PATCH] feat(logistics): improve order workflow and aging --- docker-compose.dev.yml | 25 + public/service-worker.js | 4 +- src/OrdersTable.jsx | 79 +- src/components/driver/DriverShipmentPanel.jsx | 277 ++++- .../logistics/LogisticsReadinessBoard.jsx | 288 +++-- src/components/orders/CalledPanel.jsx | 125 ++ src/components/orders/CalledToggle.jsx | 53 + src/components/orders/OrderDetailPanel.jsx | 1089 +++++++++++------ .../orders/OrderHistoryTimeline.jsx | 260 ++++ src/components/orders/SmsStatusCard.jsx | 5 +- src/components/orders/StatusActionPanel.jsx | 21 +- src/services/supabase/orderGroupRepository.js | 2 +- 12 files changed, 1667 insertions(+), 561 deletions(-) create mode 100644 docker-compose.dev.yml create mode 100644 src/components/orders/CalledPanel.jsx create mode 100644 src/components/orders/CalledToggle.jsx create mode 100644 src/components/orders/OrderHistoryTimeline.jsx diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..21666e5 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,25 @@ +services: + supersam-dev: + build: + context: /opt/supersam-dev + dockerfile: Dockerfile + args: + VITE_SUPABASE_URL: ${VITE_SUPABASE_URL} + VITE_SUPABASE_ANON_KEY: ${VITE_SUPABASE_ANON_KEY} + VITE_SUPABASE_SERVICE_ROLE_KEY: ${VITE_SUPABASE_SERVICE_ROLE_KEY:-} + container_name: supersam-dev + restart: unless-stopped + networks: + - coolify + labels: + - traefik.enable=true + - traefik.http.routers.supersam-dev.rule=Host(`dev.mkn8n.ru`) + - traefik.http.routers.supersam-dev.entryPoints=https + - traefik.http.routers.supersam-dev.tls=true + - traefik.http.routers.supersam-dev.tls.certresolver=letsencrypt + - traefik.http.routers.supersam-dev.service=supersam-dev + - traefik.http.services.supersam-dev.loadbalancer.server.port=80 + +networks: + coolify: + external: true diff --git a/public/service-worker.js b/public/service-worker.js index 612d17f..985cb06 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,8 +1,8 @@ const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1"; if (!isLocalhost) { - const STATIC_CACHE = "construction-delivery-static-v49"; - const RUNTIME_CACHE = "construction-delivery-runtime-v49"; + const STATIC_CACHE = "construction-delivery-static-v78"; + const RUNTIME_CACHE = "construction-delivery-runtime-v78"; const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"]; self.addEventListener("install", (event) => { diff --git a/src/OrdersTable.jsx b/src/OrdersTable.jsx index f6774f0..5c25842 100644 --- a/src/OrdersTable.jsx +++ b/src/OrdersTable.jsx @@ -11,6 +11,8 @@ import { const MAX_VISIBLE_INVOICES = 2; +const TERMINAL_STATUSES = ["delivered", "picked_up", "cancelled"]; + const fmtDate = (d) => { if (!d) return ''; const [y, m, day] = d.split('-'); @@ -18,6 +20,28 @@ const fmtDate = (d) => { return `${day}.${m}.${y}`; }; +const hasNoSms = (group) => { + if (!group) return false; + if (group.firstSmsSentAt) return false; + if (TERMINAL_STATUSES.includes(group.deliveryStatus)) return false; + return true; +}; + +const getDaysSinceCreation = (group) => { + if (!group) return null; + const ts = group.createdFromExchangeAt || group.createdAt || group.updatedAt; + if (!ts) return null; + const days = Math.floor((Date.now() - new Date(ts)) / 86400000); + return days < 0 ? 0 : days; +}; + +const getDaysColorClass = (days) => { + if (days == null) return "text-[var(--color-text-muted)]"; + if (days < 3) return "text-[var(--color-text)]"; + if (days <= 7) return "text-[var(--color-warning)]"; + return "text-[var(--color-danger)]"; +}; + const getShipmentIssues = (group) => { const data = group?.driverShipmentData; if (!Array.isArray(data) || data.length === 0) return null; @@ -119,11 +143,15 @@ export const OrdersTable = ({ ) : null} {orderGroups.map((group) => { const hasProblem = group.hasDeliveryProblem; + const noSms = hasNoSms(group); + const days = getDaysSinceCreation(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)]" : hasProblem ? "border-[var(--color-danger)] bg-[rgba(201,61,61,0.1)]" + : noSms + ? "border-[var(--color-border)] bg-[rgba(245,158,11,0.05)]" : "border-[var(--color-border)] bg-[var(--color-surface-strong)]"; const allNumbers = group.allBillNumbers || group.orderNumbers || []; @@ -155,6 +183,17 @@ export const OrdersTable = ({ {getOrderGroupDisplayStatusLabel(group)} + {noSms && ( + + + SMS не отправлено + + )} + {days != null && ( + + {days} дн + + )} {group.hasDeliveryProblem && ( @@ -178,23 +217,32 @@ export const OrdersTable = ({ ) : (
-
-
+
+
Группа / Клиент
Счета
Город
Статус
Дата доставки
+
Дней
Тип
Водитель
{orderGroups.map((group) => { const hasProblem = group.hasDeliveryProblem; - const rowClassName = `grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,0.8fr)_minmax(90px,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 noSms = hasNoSms(group); + const days = getDaysSinceCreation(group); + let rowBg = ""; + if (selectedOrderGroupId === group.id) { + rowBg = "bg-[var(--color-accent-soft)]"; + } else if (hasProblem) { + rowBg = "bg-[rgba(201,61,61,0.1)] hover:bg-[rgba(201,61,61,0.15)]"; + } else if (noSms) { + rowBg = "bg-[rgba(245,158,11,0.05)] hover:bg-[rgba(245,158,11,0.1)]"; + } else { + rowBg = "hover:bg-[var(--color-accent-soft)]"; + } + const rowClassName = `grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(100px,0.8fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(70px,0.5fr)_minmax(100px,0.8fr)_minmax(90px,0.8fr)] gap-0 w-full border-t border-[var(--color-border)] text-left transition ${rowBg}`; const billNumbers = group.allBillNumbers || group.orderNumbers || []; const primaryBill = billNumbers[0] || "—"; @@ -225,9 +273,17 @@ export const OrdersTable = ({ {group.city || "—"}
- - {getOrderGroupDisplayStatusLabel(group)} - +
+ + {getOrderGroupDisplayStatusLabel(group)} + + {noSms && ( + + + SMS + + )} +
{group.deliveryDate ? ( @@ -236,6 +292,9 @@ export const OrdersTable = ({ )}
+
+ {days != null ? `${days} дн` : "—"} +
{group.deliveryType === "pickup" ? "🏪" : "🚚"} diff --git a/src/components/driver/DriverShipmentPanel.jsx b/src/components/driver/DriverShipmentPanel.jsx index b5a93a6..e145224 100644 --- a/src/components/driver/DriverShipmentPanel.jsx +++ b/src/components/driver/DriverShipmentPanel.jsx @@ -2,7 +2,6 @@ import React from "react"; import { supabase } from "../../supabaseClient"; import { Badge } from "../UI/Badge"; import { Button } from "../UI/Button"; -import { Panel } from "../UI/Panel"; import { matchesStopWord } from "../../hooks/useStopWords"; const parseOrderItems = (order) => { @@ -20,6 +19,7 @@ const parseOrderItems = (order) => { const hasProducts = subItems.some( (p) => typeof p === "object" && (p.product_name || p.name) ); + const orderNom = String(sub.nom || sub.name || "").trim(); if (hasProducts) { for (const p of subItems) { if (!p || typeof p !== "object") continue; @@ -30,6 +30,7 @@ const parseOrderItems = (order) => { name, quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(), unit: String(p.product_ed || p.unit || "").trim(), + orderNom, }); } } else if (sub.nom || sub.name) { @@ -38,6 +39,7 @@ const parseOrderItems = (order) => { name: String(sub.nom || sub.name || "").trim(), quantity: "", unit: "", + orderNom, }); } } @@ -57,6 +59,7 @@ const parseOrderItems = (order) => { name, quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(), unit: String(p.product_ed || p.unit || "").trim(), + orderNom: String(src.nom || "").trim(), }); } } @@ -71,6 +74,7 @@ const parseOrderItems = (order) => { for (const sub of orderList) { if (!sub || typeof sub !== "object") continue; const items = Array.isArray(sub.items) ? sub.items : []; + const orderNom = String(sub.nom || sub.name || "").trim(); for (const p of items) { if (!p || typeof p !== "object") continue; const name = String(p.product_name || p.name || "").trim(); @@ -80,6 +84,7 @@ const parseOrderItems = (order) => { name, quantity: String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(), unit: String(p.product_ed || p.unit || "").trim(), + orderNom, }); } } @@ -89,12 +94,15 @@ const parseOrderItems = (order) => { return []; }; -export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, isSavingShipment, onResetStatus, isSavingStatusChange }) => { +export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, isSavingShipment, onResetStatus, isSavingStatusChange, groupByOrder = false }) => { const [stopWords, setStopWords] = React.useState([]); const [scopeActive, setScopeActive] = React.useState(true); const [savedShipment, setSavedShipment] = React.useState([]); const [justSaved, setJustSaved] = React.useState(false); const [showResetConfirm, setShowResetConfirm] = React.useState(false); + const [filterMode, setFilterMode] = React.useState("all"); // "all" | "stop_only" + const [collapsedGroups, setCollapsedGroups] = React.useState(null); // null = all collapsed by default + const [searchQuery, setSearchQuery] = React.useState(""); React.useEffect(() => { if (!supabase) return; @@ -108,10 +116,56 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i }, []); const allItems = React.useMemo(() => parseOrderItems(order), [order]); + + // For non-grouped mode: filter based on scopeActive (existing behavior) + // For grouped mode: items = allItems, filter is only for display const items = React.useMemo(() => { + if (groupByOrder) return allItems; if (!stopWords.length || !scopeActive) return allItems; return allItems.filter((item) => !matchesStopWord(item.name, stopWords)); - }, [allItems, stopWords, scopeActive]); + }, [allItems, stopWords, scopeActive, groupByOrder]); + + // For grouped mode: display items based on filterMode and search + const displayItems = React.useMemo(() => { + if (!groupByOrder) return items; + let result = allItems; + if (filterMode === "stop_only") { + result = result.filter((item) => !matchesStopWord(item.name, stopWords)); + } + const q = searchQuery.trim().toLowerCase(); + if (q) { + // Check if search matches any order number — if so, show all items in that order + const matchingOrderNoms = new Set(); + for (const item of result) { + if (String(item.orderNom || "").toLowerCase().includes(q)) { + matchingOrderNoms.add(String(item.orderNom || "")); + } + } + if (matchingOrderNoms.size > 0) { + result = result.filter((item) => matchingOrderNoms.has(String(item.orderNom || ""))); + } else { + // Filter by item name or bill number + result = result.filter((item) => { + const nameMatch = String(item.name || "").toLowerCase().includes(q); + const billMatch = String(item.orderNom || "").toLowerCase().includes(q); + return nameMatch || billMatch; + }); + } + } + return result; + }, [allItems, items, groupByOrder, filterMode, stopWords, searchQuery]); + + // Group display items by orderNom (for grouped mode) + const groupedItems = React.useMemo(() => { + if (!groupByOrder) return null; + const groups = {}; + for (const item of displayItems) { + const key = item.orderNom || "Без номера"; + if (!groups[key]) groups[key] = []; + groups[key].push(item); + } + return groups; + }, [displayItems, groupByOrder]); // Restore previously saved shipment data from order const initialShippedIds = React.useMemo(() => { @@ -171,7 +225,8 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i }; const shipAll = () => { - setShippedItems(new Set(items.map((i) => i.id))); + const targetItems = groupByOrder ? displayItems : items; + setShippedItems(new Set(targetItems.map((i) => i.id))); setComments({}); }; @@ -196,6 +251,15 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i } }; + const toggleGroup = (key) => { + setCollapsedGroups((prev) => { + const current = prev === null ? new Set(Object.keys(groupedItems || {})) : new Set(prev); + if (current.has(key)) current.delete(key); + else current.add(key); + return current; + }); + }; + const shippedCount = items.filter((i) => shippedItems.has(i.id)).length; const unshippedCount = items.length - shippedCount; const allShipped = items.length > 0 && shippedCount === items.length; @@ -226,24 +290,65 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i } }, [items.length, shippedCount, unshippedCount, unshippedWithoutComment, allShipped, shippedItems, comments, onShipmentChange]); + const renderItem = (item) => { + const isShipped = shippedItems.has(item.id); + const hasComment = !isShipped && comments[item.id]?.trim(); + return ( +
+ +
+ ); + }; + if (items.length === 0) { return ( - - Состав заказа +

Позиции не указаны

- +
); } return ( - +
-
- Отгрузка -

- Отметьте позиции, которые отгружены. Для смены статуса на «Доставлено» все позиции должны быть отгружены. -

-
{shippedCount}/{items.length} отгружено @@ -251,6 +356,42 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
+ {groupByOrder && ( +
+
+ + +
+ setSearchQuery(e.target.value)} + placeholder="Поиск по счёту или позиции..." + className="w-full rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-2 text-sm !text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none sm:flex-1" + /> +
+ )} +
)} -
- {items.map((item) => { - const isShipped = shippedItems.has(item.id); - const hasComment = !isShipped && comments[item.id]?.trim(); - return ( -
-
+ ) : ( + /* === Flat list (driver mode — existing behavior) === */ +
+ {items.map((item) => renderItem(item))} +
+ )} {unshippedCount > 0 && (
@@ -435,6 +586,6 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
)} - +
); -}; +}; \ No newline at end of file diff --git a/src/components/logistics/LogisticsReadinessBoard.jsx b/src/components/logistics/LogisticsReadinessBoard.jsx index 589028f..566a05f 100644 --- a/src/components/logistics/LogisticsReadinessBoard.jsx +++ b/src/components/logistics/LogisticsReadinessBoard.jsx @@ -23,6 +23,7 @@ import { import { Badge } from "../UI/Badge"; 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"; @@ -53,6 +54,9 @@ const DEFAULT_FUNNEL_ORDER = [ const STORAGE_KEY = "logistics-section-order"; const COLLAPSED_KEY = "logistics-section-collapsed"; +const PAGE_KEY = "logistics-board-page"; +const SORT_KEY = "logistics-board-created-sort"; +const PAGE_SIZE = 20; // Load custom order from localStorage, merge with defaults const loadCustomOrder = () => { @@ -94,11 +98,11 @@ const saveCollapsedSections = (collapsedSet) => { } }; -// 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]"; +// 8 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)_minmax(120px,1fr)]"; +const MIN_W = "min-w-[1160px]"; -const TableHeader = () => ( +const TableHeader = ({ createdSort, onToggleCreatedSort }) => (
Клиент
Город
@@ -107,6 +111,16 @@ const TableHeader = () => (
Водитель
Статус
Обновлён
+
); @@ -120,72 +134,72 @@ const isStale = (group) => { return diff > 24 * 60 * 60 * 1000; }; -const isLinkOpened = (group) => !!(group.invitationOpenedAt || (group.invitationAccessCount && group.invitationAccessCount > 0)); +const getDatabaseAge = (group) => { + if (!group.createdAt) return { days: null, rowClass: "", textClass: "text-[var(--color-text-muted)]" }; + const created = new Date(group.createdAt).getTime(); + if (!Number.isFinite(created)) return { days: null, rowClass: "", textClass: "text-[var(--color-text-muted)]" }; + const days = Math.max(0, Math.floor((Date.now() - created) / 86400000)); + if (days >= 14) return { days, rowClass: "bg-[rgba(239,68,68,0.14)]", textClass: "font-bold text-[var(--color-danger)]" }; + if (days >= 7) return { days, rowClass: "bg-[rgba(239,68,68,0.09)]", textClass: "font-semibold text-[var(--color-danger)]" }; + if (days >= 3) return { days, rowClass: "bg-[rgba(245,158,11,0.08)]", textClass: "font-semibold text-[var(--color-warning)]" }; + return { days, rowClass: "", textClass: "text-[var(--color-text-muted)]" }; +}; -const renderRow = (group, onSelectSet) => ( -
-
- {formatDateTime(group.updatedAt)} -
- -); +
{g.city || "—"}
+
+ + {g.deliveryType === "pickup" ? "🏪 Самовывоз" : "🚚 Доставка"} + +
+
+ {g.deliveryDate ? fmtDate(g.deliveryDate) : "—"} + {g.deliveryTime ? {g.deliveryTime} : null} +
+
{g.assignedDriverName || "—"}
+
+ {getOrderGroupDisplayStatusLabel(g)} +
+
{formatDateTime(g.updatedAt)}
+
+
{formatDateTime(g.createdAt)}
+ {databaseAge.days !== null ?
{databaseAge.days === 0 ? "сегодня" : `${databaseAge.days} дн. в базе`}
: null} +
+ + ); +}; -// Sortable section wrapper -const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, onSelectSet }) => { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: statusValue }); +const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, onSelectSet, createdSort, onToggleCreatedSort }) => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: statusValue }); const style = { transform: CSS.Transform.toString(transform), @@ -194,58 +208,35 @@ const SortableSection = ({ statusValue, label, groups, isCollapsed, onToggle, on }; return ( -
- {/* Section header — drag handle + collapse toggle */} -
- {/* 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 ( +
+
+
+ + setCallDate(e.target.value)} + className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm !text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none" /> +
+
+ + setCallTime(e.target.value)} + className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm !text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none" /> +
+
+
+ +