feat(logistics): improve order workflow and aging
This commit is contained in:
parent
d11ccd4f76
commit
60d6033085
|
|
@ -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
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
|||
<Badge tone={getOrderGroupStatusTone(group)}>
|
||||
{getOrderGroupDisplayStatusLabel(group)}
|
||||
</Badge>
|
||||
{noSms && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-[rgba(245,158,11,0.15)] px-2 py-0.5 text-[10px] font-medium text-[#d97706]" title="SMS не отправлено">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[#f59e0b]"></span>
|
||||
SMS не отправлено
|
||||
</span>
|
||||
)}
|
||||
{days != null && (
|
||||
<span className={`inline-flex items-center rounded-full bg-[var(--color-surface-strong)] px-2 py-0.5 text-[10px] font-medium ${getDaysColorClass(days)}`}>
|
||||
{days} дн
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{group.hasDeliveryProblem && (
|
||||
|
|
@ -178,23 +217,32 @@ export const OrdersTable = ({
|
|||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[1080px]">
|
||||
<div className="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 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-xs uppercase tracking-[0.12em] text-[var(--color-text-muted)]">
|
||||
<div className="min-w-[1180px]">
|
||||
<div className="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 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 className="px-3 py-1.5 font-medium">Водитель</div>
|
||||
</div>
|
||||
{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 || "—"}
|
||||
</div>
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Badge tone={getOrderGroupStatusTone(group)}>
|
||||
{getOrderGroupDisplayStatusLabel(group)}
|
||||
</Badge>
|
||||
{noSms && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-[rgba(245,158,11,0.15)] px-1.5 py-0.5 text-[10px] font-medium text-[#d97706]" title="SMS не отправлено">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[#f59e0b]"></span>
|
||||
SMS
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-1.5 text-xs">
|
||||
{group.deliveryDate ? (
|
||||
|
|
@ -236,6 +292,9 @@ export const OrdersTable = ({
|
|||
<span className="text-[var(--color-text-muted)]">—</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`px-3 py-1.5 text-xs font-medium ${getDaysColorClass(days)}`}>
|
||||
{days != null ? `${days} дн` : "—"}
|
||||
</div>
|
||||
<div className="px-3 py-1.5 text-xs">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{group.deliveryType === "pickup" ? "🏪" : "🚚"}
|
||||
|
|
|
|||
|
|
@ -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,67 +290,7 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
|
|||
}
|
||||
}, [items.length, shippedCount, unshippedCount, unshippedWithoutComment, allShipped, shippedItems, comments, onShipmentChange]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Panel className="space-y-3 p-5 fs-zone-card">
|
||||
<strong>Состав заказа</strong>
|
||||
<p className="text-sm text-[var(--color-text-muted)]">Позиции не указаны</p>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel className="space-y-4 p-5 fs-zone-card">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<strong>Отгрузка</strong>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
Отметьте позиции, которые отгружены. Для смены статуса на «Доставлено» все позиции должны быть отгружены.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Badge tone={allShipped ? "accent" : "neutral"}>
|
||||
{shippedCount}/{items.length} отгружено
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={shipAll} disabled={allShipped}>
|
||||
Отгрузить всё
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={unshipAll} disabled={shippedCount === 0 && !isStatusFinal}>
|
||||
Сбросить отгрузку
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showResetConfirm && (
|
||||
<div className="rounded-xl border border-[var(--color-warning)] bg-[var(--color-warning-soft)] p-4 space-y-3">
|
||||
<p className="text-sm font-medium text-[var(--color-text)]">
|
||||
Сбросить отгрузку и вернуть статус?
|
||||
</p>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
Текущий статус («{currentDeliveryStatus === "delivered" ? "Доставлено" : currentDeliveryStatus === "problem" ? "Проблема" : "Вывезено"}») будет сброшен.
|
||||
Отгрузка будет очищена, логист увидит что доставка требует доработки.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={confirmResetAll}
|
||||
disabled={isSavingStatusChange}
|
||||
>
|
||||
{isSavingStatusChange ? "Сохраняем..." : "Да, сбросить"}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowResetConfirm(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => {
|
||||
const renderItem = (item) => {
|
||||
const isShipped = shippedItems.has(item.id);
|
||||
const hasComment = !isShipped && comments[item.id]?.trim();
|
||||
return (
|
||||
|
|
@ -332,8 +336,155 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
|
|||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
};
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-[var(--color-text-muted)]">Позиции не указаны</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Badge tone={allShipped ? "accent" : "neutral"}>
|
||||
{shippedCount}/{items.length} отгружено
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{groupByOrder && (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="flex gap-2 sm:flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className={["rounded-xl px-3 py-2 text-sm font-semibold transition",
|
||||
filterMode === "all"
|
||||
? "bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
|
||||
: "text-[var(--color-text-muted)] hover:bg-[var(--color-accent-soft)] border border-[var(--color-border)]"
|
||||
].join(" ")}
|
||||
onClick={() => setFilterMode("all")}
|
||||
>
|
||||
Все
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={["rounded-xl px-3 py-2 text-sm font-semibold transition",
|
||||
filterMode === "stop_only"
|
||||
? "bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
|
||||
: "text-[var(--color-text-muted)] hover:bg-[var(--color-accent-soft)] border border-[var(--color-border)]"
|
||||
].join(" ")}
|
||||
onClick={() => setFilterMode("stop_only")}
|
||||
>
|
||||
Со стоп-словами
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={shipAll} disabled={allShipped}>
|
||||
Отгрузить всё
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={unshipAll} disabled={shippedCount === 0 && !isStatusFinal}>
|
||||
Сбросить отгрузку
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showResetConfirm && (
|
||||
<div className="rounded-xl border border-[var(--color-warning)] bg-[var(--color-warning-soft)] p-4 space-y-3">
|
||||
<p className="text-sm font-medium text-[var(--color-text)]">
|
||||
Сбросить отгрузку и вернуть статус?
|
||||
</p>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
Текущий статус («{currentDeliveryStatus === "delivered" ? "Доставлено" : currentDeliveryStatus === "problem" ? "Проблема" : "Вывезено"}») будет сброшен.
|
||||
Отгрузка будет очищена, логист увидит что доставка требует доработки.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={confirmResetAll}
|
||||
disabled={isSavingStatusChange}
|
||||
>
|
||||
{isSavingStatusChange ? "Сохраняем..." : "Да, сбросить"}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowResetConfirm(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupByOrder ? (
|
||||
/* === Grouped by order (счёту) === */
|
||||
<div className="space-y-3">
|
||||
{displayItems.length === 0 ? (
|
||||
<p className="text-sm text-[var(--color-text-muted)] italic">
|
||||
{filterMode === "stop_only" ? "Нет позиций со стоп-словами" : searchQuery.trim() ? "Ничего не найдено" : "Нет позиций для отображения"}
|
||||
</p>
|
||||
) : (
|
||||
Object.entries(groupedItems).map(([orderNom, groupItems]) => {
|
||||
const isCollapsed = collapsedGroups === null ? true : collapsedGroups.has(orderNom);
|
||||
const groupShipped = groupItems.filter((i) => shippedItems.has(i.id)).length;
|
||||
return (
|
||||
<div
|
||||
key={orderNom}
|
||||
className="rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-2 px-4 py-3 text-left transition hover:bg-[var(--color-accent-soft)]"
|
||||
onClick={() => toggleGroup(orderNom)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full bg-[var(--color-accent-soft)] px-3 py-1 text-sm font-semibold text-[var(--color-accent)]">
|
||||
Счёт {orderNom}
|
||||
</span>
|
||||
<span className="text-sm text-[var(--color-text-muted)]">
|
||||
· {groupItems.length} поз.
|
||||
</span>
|
||||
{groupShipped === groupItems.length && groupItems.length > 0 && (
|
||||
<span className="text-xs text-[var(--color-accent)]">✓</span>
|
||||
)}
|
||||
</div>
|
||||
<svg
|
||||
className="h-4 w-4 flex-shrink-0 text-[var(--color-text-muted)] transition-transform"
|
||||
style={{ transform: isCollapsed ? "rotate(0deg)" : "rotate(180deg)" }}
|
||||
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="space-y-2 px-4 pb-4">
|
||||
{groupItems.map((item) => renderItem(item))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* === Flat list (driver mode — existing behavior) === */
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => renderItem(item))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{unshippedCount > 0 && (
|
||||
<div className="rounded-xl border border-[var(--color-warning)] bg-[var(--color-warning-soft)] p-3 text-sm">
|
||||
|
|
@ -435,6 +586,6 @@ export const DriverShipmentPanel = ({ order, onShipmentChange, onSaveShipment, i
|
|||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 }) => (
|
||||
<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>
|
||||
|
|
@ -107,6 +111,16 @@ const TableHeader = () => (
|
|||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCreatedSort}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-left font-medium transition hover:text-[var(--color-text)]"
|
||||
title={createdSort === "asc" ? "Сейчас старые сверху. Нажмите: свежие сверху" : "Сейчас свежие сверху. Нажмите: старые сверху"}
|
||||
aria-label={createdSort === "asc" ? "Добавлен в базу: старые сверху" : "Добавлен в базу: свежие сверху"}
|
||||
>
|
||||
<span>Добавлен в базу</span>
|
||||
<span aria-hidden="true">{createdSort === "asc" ? "↑" : "↓"}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
@ -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) => (
|
||||
const renderRow = (g, onSelectSet) => {
|
||||
const statusTone = getOrderGroupStatusTone(g);
|
||||
const stale = isStale(g);
|
||||
const databaseAge = getDatabaseAge(g);
|
||||
return (
|
||||
<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)] ${isStale(group) ? "bg-[rgba(191,123,33,0.06)]" : ""}`}
|
||||
onClick={() => { if (onSelectSet) onSelectSet(group.id); }}
|
||||
key={g.id}
|
||||
onClick={() => onSelectSet(g.id)}
|
||||
className={`grid ${COLS} ${MIN_W} w-full border-t border-[var(--color-border)] text-left transition hover:bg-[var(--color-accent-soft)] ${
|
||||
databaseAge.rowClass || (stale ? "bg-[rgba(245,158,11,0.06)]" : "")
|
||||
}`}
|
||||
>
|
||||
<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)] flex items-center gap-1">
|
||||
{group.customerPhone || ""}
|
||||
{isLinkOpened(group) && <span title="Клиент открывал ссылку" style={{ color: "#22c55e", fontSize: "11px" }}>👁</span>}
|
||||
</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>
|
||||
<div className="min-w-0 px-3 py-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium text-[var(--color-text)] truncate">
|
||||
{g.customerName || g.groupKey || "—"}
|
||||
</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>
|
||||
{stale && (
|
||||
<span title="Обновлён >24ч назад" className="inline-block h-2 w-2 flex-shrink-0 rounded-full bg-[var(--color-warning)]"></span>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-3 py-1.5 text-xs">
|
||||
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]">—</span>}
|
||||
<div className="mt-0.5 text-xs text-[var(--color-text-muted)] truncate">
|
||||
{(g.allBillNumbers || g.orderNumbers || []).join(", ") || "—"}
|
||||
</div>
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
|
||||
{(group.hasDeliveryProblem || group.has_delivery_problem) && (
|
||||
<span
|
||||
title={group.deliveryProblemNote || group.delivery_problem_note || "Есть проблемы с отгрузкой позиций"}
|
||||
className="inline-flex w-fit items-center gap-0.5 rounded-full bg-[rgba(239,68,68,0.12)] px-1.5 py-0.5 text-[10px] font-bold text-[var(--color-danger)]"
|
||||
>
|
||||
⚠ Проблема
|
||||
</div>
|
||||
<div className="px-3 py-2.5 text-sm text-[var(--color-text-muted)]">{g.city || "—"}</div>
|
||||
<div className="px-3 py-2.5 text-xs">
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-medium ${
|
||||
g.deliveryType === "pickup"
|
||||
? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
|
||||
: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]"
|
||||
}`}>
|
||||
{g.deliveryType === "pickup" ? "🏪 Самовывоз" : "🚚 Доставка"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-3 py-2.5 text-sm text-[var(--color-text-muted)]">
|
||||
{g.deliveryDate ? fmtDate(g.deliveryDate) : "—"}
|
||||
{g.deliveryTime ? <span className="ml-1 text-xs text-[var(--color-text-muted)]">{g.deliveryTime}</span> : null}
|
||||
</div>
|
||||
<div className="px-3 py-1.5 text-xs text-[var(--color-text-muted)]">
|
||||
{formatDateTime(group.updatedAt)}
|
||||
<div className="px-3 py-2.5 text-sm text-[var(--color-text-muted)]">{g.assignedDriverName || "—"}</div>
|
||||
<div className="px-3 py-2.5">
|
||||
<Badge tone={statusTone}>{getOrderGroupDisplayStatusLabel(g)}</Badge>
|
||||
</div>
|
||||
<div className="px-3 py-2.5 text-xs text-[var(--color-text-muted)]">{formatDateTime(g.updatedAt)}</div>
|
||||
<div className={`px-3 py-2.5 text-xs ${databaseAge.textClass}`}>
|
||||
<div>{formatDateTime(g.createdAt)}</div>
|
||||
{databaseAge.days !== null ? <div className="mt-0.5">{databaseAge.days === 0 ? "сегодня" : `${databaseAge.days} дн. в базе`}</div> : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<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 */}
|
||||
<div ref={setNodeRef} style={style} className="rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">
|
||||
<div className="flex items-center gap-2 border-b border-[var(--color-border)] px-4 py-3">
|
||||
<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}
|
||||
className="cursor-grab touch-none text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
aria-label="Перетащить секцию"
|
||||
>
|
||||
<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 className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M7 4a1 1 0 11-2 0 1 1 0 012 0zM7 10a1 1 0 11-2 0 1 1 0 012 0zM7 16a1 1 0 11-2 0 1 1 0 012 0zM15 4a1 1 0 11-2 0 1 1 0 012 0zM15 10a1 1 0 11-2 0 1 1 0 012 0zM15 16a1 1 0 11-2 0 1 1 0 012 0z" />
|
||||
</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}
|
||||
className="flex flex-1 items-center justify-between text-left"
|
||||
>
|
||||
<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>
|
||||
<span className="text-sm font-semibold text-[var(--color-text)]">{label}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Badge tone="neutral">{groups.length}</Badge>
|
||||
<span className="text-xs text-[var(--color-text-muted)]">{isCollapsed ? "▶" : "▼"}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="overflow-x-auto">
|
||||
<div className={MIN_W}>
|
||||
<TableHeader />
|
||||
<TableHeader createdSort={createdSort} onToggleCreatedSort={onToggleCreatedSort} />
|
||||
{groups.map((g) => renderRow(g, onSelectSet))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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 && (
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
Показано {Math.min(currentPage * PAGE_SIZE, totalGroups)} из {totalGroups} · по {PAGE_SIZE} на странице
|
||||
</p>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{!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
|
|||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{!hasActiveFilters && totalGroups > PAGE_SIZE && (
|
||||
<Pagination
|
||||
page={currentPage}
|
||||
totalPages={totalPages}
|
||||
onChange={setPage}
|
||||
itemsPerPage={PAGE_SIZE}
|
||||
totalItems={totalGroups}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 (
|
||||
<div className="space-y-3 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--color-text-muted)] mb-1">Дата звонка</label>
|
||||
<input type="date" value={callDate} onChange={(e) => 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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--color-text-muted)] mb-1">Время звонка</label>
|
||||
<input type="time" value={callTime} onChange={(e) => 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" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--color-text-muted)] mb-1">Комментарий</label>
|
||||
<textarea value={comment} onChange={(e) => setComment(e.target.value)} placeholder="Результат звонка..." rows={2}
|
||||
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm !text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none resize-none" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="primary" size="sm" onClick={() => save(true)} disabled={busy}>
|
||||
{busy ? "Сохраняем..." : "Сохранить"}
|
||||
</Button>
|
||||
{isCalled && (
|
||||
<Button variant="ghost" size="sm" onClick={() => save(false)} disabled={busy}>Сбросить</Button>
|
||||
)}
|
||||
<Button variant="secondary" size="sm" onClick={() => setEditing(false)} disabled={busy}>Отмена</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default mode: buttons like status delivery
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{isCalled ? (
|
||||
<>
|
||||
<Button variant="primary" size="sm" onClick={() => setEditing(true)} disabled={busy}>
|
||||
📞 Звонили
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => save(false)} disabled={busy}>
|
||||
Не звонили
|
||||
</Button>
|
||||
{order?.called_at && (
|
||||
<span className="text-xs text-[var(--color-text-muted)]">{formatCalledAt(order.called_at)}</span>
|
||||
)}
|
||||
{order?.called_comment && (
|
||||
<span className="text-xs text-[var(--color-text-muted)] italic">«{order.called_comment}»</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={() => setEditing(true)} disabled={busy}>
|
||||
📞 Звонили
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" disabled>
|
||||
Не звонили
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { CalledPanel };
|
||||
export default CalledPanel;
|
||||
|
|
@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
disabled={busy}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-[rgba(18,128,92,0.28)] bg-[var(--color-accent-soft)] px-4 py-2 text-sm font-semibold text-[var(--color-accent)] transition hover:opacity-80 active:opacity-60 disabled:opacity-50"
|
||||
title="Отметить как «не звонили»"
|
||||
>
|
||||
📞 Звонили
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
disabled={busy}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-[rgba(245,158,11,0.35)] bg-[rgba(245,158,11,0.12)] px-4 py-2 text-sm font-semibold text-[rgb(180,120,20)] transition hover:opacity-80 active:opacity-60 disabled:opacity-50"
|
||||
title="Отметить как «звонили»"
|
||||
>
|
||||
📞 Не звонили
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default CalledToggle;
|
||||
|
|
@ -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 (
|
||||
<Panel className="space-y-3 p-4">
|
||||
<div className="flex w-full items-center justify-between gap-2 pb-2 border-b border-[var(--color-border)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" {...dragAttributes} {...dragListeners} className="cursor-grab touch-none text-[var(--color-text-muted)] hover:text-[var(--color-text)]">
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20"><path d="M7 4a1 1 0 11-2 0 1 1 0 012 0zM7 10a1 1 0 11-2 0 1 1 0 012 0zM7 16a1 1 0 11-2 0 1 1 0 012 0zM15 4a1 1 0 11-2 0 1 1 0 012 0zM15 10a1 1 0 11-2 0 1 1 0 012 0zM15 16a1 1 0 11-2 0 1 1 0 012 0z" /></svg>
|
||||
</button>
|
||||
<button type="button" onClick={toggle} className="text-sm font-bold text-[var(--color-text)]">{title}</button>
|
||||
</div>
|
||||
<button type="button" onClick={toggle} className="text-xs text-[var(--color-text-muted)]">{collapsed ? "▼" : "▲"}</button>
|
||||
</div>
|
||||
{!collapsed && <div className="space-y-4 pt-2">{children}</div>}
|
||||
</Panel>
|
||||
);
|
||||
};
|
||||
|
||||
// ---- 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 (
|
||||
<div ref={setNodeRef} style={style}>
|
||||
{typeof children === "function" ? children({ dragAttributes: attributes, dragListeners: listeners }) : children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<Panel className="space-y-4 p-5">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex h-2 w-2 rounded-full bg-[var(--color-warning)]"></span>
|
||||
<strong>Платное хранение</strong>
|
||||
<span className="font-semibold text-[var(--color-text)]">Заказ на платном хранении</span>
|
||||
</div>
|
||||
{order.paidStorageAt && (
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
|
|
@ -479,15 +562,14 @@ const PaidStoragePanel = ({ order, onChangeDeliveryStatus, isSavingStatusChange,
|
|||
>
|
||||
Отменить платное хранение
|
||||
</Button>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel className="space-y-4 p-5">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<strong>Платное хранение</strong>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
Переведите заказ в статус платного хранения, если клиент не забрал товар в срок.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -533,7 +615,7 @@ const PaidStoragePanel = ({ order, onChangeDeliveryStatus, isSavingStatusChange,
|
|||
Перевести в платное хранение
|
||||
</Button>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -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,16 +1049,22 @@ export const OrderDetailPanel = ({
|
|||
</div>
|
||||
</Panel>
|
||||
|
||||
{canManageDelivery ? (
|
||||
<Panel className="space-y-4 p-5">
|
||||
<div>
|
||||
<strong>Ручное согласование</strong>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
{/* ===== Collapsible + sortable blocks (logistics/admin/manager only) ===== */}
|
||||
{isLogisticsRole && order ? (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={blockOrder} strategy={verticalListSortingStrategy}>
|
||||
{blockOrder.map(blockKey => {
|
||||
/* manual_confirmation */
|
||||
if (blockKey === "manual_confirmation" && canManageDelivery) {
|
||||
return (
|
||||
<SortableBlock key={blockKey} id={blockKey}>
|
||||
{({ dragAttributes, dragListeners }) => (
|
||||
<CollapsibleBlock blockKey={blockKey} title={BLOCK_TITLES[blockKey]} dragAttributes={dragAttributes} dragListeners={dragListeners}>
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
{isDeliveryAgreed
|
||||
? "Дата и время уже зафиксированы."
|
||||
: "Если клиент согласовал доставку или самовывоз по телефону, сохраните дату и время здесь."}
|
||||
</p>
|
||||
</div>
|
||||
{/* Delivery type tabs */}
|
||||
<div className="flex gap-2 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-1">
|
||||
<button
|
||||
|
|
@ -1075,11 +1188,18 @@ export const OrderDetailPanel = ({
|
|||
{formMessage ? (
|
||||
<p className="text-sm text-[var(--color-text-muted)]">{formMessage}</p>
|
||||
) : null}
|
||||
</Panel>
|
||||
) : null}
|
||||
</CollapsibleBlock>
|
||||
)}
|
||||
</SortableBlock>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
{deliveryType === "delivery" && (
|
||||
/* driver_assignment */
|
||||
if (blockKey === "driver_assignment" && deliveryType === "delivery") {
|
||||
return (
|
||||
<SortableBlock key={blockKey} id={blockKey}>
|
||||
{({ dragAttributes, dragListeners }) => (
|
||||
<CollapsibleBlock blockKey={blockKey} title={BLOCK_TITLES[blockKey]} dragAttributes={dragAttributes} dragListeners={dragListeners}>
|
||||
<DriverAssignmentPanel
|
||||
order={order}
|
||||
userRole={userRole}
|
||||
|
|
@ -1091,44 +1211,18 @@ export const OrderDetailPanel = ({
|
|||
driverMessage={driverMessage}
|
||||
drivers={drivers}
|
||||
/>
|
||||
</CollapsibleBlock>
|
||||
)}
|
||||
|
||||
<SmsStatusCard order={order} userRole={userRole} />
|
||||
|
||||
<StatusActionPanel
|
||||
order={order}
|
||||
userRole={userRole}
|
||||
canManageDelivery={canManageDelivery}
|
||||
isSavingStatusChange={isSavingStatusChange}
|
||||
onConfirmStatus={(action) => {
|
||||
if (action.type === "hint") {
|
||||
setFormMessage(action.hint);
|
||||
} else if (action.type === "status") {
|
||||
setConfirmAction({
|
||||
type: "status",
|
||||
status: action.status,
|
||||
label: action.label,
|
||||
mismatch: action.mismatch,
|
||||
deliveryType: action.deliveryType,
|
||||
});
|
||||
</SortableBlock>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{formMessage && ["manager", "logistician", "admin", "mega_admin"].includes(userRole) && order && onChangeDeliveryStatus ? (
|
||||
<p className="text-sm text-[var(--color-warning)]">{formMessage}</p>
|
||||
) : null}
|
||||
|
||||
|
||||
{["manager", "logistician", "admin", "mega_admin"].includes(userRole) && order && onChangeDeliveryStatus ? (
|
||||
<PaidStoragePanel
|
||||
order={order}
|
||||
onChangeDeliveryStatus={onChangeDeliveryStatus}
|
||||
isSavingStatusChange={isSavingStatusChange}
|
||||
setFormMessage={setFormMessage}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{["driver", "logistician", "admin", "mega_admin"].includes(userRole) && order ? (
|
||||
/* shipment — DriverShipmentPanel */
|
||||
if (blockKey === "shipment") {
|
||||
return (
|
||||
<SortableBlock key={blockKey} id={blockKey}>
|
||||
{({ dragAttributes, dragListeners }) => (
|
||||
<CollapsibleBlock blockKey={blockKey} title={BLOCK_TITLES[blockKey]} dragAttributes={dragAttributes} dragListeners={dragListeners}>
|
||||
<DriverShipmentPanel
|
||||
order={order}
|
||||
onShipmentChange={handleShipmentChange}
|
||||
|
|
@ -1149,9 +1243,223 @@ export const OrderDetailPanel = ({
|
|||
}
|
||||
}}
|
||||
isSavingStatusChange={isSavingStatusChange}
|
||||
groupByOrder={true}
|
||||
/>
|
||||
</CollapsibleBlock>
|
||||
)}
|
||||
</SortableBlock>
|
||||
);
|
||||
}
|
||||
|
||||
/* sms_status */
|
||||
if (blockKey === "sms_status") {
|
||||
return (
|
||||
<SortableBlock key={blockKey} id={blockKey}>
|
||||
{({ dragAttributes, dragListeners }) => (
|
||||
<CollapsibleBlock blockKey={blockKey} title={BLOCK_TITLES[blockKey]} dragAttributes={dragAttributes} dragListeners={dragListeners}>
|
||||
<SmsStatusCard order={order} userRole={userRole} />
|
||||
</CollapsibleBlock>
|
||||
)}
|
||||
</SortableBlock>
|
||||
);
|
||||
}
|
||||
|
||||
/* status_actions */
|
||||
if (blockKey === "status_actions") {
|
||||
return (
|
||||
<SortableBlock key={blockKey} id={blockKey}>
|
||||
{({ dragAttributes, dragListeners }) => (
|
||||
<CollapsibleBlock blockKey={blockKey} title={BLOCK_TITLES[blockKey]} dragAttributes={dragAttributes} dragListeners={dragListeners}>
|
||||
<StatusActionPanel
|
||||
order={order}
|
||||
userRole={userRole}
|
||||
canManageDelivery={canManageDelivery}
|
||||
isSavingStatusChange={isSavingStatusChange}
|
||||
onRefreshOrder={() => {}}
|
||||
onConfirmStatus={(action) => {
|
||||
if (action.type === "hint") {
|
||||
setFormMessage(action.hint);
|
||||
} else if (action.type === "status") {
|
||||
setConfirmAction({
|
||||
type: "status",
|
||||
status: action.status,
|
||||
label: action.label,
|
||||
mismatch: action.mismatch,
|
||||
deliveryType: action.deliveryType,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{formMessage && onChangeDeliveryStatus ? (
|
||||
<p className="text-sm text-[var(--color-warning)]">{formMessage}</p>
|
||||
) : null}
|
||||
</CollapsibleBlock>
|
||||
)}
|
||||
</SortableBlock>
|
||||
);
|
||||
}
|
||||
|
||||
/* paid_storage */
|
||||
if (blockKey === "paid_storage" && onChangeDeliveryStatus) {
|
||||
return (
|
||||
<SortableBlock key={blockKey} id={blockKey}>
|
||||
{({ dragAttributes, dragListeners }) => (
|
||||
<CollapsibleBlock blockKey={blockKey} title={BLOCK_TITLES[blockKey]} dragAttributes={dragAttributes} dragListeners={dragListeners}>
|
||||
<PaidStoragePanel
|
||||
order={order}
|
||||
onChangeDeliveryStatus={onChangeDeliveryStatus}
|
||||
isSavingStatusChange={isSavingStatusChange}
|
||||
setFormMessage={setFormMessage}
|
||||
/>
|
||||
</CollapsibleBlock>
|
||||
)}
|
||||
</SortableBlock>
|
||||
);
|
||||
}
|
||||
|
||||
/* delivery_link */
|
||||
if (blockKey === "delivery_link" && order?.deliveryLink) {
|
||||
return (
|
||||
<SortableBlock key={blockKey} id={blockKey}>
|
||||
{({ dragAttributes, dragListeners }) => (
|
||||
<CollapsibleBlock blockKey={blockKey} title={BLOCK_TITLES[blockKey]} dragAttributes={dragAttributes} dragListeners={dragListeners}>
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
Отправьте эту ссылку клиенту, чтобы он мог согласовать доставку или самовывоз самостоятельно.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<a
|
||||
href={order.deliveryLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-2xl bg-[var(--color-accent)] px-4 py-2.5 text-sm font-semibold text-white transition hover:opacity-90"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
Открыть страницу согласования
|
||||
</a>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(order.deliveryLink).then(() => {
|
||||
setFormMessage("Ссылка скопирована в буфер обмена");
|
||||
setTimeout(() => setFormMessage(""), 3000);
|
||||
}).catch(() => {
|
||||
setFormMessage("Не удалось скопировать ссылку");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
|
||||
</svg>
|
||||
Скопировать ссылку
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{order.invitationAccessCount > 0 ? (
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
👁 Клиент открывал страницу {order.invitationAccessCount} раз{(order.invitationLastAccessedAt || order.invitationOpenedAt) ? `, последний раз ${new Date(order.invitationLastAccessedAt || order.invitationOpenedAt).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "2-digit" })}` : ""}.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
⏳ Клиент ещё не открывал страницу согласования.
|
||||
</p>
|
||||
)}
|
||||
</CollapsibleBlock>
|
||||
)}
|
||||
</SortableBlock>
|
||||
);
|
||||
}
|
||||
|
||||
/* order_history */
|
||||
if (blockKey === "order_history") {
|
||||
return (
|
||||
<SortableBlock key={blockKey} id={blockKey}>
|
||||
{({ dragAttributes, dragListeners }) => (
|
||||
<CollapsibleBlock blockKey={blockKey} title={BLOCK_TITLES[blockKey]} dragAttributes={dragAttributes} dragListeners={dragListeners}>
|
||||
<OrderHistoryTimeline order={order} userRole={userRole} />
|
||||
</CollapsibleBlock>
|
||||
)}
|
||||
</SortableBlock>
|
||||
);
|
||||
}
|
||||
|
||||
/* extra_data */
|
||||
if (blockKey === "extra_data") {
|
||||
return (
|
||||
<SortableBlock key={blockKey} id={blockKey}>
|
||||
{({ dragAttributes, dragListeners }) => (
|
||||
<CollapsibleBlock blockKey={blockKey} title={BLOCK_TITLES[blockKey]} dragAttributes={dragAttributes} dragListeners={dragListeners} defaultCollapsed={true}>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{order.managerName ? (
|
||||
<div>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">Менеджер</p>
|
||||
<p className="mt-1 font-medium !text-[var(--color-text)]">{order.managerName}</p>
|
||||
{order.managerTel ? (
|
||||
<a href={`tel:${order.managerTel}`} className="text-sm text-[var(--color-accent)] hover:underline">{order.managerTel}</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">Оплата доставки</p>
|
||||
<p className="mt-1 font-medium !text-[var(--color-text)]">
|
||||
{order.isPayedShip ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="rounded-full bg-[var(--color-accent-soft)] px-2 py-0.5 text-xs font-semibold text-[var(--color-accent)]">✓ Оплачено</span>
|
||||
{order.payedShip ? <span className="text-sm">{Number(order.payedShip).toLocaleString("ru-RU")} ₽</span> : null}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-[var(--color-text-muted)]">Не оплачено</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{order.firstSmsSentAt ? (
|
||||
<div>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">1-е SMS отправлено</p>
|
||||
<p className="mt-1 font-medium !text-[var(--color-text)]">{formatDateTime(order.firstSmsSentAt)}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{order.secondSmsSentAt ? (
|
||||
<div>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">2-е SMS отправлено</p>
|
||||
<p className="mt-1 font-medium !text-[var(--color-text)]">{formatDateTime(order.secondSmsSentAt)}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{!order.firstSmsSentAt && !order.secondSmsSentAt ? (
|
||||
<div>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">SMS отправлено</p>
|
||||
<p className="mt-1 font-medium !text-[var(--color-text)]">Нет</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">Ручное согласование выполнено</p>
|
||||
<p className="mt-1 font-medium !text-[var(--color-text)]">{order.manualConfirmationAt ? formatDateTime(order.manualConfirmationAt) : "Нет"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">Платное хранение</p>
|
||||
<p className="mt-1 font-medium !text-[var(--color-text)]">{order.paidStorageAt ? formatDateTime(order.paidStorageAt) : "Нет"}</p>
|
||||
</div>
|
||||
{order.createdFromExchangeAt ? (
|
||||
<div>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">Создано из обмена</p>
|
||||
<p className="mt-1 font-medium !text-[var(--color-text)]">{formatDateTime(order.createdFromExchangeAt)}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CollapsibleBlock>
|
||||
)}
|
||||
</SortableBlock>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : null}
|
||||
|
||||
{/* ===== Driver-only blocks (not sortable/collapsible) ===== */}
|
||||
{userRole === "driver" && order && onChangeDeliveryStatus ? (
|
||||
<Panel className="space-y-4 p-5">
|
||||
<div>
|
||||
|
|
@ -1176,7 +1484,6 @@ export const OrderDetailPanel = ({
|
|||
|
||||
let statusOptions = [];
|
||||
if (currentStatus === "delivered" || currentStatus === "picked_up" || currentStatus === "problem") {
|
||||
// Final statuses — show "Return to work" instead
|
||||
statusOptions = [];
|
||||
} else if (currentStatus === "cancelled" || currentStatus === "paid_storage") {
|
||||
statusOptions = [];
|
||||
|
|
@ -1203,7 +1510,6 @@ export const OrderDetailPanel = ({
|
|||
return statusOptions.map((statusOption) => {
|
||||
const isSelected = pendingStatus?.value === statusOption.value;
|
||||
const isMismatch = statusOption.mismatch;
|
||||
const blockedBySchedule = statusOption.requiresSchedule && !hasDeliverySchedule;
|
||||
return (
|
||||
<Button
|
||||
key={statusOption.value}
|
||||
|
|
@ -1303,6 +1609,58 @@ export const OrderDetailPanel = ({
|
|||
</Panel>
|
||||
) : null}
|
||||
|
||||
{/* ===== Non-logistics blocks (client views) ===== */}
|
||||
{!isLogisticsRole ? (
|
||||
<>
|
||||
{/* Driver shipment panel for driver role */}
|
||||
{userRole === "driver" && order ? (
|
||||
<DriverShipmentPanel
|
||||
order={order}
|
||||
onShipmentChange={handleShipmentChange}
|
||||
onSaveShipment={handleSaveShipment}
|
||||
isSavingShipment={isSavingShipment}
|
||||
onResetStatus={() => {
|
||||
if (onChangeDeliveryStatus) {
|
||||
onChangeDeliveryStatus({
|
||||
orderGroupId: order.id,
|
||||
status: "driver_assigned",
|
||||
}).then((response) => {
|
||||
if (!response.success) {
|
||||
setFormMessage(response.error || "Не удалось сбросить статус");
|
||||
} else {
|
||||
setFormMessage("Статус сброшен, отгрузка очищена");
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
isSavingStatusChange={isSavingStatusChange}
|
||||
groupByOrder={false}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<SmsStatusCard order={order} userRole={userRole} />
|
||||
|
||||
<StatusActionPanel
|
||||
order={order}
|
||||
userRole={userRole}
|
||||
canManageDelivery={canManageDelivery}
|
||||
isSavingStatusChange={isSavingStatusChange}
|
||||
onConfirmStatus={(action) => {
|
||||
if (action.type === "hint") {
|
||||
setFormMessage(action.hint);
|
||||
} else if (action.type === "status") {
|
||||
setConfirmAction({
|
||||
type: "status",
|
||||
status: action.status,
|
||||
label: action.label,
|
||||
mismatch: action.mismatch,
|
||||
deliveryType: action.deliveryType,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Счета + Состав заказа — separate panels for client/non-logistics views only. */}
|
||||
<Panel className="space-y-4 p-5">
|
||||
<strong>Счета</strong>
|
||||
{(() => {
|
||||
|
|
@ -1393,10 +1751,6 @@ export const OrderDetailPanel = ({
|
|||
</Panel>
|
||||
) : null}
|
||||
|
||||
{userRole !== "driver" && (order?.driver_shipment_data || order?.driverShipmentData) ? (
|
||||
<DriverShipmentReport shipmentData={order.driver_shipment_data || order.driverShipmentData} />
|
||||
) : null}
|
||||
{userRole !== "driver" ? (
|
||||
<Panel className="space-y-4 p-5">
|
||||
<strong>Дополнительные данные</strong>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
|
|
@ -1456,6 +1810,7 @@ export const OrderDetailPanel = ({
|
|||
) : null}
|
||||
</div>
|
||||
</Panel>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<ConfirmModal
|
||||
|
|
|
|||
|
|
@ -0,0 +1,260 @@
|
|||
import React, { useEffect, useState, useMemo } from "react";
|
||||
import { supabase } from "../../supabaseClient";
|
||||
|
||||
// Russian labels for status values
|
||||
const STATUS_LABELS = {
|
||||
pending_confirmation: "Ожидает подтверждения",
|
||||
first_sms_sent: "1-е SMS отправлено",
|
||||
second_sms_sent: "2-е SMS отправлено",
|
||||
second_sms_sending: "Отправка 2-го SMS",
|
||||
first_sms_sending: "Отправка 1-го SMS",
|
||||
sms_sending: "Отправка SMS",
|
||||
manual_required: "Требуется ручное согласование",
|
||||
manual_confirmation_required: "Требуется ручное согласование",
|
||||
ready_to_launch: "Готов к отправке",
|
||||
ready_for_notification: "Готов к отправке",
|
||||
link_ready: "Ссылка готова",
|
||||
not_started: "Не начато",
|
||||
confirmed: "Подтверждено",
|
||||
agreed: "Согласовано",
|
||||
driver_assigned: "Водитель назначен",
|
||||
loaded: "Загружено",
|
||||
on_route: "В пути",
|
||||
delivered: "Доставлено",
|
||||
picked_up: "Вывезено",
|
||||
problem: "Проблема",
|
||||
cancelled: "Отменено",
|
||||
paid_storage: "Платное хранение",
|
||||
pickup: "Самовывоз",
|
||||
null: "—",
|
||||
};
|
||||
|
||||
// Fields to show in changes, with Russian labels
|
||||
const FIELD_LABELS = {
|
||||
delivery_status: "Статус доставки",
|
||||
notification_status: "Статус уведомления",
|
||||
first_sms_sent_at: "1-е SMS отправлено",
|
||||
second_sms_sent_at: "2-е SMS отправлено",
|
||||
manual_confirmation_at: "Ручное согласование",
|
||||
paid_storage_at: "Платное хранение",
|
||||
has_delivery_problem: "Проблема доставки",
|
||||
delivery_problem_note: "Описание проблемы",
|
||||
assigned_driver_id: "Водитель",
|
||||
};
|
||||
|
||||
// Fields to SKIP (redundant or noisy)
|
||||
const SKIP_FIELDS = new Set([
|
||||
"status", // duplicates delivery_status or notification_status
|
||||
"group_key", // internal
|
||||
"delivery_link", // long URL, not useful in history
|
||||
"delivery_link_code", // internal
|
||||
"delivery_invitation_id", // internal ID
|
||||
"next_notification_check_at", // internal scheduling
|
||||
"sms_attempts", // counter, noisy
|
||||
"last_sms_error", // shown in SMS log instead
|
||||
"updated_at", // meta
|
||||
"can_launch_invitation", // internal flag
|
||||
]);
|
||||
|
||||
const ACTION_LABELS = {
|
||||
delivery_status_change: "Изменение статуса доставки",
|
||||
notification_status_change: "Изменение статуса уведомления",
|
||||
status_change: "Изменение статуса",
|
||||
};
|
||||
|
||||
const SMS_CAMPAIGN_LABELS = {
|
||||
first_sms: "Отправка 1-го SMS",
|
||||
second_sms: "Отправка 2-го SMS",
|
||||
manual: "Ручная отправка SMS",
|
||||
paid_storage: "SMS о платном хранении",
|
||||
};
|
||||
|
||||
const SMS_STATUS_LABELS = {
|
||||
delivered: "доставлено",
|
||||
sent: "отправлено",
|
||||
failed: "ошибка",
|
||||
pending: "в очереди",
|
||||
};
|
||||
|
||||
const fmtTimestamp = (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 translateValue = (v) => {
|
||||
if (v === null || v === undefined) return "—";
|
||||
if (typeof v === "string" && v.includes("T") && v.length > 10) {
|
||||
return fmtTimestamp(v);
|
||||
}
|
||||
if (typeof v === "boolean") return v ? "да" : "нет";
|
||||
return STATUS_LABELS[v] ?? String(v);
|
||||
};
|
||||
|
||||
const translateField = (f) => FIELD_LABELS[f] ?? f;
|
||||
|
||||
const formatChanges = (changes) => {
|
||||
if (!changes || typeof changes !== "object") return [];
|
||||
const lines = [];
|
||||
for (const [field, change] of Object.entries(changes)) {
|
||||
if (SKIP_FIELDS.has(field)) continue;
|
||||
if (change && typeof change === "object" && "old" in change && "new" in change) {
|
||||
const oldVal = translateValue(change.old);
|
||||
const newVal = translateValue(change.new);
|
||||
if (oldVal !== newVal) {
|
||||
lines.push({ field: translateField(field), oldVal, newVal });
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
||||
export const OrderHistoryTimeline = ({ order, userRole }) => {
|
||||
const [history, setHistory] = useState(null);
|
||||
const [smsLogs, setSmsLogs] = useState([]);
|
||||
const [invitation, setInvitation] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fetchAll = async () => {
|
||||
if (!order?.id || !supabase) return;
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from("order_history")
|
||||
.select("id, action, old_status, new_status, user_id, metadata, created_at")
|
||||
.eq("order_group_id", order.id)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(50);
|
||||
if (cancelled) return;
|
||||
if (error) { setHistory([]); } else { setHistory(data || []); }
|
||||
} catch { if (!cancelled) setHistory([]); }
|
||||
|
||||
try {
|
||||
const { data: smsData, error: smsError } = await supabase
|
||||
.from("sms_campaign_log")
|
||||
.select("id, campaign_type, status, sms_text, error_message, created_at, sent_to")
|
||||
.eq("order_group_id", order.id)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(50);
|
||||
if (cancelled) return;
|
||||
if (!smsError) setSmsLogs(smsData || []);
|
||||
} catch { if (!cancelled) setSmsLogs([]); }
|
||||
|
||||
try {
|
||||
const { data: invData, error: invError } = await supabase
|
||||
.from("delivery_invitations")
|
||||
.select("id, sent_at, opened_at, confirmed_at, access_count, last_accessed_at")
|
||||
.eq("order_group_id", order.id)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(1);
|
||||
if (cancelled) return;
|
||||
if (!invError) setInvitation((invData && invData.length > 0) ? invData[0] : null);
|
||||
} catch { if (!cancelled) setInvitation(null); }
|
||||
};
|
||||
fetchAll();
|
||||
return () => { cancelled = true; };
|
||||
}, [order?.id]);
|
||||
|
||||
const allEvents = useMemo(() => {
|
||||
const events = [];
|
||||
|
||||
if (history && history.length > 0) {
|
||||
for (const h of history) {
|
||||
const changes = formatChanges(h.metadata?.changes);
|
||||
events.push({
|
||||
id: `hist-${h.id}`,
|
||||
created_at: h.created_at,
|
||||
type: "history",
|
||||
label: ACTION_LABELS[h.action] ?? h.action,
|
||||
changes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (smsLogs && smsLogs.length > 0) {
|
||||
for (const s of smsLogs) {
|
||||
const text = s.sms_text ? (s.sms_text.length > 100 ? s.sms_text.slice(0, 100) + "…" : s.sms_text) : "";
|
||||
events.push({
|
||||
id: `sms-${s.id}`,
|
||||
created_at: s.created_at,
|
||||
type: "sms",
|
||||
label: SMS_CAMPAIGN_LABELS[s.campaign_type] ?? `SMS (${s.campaign_type || "—"})`,
|
||||
smsStatus: SMS_STATUS_LABELS[s.status] ?? s.status ?? "—",
|
||||
smsText: text,
|
||||
smsError: s.error_message || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (invitation) {
|
||||
if (invitation.sent_at) {
|
||||
events.push({ id: `inv-sent-${invitation.id}`, created_at: invitation.sent_at, type: "invitation", label: "Приглашение отправлено" });
|
||||
}
|
||||
if (invitation.opened_at) {
|
||||
events.push({ id: `inv-opened-${invitation.id}`, created_at: invitation.opened_at, type: "invitation", label: "Клиент открыл страницу", accessCount: invitation.access_count });
|
||||
}
|
||||
if (invitation.confirmed_at) {
|
||||
events.push({ id: `inv-confirmed-${invitation.id}`, created_at: invitation.confirmed_at, type: "invitation", label: "Клиент подтвердил дату доставки" });
|
||||
}
|
||||
}
|
||||
|
||||
events.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
return events;
|
||||
}, [history, smsLogs, invitation]);
|
||||
|
||||
if (!history && smsLogs.length === 0 && !invitation) return null;
|
||||
if (allEvents.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ol className="relative space-y-3 pl-4">
|
||||
{allEvents.map((ev, idx) => (
|
||||
<li key={ev.id ?? idx} className="relative border-l border-[var(--color-border)] pl-4">
|
||||
<span className="absolute -left-[5px] top-1 h-2.5 w-2.5 rounded-full bg-[var(--color-accent)]" aria-hidden />
|
||||
<p className="text-xs text-[var(--color-text-muted)]">{fmtTimestamp(ev.created_at)}</p>
|
||||
<p className="mt-0.5 text-sm font-medium text-[var(--color-text)]">{ev.label}</p>
|
||||
|
||||
{ev.type === "history" && ev.changes.length > 0 && (
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{ev.changes.map((c, i) => (
|
||||
<p key={i} className="text-sm text-[var(--color-text-muted)]">
|
||||
{c.field}: <span className="font-medium">{c.oldVal}</span>
|
||||
{" → "}
|
||||
<span className="font-medium text-[var(--color-text)]">{c.newVal}</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ev.type === "sms" && (
|
||||
<>
|
||||
<p className="mt-0.5 text-sm">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${
|
||||
ev.smsStatus === "доставлено"
|
||||
? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
|
||||
: ev.smsStatus === "ошибка"
|
||||
? "bg-[rgba(239,68,68,0.12)] text-[var(--color-danger)]"
|
||||
: "bg-[var(--color-surface)] text-[var(--color-text-muted)]"
|
||||
}`}>
|
||||
{ev.smsStatus}
|
||||
</span>
|
||||
</p>
|
||||
{ev.smsText && <p className="mt-0.5 text-xs text-[var(--color-text-muted)]">«{ev.smsText}»</p>}
|
||||
{ev.smsError && <p className="mt-0.5 text-xs text-[var(--color-danger)]">Ошибка: {ev.smsError}</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{ev.type === "invitation" && ev.accessCount != null && ev.accessCount > 0 && (
|
||||
<p className="mt-0.5 text-xs text-[var(--color-text-muted)]">Открытий страницы: {ev.accessCount}</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderHistoryTimeline;
|
||||
|
|
@ -5,7 +5,6 @@
|
|||
* and a "Restart SMS" button that resets this group's notification status.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Panel } from "../UI/Panel";
|
||||
import { Badge } from "../UI/Badge";
|
||||
import { supabase } from "../../supabaseClient";
|
||||
|
||||
|
|
@ -212,7 +211,7 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
|||
const secondSmsFailed = secondSmsLog && ["expired", "error", "send_failed", "limit_exceeded"].includes(secondSmsLog.status);
|
||||
|
||||
return (
|
||||
<Panel className="p-4">
|
||||
<div className="p-4">
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-semibold text-[var(--color-text)]">📱 SMS-уведомления</h3>
|
||||
</div>
|
||||
|
|
@ -330,6 +329,6 @@ export const SmsStatusCard = ({ order, userRole }) => {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import React from "react";
|
||||
import { Badge } from "../UI/Badge";
|
||||
import { Button } from "../UI/Button";
|
||||
import { Panel } from "../UI/Panel";
|
||||
import { DELIVERY_GROUP_STATUS_LABELS } from "../../services/orderGroupViews";
|
||||
import { CalledPanel } from "./CalledPanel";
|
||||
|
||||
const StatusActionPanel = ({
|
||||
order,
|
||||
|
|
@ -10,6 +10,7 @@ const StatusActionPanel = ({
|
|||
canManageDelivery,
|
||||
isSavingStatusChange,
|
||||
onConfirmStatus,
|
||||
onRefreshOrder,
|
||||
}) => {
|
||||
if (!canManageDelivery || !["manager", "logistician", "admin", "mega_admin"].includes(userRole) || !order) {
|
||||
return null;
|
||||
|
|
@ -60,13 +61,10 @@ const StatusActionPanel = ({
|
|||
];
|
||||
|
||||
return (
|
||||
<Panel className="space-y-4 p-5">
|
||||
<div>
|
||||
<strong>Статус доставки</strong>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
Измените статус, если водитель забыл обновить или нужна корректировка.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status indicators: show current state clearly */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
|
@ -130,7 +128,12 @@ const StatusActionPanel = ({
|
|||
⚠ Чтобы поставить «Доставлено» или «Вывезено», сначала укажите дату и половину дня доставки выше.
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<div className="pt-3 border-t border-[var(--color-border)]">
|
||||
<p className="mb-2 text-sm text-[var(--color-text-muted)]">Статус звонка:</p>
|
||||
<CalledPanel order={order} onUpdate={onRefreshOrder} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -285,7 +285,7 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
|
|||
};
|
||||
};
|
||||
|
||||
const ORDER_GROUP_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, delivery_link_code, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, delivery_invitation:delivery_invitations!order_groups_delivery_invitation_id_fkey(opened_at, access_count, last_accessed_at)`;
|
||||
const ORDER_GROUP_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, delivery_link_code, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, delivery_invitation:delivery_invitations!order_groups_delivery_invitation_id_fkey(opened_at, access_count, last_accessed_at), called, called_at, called_comment, order_history(id, action, old_status, new_status, user_id, metadata, created_at)`;
|
||||
|
||||
export const updateOrderGroupDeliveryChoice = async ({
|
||||
orderGroupId,
|
||||
|
|
|
|||
Loading…
Reference in New Issue