supersam/src/components/orders/OrderDetailPanel.jsx

1246 lines
54 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const DriverShipmentReport = ({ shipmentData }) => {
if (!Array.isArray(shipmentData) || shipmentData.length === 0) return null;
return (
<Panel className="space-y-4 p-5 border-[var(--color-warning)]">
<div className="flex items-center gap-2">
<svg className="h-5 w-5 text-[var(--color-warning)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<strong className="text-[var(--color-warning)]">Проблемы с доставкой позиций</strong>
</div>
<p className="text-sm text-[var(--color-text-muted)]">
Не доставлено {shipmentData.length} {shipmentData.length === 1 ? "позиция" : shipmentData.length < 5 ? "позиции" : "позиций"}. Остальное доставлено.
</p>
<div className="space-y-2">
{shipmentData.map((item) => (
<div
key={item.id || item.name}
className="rounded-[18px] border border-[var(--color-warning)] bg-[var(--color-warning-soft)] px-4 py-3 text-sm"
>
<div className="flex items-center justify-between gap-2">
<span className="text-[var(--color-text)]">{item.name}</span>
{item.quantity || item.unit ? (
<Badge tone="neutral">{[item.quantity, item.unit].filter(Boolean).join(" ")}</Badge>
) : null}
</div>
{item.comment ? (
<p className="mt-1 text-xs text-[var(--color-text-muted)]">Причина: {item.comment}</p>
) : (
<p className="mt-1 text-xs text-[var(--color-text-muted)] italic">Причина не указана</p>
)}
</div>
))}
</div>
</Panel>
);
};
import React from "react";
import { formatDateTime } from "../../utils/formatters";
import { Badge } from "../UI/Badge";
import { Button } from "../UI/Button";
import { Panel } from "../UI/Panel";
import { DriverShipmentPanel } from "../driver/DriverShipmentPanel";
import { CalendarWidget } from "./CalendarWidget";
import { StatusActionPanel } from "./StatusActionPanel";
import { DriverAssignmentPanel } from "./DriverAssignmentPanel";
import { matchesStopWord, useStopWords } from "../../hooks/useStopWords";
import {
getOrderGroupDeliveryStatusLabel,
getOrderGroupDisplayStatusLabel,
getOrderGroupStatusTone,
DELIVERY_GROUP_STATUS_LABELS,
} from "../../services/orderGroupViews";
import { getErrorMessage, normalizeNom } from "../../utils/deliveryUtils";
const DELIVERY_TIME_OPTIONS = ["Первая половина дня", "Вторая половина дня"];
const STATUS_LABELS = DELIVERY_GROUP_STATUS_LABELS;
const ConfirmModal = ({ open, title, message, onConfirm, onCancel }) => {
if (!open) return null;
return (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/40" onClick={onCancel}>
<div className="mx-4 w-full max-w-sm rounded-[24px] bg-[var(--color-surface-strong)] p-6 shadow-xl" onClick={e => e.stopPropagation()}>
{title && <h3 className="text-lg font-semibold">{title}</h3>}
{message && <p className="mt-2 text-sm text-[var(--color-text-muted)]">{message}</p>}
<div className="mt-5 flex justify-end gap-3">
<button type="button" className="rounded-xl border border-[var(--color-border)] px-4 py-2 text-sm font-medium text-[var(--color-text-muted)] hover:bg-[var(--color-surface)]" onClick={onCancel}>Отмена</button>
<button type="button" className="rounded-xl bg-[var(--color-accent)] px-4 py-2 text-sm font-medium text-white hover:opacity-90" onClick={onConfirm}>Подтвердить</button>
</div>
</div>
</div>
);
};
const DELIVERY_TIME_ALIASES = {
"До обеда": "Первая половина дня",
"После обеда": "Вторая половина дня",
};
const CollapsibleChips = ({ label, items }) => {
const [open, setOpen] = React.useState(false);
if (!Array.isArray(items) || items.length === 0) return null;
return (
<span className="inline">
<button
type="button"
className="ml-1 cursor-pointer rounded-full bg-[var(--color-surface)] px-2 py-0.5 text-xs font-medium text-[var(--color-text-muted)] transition hover:bg-[var(--color-accent-soft)] hover:text-[var(--color-accent)]"
onClick={() => setOpen(!open)}
>
{label} {open ? "▲" : "▼"}
</button>
{open && (
<span className="ml-1 inline-flex flex-wrap gap-1">
{items.map((item, idx) => (
<span
key={idx}
className="cursor-pointer rounded-full bg-[var(--color-surface)] px-2 py-0.5 text-xs text-[var(--color-text-muted)] transition hover:bg-[var(--color-accent-soft)] hover:text-[var(--color-accent)]"
title="Нажмите, чтобы скопировать"
onClick={() => { navigator.clipboard?.writeText(item); }}
>{item}</span>
))}
</span>
)}
</span>
);
};
const renderList = (values) => {
if (!Array.isArray(values) || !values.length) {
return <p className="text-sm text-[var(--color-text-muted)]">Нет данных</p>;
}
return (
<div className="flex flex-wrap gap-2">
{values.map((value, index) => (
<span
key={`${value}-${index}`}
className="rounded-full bg-[var(--color-surface)] px-3 py-1 text-xs text-[var(--color-text-muted)]"
>
{value}
</span>
))}
</div>
);
};
const renderValue = (value) => value || "Нет данных";
const getAllBillNumbers = (order) => {
const orders = parseOrderList(order);
if (!orders.length) return order.orderNumbers || [];
return orders.map((o) => o.nom || o.name || '').filter(Boolean);
};
const parseOrderList = (order) => {
if (!order) return [];
// Try orderList first (Supabase JSONB array of positions)
if (order.orderList) {
let parsed = order.orderList;
if (typeof parsed === 'string') {
try { parsed = JSON.parse(parsed); } catch { /* ignore */ }
}
if (Array.isArray(parsed)) return parsed;
}
// Fallback: orderListStructured (JSONB with { orders: [...] })
if (order.orderListStructured) {
let parsed = order.orderListStructured;
if (typeof parsed === 'string') {
try { parsed = JSON.parse(parsed); } catch { /* ignore */ }
}
if (parsed && Array.isArray(parsed.orders)) return parsed.orders;
}
// Fallback: sourceOrders (1C exchange data)
// 1C sends the FULL order composition (main + associated bills) in EVERY source order's orderList.
// We must deduplicate by nom to avoid showing the same items multiple times.
if (order.sourceOrders) {
let parsed = order.sourceOrders;
if (typeof parsed === 'string') {
try { parsed = JSON.parse(parsed); } catch { /* ignore */ }
}
if (Array.isArray(parsed) && parsed.length > 0) {
const seen = new Set();
const allItems = [];
for (const src of parsed) {
if (src && Array.isArray(src.orderList)) {
for (const ol of src.orderList) {
if (ol && (ol.items || ol.nom || ol.name)) {
const normalizedNom = normalizeNom(ol.nom || ol.name || '');
// Deduplicate by nom — 1C repeats same orderList in every source order
if (seen.has(normalizedNom)) continue;
seen.add(normalizedNom);
allItems.push(ol);
}
}
}
}
if (allItems.length > 0) return allItems;
// Legacy: return whole array if no orderList structure
if (parsed[0].orderList && Array.isArray(parsed[0].orderList)) {
return parsed[0].orderList;
}
return parsed;
}
}
return [];
};
const normalizeDeliveryTimeChoice = (value) => {
const normalized = value ? String(value).trim() : "";
const deliveryTime = DELIVERY_TIME_ALIASES[normalized] || normalized;
return DELIVERY_TIME_OPTIONS.includes(deliveryTime) ? deliveryTime : DELIVERY_TIME_OPTIONS[0];
};
const toDateKey = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
const fromDateKey = (value) => {
const normalized = normalizeDateForInput(value);
if (!normalized) {
return null;
}
const [year, month, day] = normalized.split("-").map(Number);
return new Date(year, month - 1, day);
};
const addDays = (date, amount) => {
const nextDate = new Date(date);
nextDate.setDate(nextDate.getDate() + amount);
return nextDate;
};
const isWeekendDate = (date) => {
const day = date.getDay();
return day === 0 || day === 6;
};
export const getNextSelectableDateKey = (referenceDate = new Date()) => {
let current = addDays(referenceDate, 1);
while (isWeekendDate(current)) {
current = addDays(current, 1);
}
return toDateKey(current);
};
const normalizePhoneForTel = (phone) => {
const cleaned = String(phone || "").trim();
if (!cleaned) return "";
if (cleaned.startsWith("+7")) return cleaned;
if (cleaned.startsWith("8")) return "+7" + cleaned.slice(1);
return "+7" + cleaned;
};
const isFutureDeliveryDate = (value) => {
const parsedDate = fromDateKey(value);
if (!parsedDate) {
return false;
}
return !isWeekendDate(parsedDate) && toDateKey(parsedDate) >= getNextSelectableDateKey();
};
const formatDateForDisplay = (value) => {
if (!value) {
return "Выберите дату";
}
const [year, month, day] = value.split("-").map(Number);
if (!year || !month || !day) {
return value;
}
return new Date(year, month - 1, day).toLocaleDateString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
};
const formatDeliveryDateDisplay = (value) => {
const normalized = normalizeDateForInput(value);
if (!normalized) {
return renderValue(value);
}
return formatDateForDisplay(normalized);
};
const startOfMonth = (date) => new Date(date.getFullYear(), date.getMonth(), 1);
const addMonths = (date, amount) => new Date(date.getFullYear(), date.getMonth() + amount, 1);
const buildCalendarDays = (currentMonth) => {
const firstDay = startOfMonth(currentMonth);
const lastDay = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0);
const firstWeekDay = (firstDay.getDay() + 6) % 7;
const totalDays = lastDay.getDate();
const cells = [];
for (let index = 0; index < firstWeekDay; index += 1) {
cells.push(null);
}
for (let day = 1; day <= totalDays; day += 1) {
cells.push(new Date(currentMonth.getFullYear(), currentMonth.getMonth(), day));
}
while (cells.length % 7 !== 0) {
cells.push(null);
}
return cells;
};
const normalizeDateForInput = (value) => {
if (!value) {
return "";
}
const normalized = String(value).trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
return normalized;
}
const shortDateMatch = normalized.match(/^(\d{2})\.(\d{2})\.(\d{2})$/);
if (shortDateMatch) {
const [, day, month, year] = shortDateMatch;
return `20${year}-${month}-${day}`;
}
return "";
};
const CollapsibleOrderComposition = ({ order }) => {
const [isExpanded, setIsExpanded] = React.useState(false);
const { stopWords, active } = useStopWords();
const orders = parseOrderList(order);
const allPositions = orders.reduce((sum, o) => sum + (o.items?.length || 0), 0);
const filteredPositions = active ? orders.reduce((sum, o) => {
if (!o.items) return sum;
return sum + o.items.filter((item) => {
const name = String(item.product_name || item.name || item.title || "");
return !matchesStopWord(name, stopWords);
}).length;
}, 0) : allPositions;
return (
<div className="space-y-3">
<button
type="button"
className="flex w-full items-center justify-between text-left"
onClick={() => setIsExpanded(!isExpanded)}
>
<span className="font-semibold">Состав заказа</span>
<span className="flex items-center gap-2 text-sm text-[var(--color-text-muted)]">
{active && filteredPositions < allPositions
? `${filteredPositions} поз. из ${allPositions}`
: filteredPositions > 0
? `${filteredPositions} поз.`
: ''}
<svg
className="h-4 w-4 transition-transform"
style={{ transform: isExpanded ? 'rotate(180deg)' : '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>
</button>
{isExpanded && (
<div className="space-y-3">
{!orders.length ? (
<p className="text-sm text-[var(--color-text-muted)]">Позиции не указаны</p>
) : (
orders.map((orderItem, idx) => (
<div key={idx} className="rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4">
<div className="mb-3 pb-2 border-b border-[var(--color-border)]">
<p className="font-bold text-[var(--color-text)] text-sm">{orderItem.nom || orderItem.name || `Заказ ${idx + 1}`}</p>
</div>
{(() => {
const filtered = (orderItem.items || []).filter((item) => {
const name = String(item.product_name || item.name || item.title || "");
return active ? !matchesStopWord(name, stopWords) : true;
});
if (filtered.length === 0 && active && (orderItem.items || []).length > 0) {
return <p className="text-sm text-[var(--color-text-muted)] italic">Только услуги скрыты стоп-словами</p>;
}
if (filtered.length === 0) {
return <p className="text-sm text-[var(--color-text-muted)]">Позиции не указаны</p>;
}
return (
<div className="space-y-2">
{filtered.map((item, itemIdx) => (
<div key={itemIdx} className="grid grid-cols-[1fr_auto] gap-x-4 gap-y-1 text-sm">
<span className="text-[var(--color-text)] min-w-0">{item.product_name || item.name || item.title || ''}</span>
<span className="text-[var(--color-text-muted)] whitespace-nowrap text-right">
{item.product_quantity || item.quantity || item.count || item.amount || ""} {item.product_ed || item.unit || ""}
</span>
</div>
))}
</div>
);
})()}
</div>
))
)}
</div>
)}
</div>
);
};
const PaidStoragePanel = ({ order, onChangeDeliveryStatus, isSavingStatusChange, setFormMessage }) => {
const [showConfirm, setShowConfirm] = React.useState(false);
const isPaidStorage = (order.deliveryStatus || order.delivery_status) === "paid_storage";
if (isPaidStorage) {
return (
<Panel className="space-y-4 p-5">
<div className="flex items-center gap-2">
<span className="inline-flex h-2 w-2 rounded-full bg-[var(--color-warning)]"></span>
<strong>Платное хранение</strong>
</div>
{order.paidStorageAt && (
<p className="text-sm text-[var(--color-text-muted)]">
Переведено: {formatDateTime(order.paidStorageAt)}
</p>
)}
<Button
variant="secondary"
onClick={() => {
onChangeDeliveryStatus({
orderGroupId: order.id,
status: "pending_confirmation",
}).then((response) => {
if (!response.success) {
setFormMessage(response.error || "Не удалось отменить платное хранение");
} else {
setFormMessage("Платное хранение отменено");
}
});
}}
disabled={isSavingStatusChange}
>
Отменить платное хранение
</Button>
</Panel>
);
}
return (
<Panel className="space-y-4 p-5">
<div>
<strong>Платное хранение</strong>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
Переведите заказ в статус платного хранения, если клиент не забрал товар в срок.
</p>
</div>
{showConfirm ? (
<div className="space-y-3 rounded-2xl border border-[var(--color-warning)] bg-[var(--color-warning-soft)] p-4">
<p className="text-sm font-medium">Перевести заказ в платное хранение? Клиент получит уведомление.</p>
<div className="flex gap-2">
<Button
variant="primary"
onClick={() => {
onChangeDeliveryStatus({
orderGroupId: order.id,
status: "paid_storage",
}).then((response) => {
if (!response.success) {
setFormMessage(response.error || "Не удалось обновить статус");
} else {
setFormMessage("Заказ переведён в платное хранение");
setShowConfirm(false);
}
});
}}
disabled={isSavingStatusChange}
>
Да, перевести
</Button>
<Button
variant="secondary"
onClick={() => setShowConfirm(false)}
disabled={isSavingStatusChange}
>
Отмена
</Button>
</div>
</div>
) : (
<Button
variant="secondary"
onClick={() => setShowConfirm(true)}
disabled={isSavingStatusChange}
>
Перевести в платное хранение
</Button>
)}
</Panel>
);
};
const PROBLEM_REASONS = [
{ value: "client_absent", label: "Клиент не принял", description: "Клиент отказался или не вышел на связь" },
{ value: "damage", label: "Повреждение заказа", description: "Товар повреждён при транспортировке" },
{ value: "wrong_address", label: "Неверный адрес", description: "Адрес доставки указан неверно" },
{ value: "other", label: "Другое", description: "Иная причина проблемы доставки" },
];
const ProblemReasonModal = ({ onSelect, onCancel }) => (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onCancel}>
<Panel className="mx-4 w-full max-w-md space-y-4 p-6" onClick={(e) => e.stopPropagation()}>
<h3 className="text-lg font-semibold">Причина проблемы</h3>
<p className="text-sm text-[var(--color-text-muted)]">Укажите причину возникшей проблемы с доставкой.</p>
<div className="space-y-2">
{PROBLEM_REASONS.map((reason) => (
<button
key={reason.value}
type="button"
className="w-full rounded-[16px] border border-[var(--color-border)] bg-[var(--color-surface)] p-3 text-left transition hover:border-[var(--color-accent)] hover:bg-[var(--color-accent-soft)]"
onClick={() => onSelect(reason.value, reason.label)}
>
<span className="font-medium">{reason.label}</span>
<p className="mt-0.5 text-xs text-[var(--color-text-muted)]">{reason.description}</p>
</button>
))}
</div>
<div className="flex justify-end">
<Button variant="ghost" onClick={onCancel}>Отмена</Button>
</div>
</Panel>
</div>
);
export const OrderDetailPanel = ({
order,
canManageDelivery = false,
onSaveManualDeliveryChoice,
isSavingDeliveryChoice = false,
isSavingDriverAssignment = false,
isSavingStatusChange = false,
drivers = [],
onAssignDriver,
onChangeDeliveryStatus,
userRole,
}) => {
const [problemReason, setProblemReason] = React.useState(null);
const [pendingStatus, setPendingStatus] = React.useState(null);
const [deliveryDate, setDeliveryDate] = React.useState("");
const [deliveryTime, setDeliveryTime] = React.useState(DELIVERY_TIME_OPTIONS[0]);
const [formMessage, setFormMessage] = React.useState("");
const [shipmentState, setShipmentState] = React.useState(null);
const [isCalendarOpen, setIsCalendarOpen] = React.useState(false);
const [driverMessage, setDriverMessage] = React.useState("");
const [selectedDriverId, setSelectedDriverId] = React.useState(order?.assignedDriverId || "");
const [deliveryType, setDeliveryType] = React.useState(order?.deliveryType || "delivery");
const [pickupDate, setPickupDate] = React.useState(order?.pickupDate || "");
const [pickupTimeSlot, setPickupTimeSlot] = React.useState(DELIVERY_TIME_OPTIONS[0]);
const [deliveryAddress, setDeliveryAddress] = React.useState(order?.originalDeliveryAddress || order?.deliveryAddress || order?.customerAddress || "");
const [confirmAction, setConfirmAction] = React.useState(null);
const [isEditingDate, setIsEditingDate] = React.useState(false);
const handleShipmentChange = React.useCallback((state) => {
setShipmentState(state);
}, []);
const minSelectableDateKey = React.useMemo(() => getNextSelectableDateKey(), []);
const [currentMonth, setCurrentMonth] = React.useState(() => {
const existingDeliveryDate = fromDateKey(order?.deliveryDate);
const fallbackDate = fromDateKey(minSelectableDateKey) || new Date();
const sourceDate = existingDeliveryDate && isFutureDeliveryDate(toDateKey(existingDeliveryDate))
? existingDeliveryDate
: fallbackDate;
return startOfMonth(sourceDate);
});
const calendarDays = React.useMemo(() => buildCalendarDays(currentMonth), [currentMonth]);
const monthLabel = React.useMemo(
() =>
currentMonth.toLocaleDateString("ru-RU", {
month: "long",
year: "numeric",
}),
[currentMonth],
);
const canGoBack = toDateKey(currentMonth) > toDateKey(startOfMonth(fromDateKey(minSelectableDateKey) || new Date()));
React.useEffect(() => {
setSelectedDriverId(order?.assignedDriverId || "");
setIsEditingDate(false);
}, [order?.id, order?.assignedDriverId]);
React.useEffect(() => {
const normalizedDeliveryDate = normalizeDateForInput(order?.deliveryDate);
const nextSelectableDateKey = getNextSelectableDateKey();
const selectedDateKey = isFutureDeliveryDate(normalizedDeliveryDate) ? normalizedDeliveryDate : nextSelectableDateKey;
setDeliveryDate(selectedDateKey);
const selectedDate = fromDateKey(selectedDateKey) || new Date();
setCurrentMonth(startOfMonth(selectedDate));
setDeliveryTime(normalizeDeliveryTimeChoice(order?.deliveryTime || order?.deliveryHalfDay));
setDeliveryType(order?.deliveryType || "delivery");
setPickupDate(order?.pickupDate || "");
setPickupTimeSlot(normalizeDeliveryTimeChoice(order?.pickupTimeSlot || order?.deliveryTime || order?.deliveryHalfDay));
setDeliveryAddress(order?.originalDeliveryAddress || order?.deliveryAddress || order?.customerAddress || "");
setFormMessage("");
}, [order?.id, order?.deliveryDate, order?.deliveryHalfDay, order?.deliveryTime, order?.deliveryType, order?.pickupDate, order?.pickupTimeSlot]);
if (!order) {
return (
<Panel className="flex min-h-[460px] items-center justify-center">
<p className="text-sm text-[var(--color-text-muted)]">Выберите группу для просмотра деталей.</p>
</Panel>
);
}
const isDeliveryAgreed = ["agreed", "driver_assigned", "loaded", "on_route", "delivered", "picked_up"].includes(order.deliveryStatus || order.delivery_status);
const isPickupOrder = order.deliveryType === "pickup" || order.deliveryStatus === "pickup" || order.delivery_status === "pickup";
// Show "agreed" banner only when selected tab matches the already-agreed type
const agreedTypeMatchesTab = isDeliveryAgreed && !isEditingDate && (
(deliveryType === "pickup" && isPickupOrder)
|| (deliveryType === "delivery" && !isPickupOrder)
);
const canEditDelivery = canManageDelivery && ["admin", "mega_admin", "logistician"].includes(userRole);
const agreedDeliveryLabel = [
formatDeliveryDateDisplay(order.deliveryDate),
order.deliveryTime || order.deliveryHalfDay,
].filter((value) => value && value !== "Нет данных").join(" · ");
const handleSaveDeliveryChoice = async () => {
const effectiveDate = deliveryType === "pickup" ? pickupDate : deliveryDate;
const effectiveTime = deliveryType === "pickup" ? pickupTimeSlot : deliveryTime;
if (!effectiveDate || !effectiveTime) {
setFormMessage(deliveryType === "pickup" ? "Укажите дату и время самовывоза." : "Укажите дату и половину дня доставки.");
return;
}
if (!isFutureDeliveryDate(effectiveDate)) {
setFormMessage(deliveryType === "pickup" ? "Выберите дату самовывоза позже сегодняшнего дня." : "Выберите дату доставки позже сегодняшнего дня.");
return;
}
try {
const result = await onSaveManualDeliveryChoice?.({
orderGroupId: order.id,
deliveryDate: deliveryType === "pickup" ? pickupDate : deliveryDate,
deliveryTime: deliveryType === "pickup" ? pickupTimeSlot : deliveryTime,
deliveryType,
...(deliveryType === "pickup" ? { pickupDate, pickupTimeSlot } : {}),
...(deliveryType === "delivery" ? { deliveryAddress: deliveryAddress.trim() } : {}),
});
if (result?.success) {
setFormMessage(deliveryType === "pickup" ? "Самовывоз согласован вручную." : "Доставка согласована вручную.");
return;
}
setFormMessage(getErrorMessage(result?.error, "Не удалось сохранить согласование доставки."));
} catch (error) {
setFormMessage(getErrorMessage(error, "Не удалось сохранить согласование доставки."));
}
};
const handleAssignDriver = async () => {
if (!selectedDriverId) {
setDriverMessage("Выберите водителя");
return;
}
if (!order.deliveryDate) {
setDriverMessage("Сначала укажите дату и время доставки.");
return;
}
setDriverMessage("");
const response = await onAssignDriver({
orderGroupId: order.id,
driverId: selectedDriverId,
});
if (!response.success) {
setDriverMessage(response.error || "Не удалось назначить водителя");
} else {
setDriverMessage("Водитель назначен");
}
};
return (
<div className="space-y-5">
<Panel className="space-y-5 p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
{isPickupOrder ? "Карточка группы самовывоза" : "Карточка группы доставки"}
</p>
<h2 className="mt-2 text-2xl font-semibold">
{order.displayTitle || order.customerName || order.groupKey}
</h2>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
{(() => {
const parts = [];
if (order.orderNumbers && order.orderNumbers.length > 0) parts.push(order.orderNumbers.join(", "));
const sub = order.displaySubtitle || [order.customerPhone, order.customerDate].filter(Boolean).join(" · ");
if (sub) parts.push(sub);
return parts.join(" · ") || "Не указано";
})()}
</p>
</div>
<Badge tone={getOrderGroupStatusTone(order)}>{getOrderGroupDisplayStatusLabel(order)}</Badge>
</div>
{(() => {
const isPickup = isPickupOrder;
const effectiveAddress = isPickup
? (order.customerAddress || "")
: (order.deliveryAddress || "");
const requiresAddress = (order.deliveryStatus === "requires_address" || order.delivery_status === "requires_address") && !effectiveAddress;
const deliveryTypeLabel = isPickup
? "Самовывоз"
: requiresAddress
? "Доставка (требуется адрес)"
: "Доставка";
const dateLabel = isPickup ? "Дата самовывоза" : "Дата доставки";
const timeLabel = isPickup ? "Время самовывоза" : "Время доставки";
const addressLabel = isPickup ? "Адрес клиента" : "Адрес доставки";
return (
<div className="grid gap-3 rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 md:grid-cols-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
{dateLabel}
</p>
<p className="mt-1 text-base font-medium !text-[var(--color-text)]">{formatDeliveryDateDisplay(order.deliveryDate)}</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
{timeLabel}
</p>
<p className="mt-1 text-base font-medium !text-[var(--color-text)]">{renderValue(order.deliveryTime || order.deliveryHalfDay)}</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Тип
</p>
<p className="mt-1 text-base font-medium !text-[var(--color-text)]">{deliveryTypeLabel}</p>
{requiresAddress && (
<div className="mt-2 flex items-start gap-2 rounded-xl border border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] p-3">
<span className="text-lg">📍</span>
<div>
<p className="text-sm font-semibold text-[var(--color-text)]">Адрес доставки не указан</p>
<p className="text-xs text-[var(--color-text-muted)]">Клиент выбрал доставку, но адрес отсутствует. Уточните адрес у клиента и заполните поле ниже.</p>
</div>
</div>
)}
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Водитель
</p>
<p className="mt-1 text-base font-medium !text-[var(--color-text)]">{order.assignedDriverId ? renderValue(order.assignedDriverName) : (isPickup ? "Не нужен" : "Не назначен")}</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Телефон
</p>
<a
href={`tel:${normalizePhoneForTel(order.customerPhone)}`}
className="mt-1 block text-base font-medium !text-[var(--color-accent)] hover:underline"
>
{renderValue(order.customerPhone)}
</a>
</div>
{!isPickup || effectiveAddress ? (
<div>
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
{addressLabel}
</p>
<p className="mt-1 text-base font-medium !text-[var(--color-text)]">{renderValue(effectiveAddress)}</p>
</div>
) : null}
</div>
);
})()}
<div className="grid gap-x-4 gap-y-2 grid-cols-2 md:grid-cols-4">
<div>
<p className="text-xs text-[var(--color-text-muted)]">Заказ</p>
<p className="font-medium !text-[var(--color-text)]">{(() => {
const mainNumbers = order.orderNumbers || [];
const allNumbers = order.allBillNumbers || [];
const mainSet = new Set(mainNumbers.map(String));
const extraNumbers = allNumbers.filter((n) => !mainSet.has(String(n)));
if (mainNumbers.length > 0) {
return (
<span>
{mainNumbers.map((num, idx) => (
<span
key={idx}
className="mr-1 cursor-pointer rounded-full bg-[var(--color-accent-soft)] px-2.5 py-0.5 text-sm font-bold text-[var(--color-accent)] transition hover:opacity-80 active:opacity-60"
title="Нажмите, чтобы скопировать"
onClick={() => { navigator.clipboard?.writeText(num); }}
>{num}</span>
))}
{extraNumbers.length > 0 && (
<CollapsibleChips
label={`+${extraNumbers.length} сч.`}
items={extraNumbers}
/>
)}
</span>
);
}
return renderValue(order.orderNumberSummary);
})()}</p>
</div>
<div>
<p className="text-xs text-[var(--color-text-muted)]">Клиент</p>
<p className="font-medium !text-[var(--color-text)]">{renderValue(order.customerName)}</p>
</div>
<div>
<p className="text-xs text-[var(--color-text-muted)]">Дата счёта</p>
<p className="font-medium !text-[var(--color-text)]">{renderValue(order.customerDate)}</p>
</div>
<div>
<p className="text-xs text-[var(--color-text-muted)]">Всего заказов</p>
<p className="font-medium !text-[var(--color-text)]">{order.ordersCount ?? 0}</p>
</div>
<div>
<p className="text-xs text-[var(--color-text-muted)]">Готово</p>
<p className="font-medium !text-[var(--color-text)]">{order.readyCount ?? 0}</p>
</div>
{(order.notReadyCount ?? 0) > 0 ? (
<div>
<p className="text-xs text-[var(--color-text-muted)]">Не готово</p>
<p className="font-medium !text-[var(--color-text)]">{order.notReadyCount}</p>
</div>
) : null}
<div>
<p className="text-xs text-[var(--color-text-muted)]">Обновлена</p>
<p className="font-medium !text-[var(--color-text)]">{formatDateTime(order.updatedAt)}</p>
</div>
<div>
<p className="text-xs text-[var(--color-text-muted)]">{isPickupOrder ? "Статус самовывоза" : "Статус доставки"}</p>
<p className="font-medium !text-[var(--color-text)]">{getOrderGroupDeliveryStatusLabel(order.deliveryStatus || order.delivery_status)}</p>
</div>
</div>
</Panel>
{canManageDelivery ? (
<Panel className="space-y-4 p-5">
<div>
<strong>Ручное согласование</strong>
<p className="mt-1 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
type="button"
className={`flex-1 rounded-xl px-3 py-2 text-sm font-semibold transition ${
deliveryType === "delivery"
? "bg-[var(--color-accent)] text-white"
: "text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
}`}
onClick={() => { setDeliveryType("delivery"); setFormMessage(""); }}
>
🚚 Доставка
</button>
<button
type="button"
className={`flex-1 rounded-xl px-3 py-2 text-sm font-semibold transition ${
deliveryType === "pickup"
? "bg-[var(--color-accent)] text-white"
: "text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
}`}
onClick={() => { setDeliveryType("pickup"); setFormMessage(""); }}
>
🏪 Самовывоз
</button>
</div>
{deliveryType === "pickup" && (
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3 text-sm text-[var(--color-text-muted)]">
<p className="font-semibold text-[var(--color-text)]"> Условия хранения</p>
<p className="mt-1">Бесплатное хранение <strong>2 рабочих дня</strong> с даты готовности.</p>
<p>Начиная с 3-го рабочего дня <strong>300 /день</strong> платного хранения.</p>
</div>
)}
{deliveryType === "delivery" && (
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Адрес доставки
</label>
<input
type="text"
value={deliveryAddress}
onChange={(e) => setDeliveryAddress(e.target.value)}
placeholder="Введите адрес доставки"
className="w-full rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-sm !text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none"
/>
</div>
)}
{agreedTypeMatchesTab ? (
<div className="space-y-3">
<div className="rounded-[24px] border border-[rgba(18,128,92,0.35)] bg-[var(--color-accent-soft)] p-4 !text-[var(--color-text)]">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-accent)]">
{deliveryType === "pickup" ? "Самовывоз согласован" : "Доставка согласована"}
</p>
<p className="mt-1 text-lg font-semibold">
{agreedDeliveryLabel || "Дата и время сохранены"}
</p>
</div>
<Badge tone="accent">Согласовано</Badge>
</div>
</div>
{canEditDelivery ? (
<Button
variant="secondary"
onClick={() => { setIsEditingDate(true); setFormMessage(""); }}
disabled={isSavingDeliveryChoice}
className="text-sm"
>
Изменить дату {deliveryType === "pickup" ? "самовывоза" : "доставки"}
</Button>
) : null}
</div>
) : deliveryType === "delivery" ? (
<CalendarWidget
label="Календарь доставки"
selectedDate={deliveryDate}
onDateChange={(dateKey) => { setDeliveryDate(dateKey); setFormMessage(""); }}
minDateKey={minSelectableDateKey}
isCalendarOpen={isCalendarOpen}
setIsCalendarOpen={setIsCalendarOpen}
currentMonth={currentMonth}
setCurrentMonth={setCurrentMonth}
calendarDays={calendarDays}
monthLabel={monthLabel}
canGoBack={canGoBack}
timeOptions={DELIVERY_TIME_OPTIONS}
selectedTime={deliveryTime}
onTimeChange={(option) => { setDeliveryTime(option); setFormMessage(""); }}
layoutClassName="flex flex-col gap-3 md:flex-row md:items-start md:relative md:z-10"
calendarClassName="relative space-y-3 md:min-w-0 md:flex-1 md:pr-4"
timeClassName="grid gap-2 sm:grid-cols-2 md:w-[320px] md:flex-none"
/>
) : (
<CalendarWidget
label="Календарь самовывоза"
selectedDate={pickupDate}
onDateChange={(dateKey) => { setPickupDate(dateKey); setFormMessage(""); }}
minDateKey={minSelectableDateKey}
isCalendarOpen={isCalendarOpen}
setIsCalendarOpen={setIsCalendarOpen}
currentMonth={currentMonth}
setCurrentMonth={setCurrentMonth}
calendarDays={calendarDays}
monthLabel={monthLabel}
canGoBack={canGoBack}
timeOptions={DELIVERY_TIME_OPTIONS}
selectedTime={pickupTimeSlot}
onTimeChange={(option) => { setPickupTimeSlot(option); setFormMessage(""); }}
layoutClassName="space-y-3"
calendarClassName="relative"
timeClassName="grid gap-2 sm:grid-cols-2"
/>
)}
<Button
className="w-full md:w-[180px] md:flex-none md:self-start"
onClick={() => setConfirmAction({ type: 'delivery' })}
disabled={isSavingDeliveryChoice}
>
{isSavingDeliveryChoice ? "Сохраняем..." : "Согласовать"}
</Button>
{formMessage ? (
<p className="text-sm text-[var(--color-text-muted)]">{formMessage}</p>
) : null}
</Panel>
) : null}
{deliveryType === "delivery" && (
<DriverAssignmentPanel
order={order}
userRole={userRole}
canManageDelivery={canManageDelivery}
isSavingDriverAssignment={isSavingDriverAssignment}
selectedDriverId={selectedDriverId}
onDriverSelect={(id) => { setSelectedDriverId(id); setDriverMessage(""); }}
onConfirmDriver={() => setConfirmAction({ type: 'driver' })}
driverMessage={driverMessage}
drivers={drivers}
/>
)}
<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 });
}
}}
/>
{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}
{userRole === "driver" && order ? (
<DriverShipmentPanel order={order} onShipmentChange={handleShipmentChange} />
) : null}
{userRole === "driver" && order && onChangeDeliveryStatus ? (
<Panel className="space-y-4 p-5">
<div>
<strong>Статус доставки</strong>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
Выберите статус и нажмите «Сохранить».
</p>
</div>
{problemReason !== null ? (
<ProblemReasonModal
onSelect={(reasonValue, reasonLabel) => {
setPendingStatus({ value: "problem", reason: reasonValue, reasonLabel });
setProblemReason(null);
}}
onCancel={() => setProblemReason(null)}
/>
) : null}
<div className="flex flex-wrap gap-2">
{(() => {
const currentStatus = order.deliveryStatus || order.delivery_status;
const IN_TRANSIT_STATUSES = ["loaded", "on_route"];
const isOnRoute = IN_TRANSIT_STATUSES.includes(currentStatus);
let statusOptions = [];
if (currentStatus === "delivered" || currentStatus === "picked_up" || currentStatus === "problem" || currentStatus === "cancelled" || currentStatus === "paid_storage") {
statusOptions = [];
} else {
statusOptions = [
{ value: "delivered", label: "Доставлено" },
{ value: "picked_up", label: "Вывезено" },
{ value: "problem", label: "Проблема" },
];
}
if (statusOptions.length === 0) return null;
return statusOptions.map((statusOption) => {
const isSelected = pendingStatus?.value === statusOption.value;
const isDeliveredBtn = statusOption.value === "delivered";
const deliveryBlocked = isDeliveredBtn && shipmentState && !shipmentState.canMarkDelivered;
return (
<Button
key={statusOption.value}
variant={isSelected ? "primary" : "secondary"}
disabled={deliveryBlocked}
title={deliveryBlocked ? "Сначала отметьте все позиции как отгруженные" : undefined}
onClick={() => {
if (statusOption.value === "problem") {
setProblemReason("selecting");
return;
}
setPendingStatus({ value: statusOption.value });
}}
>
{statusOption.label}
{isDeliveredBtn && shipmentState && !shipmentState.canMarkDelivered ? (
<span className="ml-1.5 text-xs text-[var(--color-text-muted)]">
({shipmentState.shipped}/{shipmentState.total})
</span>
) : null}
</Button>
);
});
})()}
</div>
{pendingStatus ? (
<div className="flex items-center gap-3 mt-2">
<Button
variant="primary"
disabled={isSavingStatusChange}
onClick={() => {
if (pendingStatus.value === "delivered" && shipmentState && !shipmentState.canMarkDelivered) return;
onChangeDeliveryStatus({
orderGroupId: order.id,
status: pendingStatus.value,
details: pendingStatus.reason ? { reason: pendingStatus.reason, reasonLabel: pendingStatus.reasonLabel } : undefined,
shipmentData: pendingStatus.value === "delivered" && shipmentState ? shipmentState.shipmentData.filter((i) => !i.shipped) : undefined,
}).then((response) => {
if (!response.success) {
setFormMessage(response.error || "Не удалось обновить статус");
} else {
setFormMessage("");
setPendingStatus(null);
}
});
}}
>
Сохранить
</Button>
<Button variant="ghost" onClick={() => setPendingStatus(null)}>
Отмена
</Button>
</div>
) : null}
{formMessage ? (
<p className="text-sm text-[var(--color-warning)]">{formMessage}</p>
) : null}
</Panel>
) : null}
<Panel className="space-y-4 p-5">
<strong>Счета</strong>
{(() => {
const mainNumbers = order.orderNumbers || [];
const allNumbers = getAllBillNumbers(order);
const mainSet = new Set(mainNumbers.map(String));
const extraNumbers = allNumbers.filter((n) => !mainSet.has(String(n)));
return (
<div className="space-y-2">
{mainNumbers.length > 0 && (
<div>
<p className="text-xs text-[var(--color-text-muted)]">Основной счёт</p>
<div className="flex flex-wrap gap-2">
{mainNumbers.map((num, idx) => (
<span key={idx} className="rounded-full bg-[var(--color-accent-soft)] px-3 py-1 text-sm font-semibold text-[var(--color-accent)]">{num}</span>
))}
</div>
</div>
)}
{extraNumbers.length > 0 && (
<div>
<p className="text-xs text-[var(--color-text-muted)]">{mainNumbers.length > 0 ? "Составные заказы" : "Все счета"}</p>
<div className="flex flex-wrap gap-2">
{extraNumbers.map((num, idx) => (
<span key={idx} className="rounded-full bg-[var(--color-surface)] px-3 py-1 text-xs text-[var(--color-text-muted)]">{num}</span>
))}
</div>
</div>
)}
{mainNumbers.length === 0 && extraNumbers.length === 0 && (
<p className="text-sm text-[var(--color-text-muted)]">Нет данных</p>
)}
</div>
);
})()}
</Panel>
<Panel className="space-y-4 p-5">
<CollapsibleOrderComposition order={order} />
</Panel>
{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">
{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>
</Panel>
) : null}
<ConfirmModal
open={confirmAction?.type === 'delivery'}
title="Согласовать доставку?"
message={deliveryType === 'pickup' ? `Подтвердите самовывоз ${pickupDate ? formatDateForDisplay(pickupDate) : ''} ${pickupTimeSlot || ''}` : `Подтвердите доставку ${deliveryDate ? formatDateForDisplay(deliveryDate) : ''} ${deliveryTime || ''}`}
onConfirm={() => { setConfirmAction(null); handleSaveDeliveryChoice(); }}
onCancel={() => setConfirmAction(null)}
/>
<ConfirmModal
open={confirmAction?.type === 'driver'}
title="Назначить водителя?"
message={`Назначить ${drivers.find(d => d.id === selectedDriverId)?.name || 'выбранного водителя'} на эту группу?`}
onConfirm={() => { setConfirmAction(null); handleAssignDriver(); }}
onCancel={() => setConfirmAction(null)}
/>
<ConfirmModal
open={confirmAction?.type === 'status'}
title="Изменить статус?"
message={`Установить статус «${STATUS_LABELS[confirmAction?.status] || confirmAction?.status}»?`}
onConfirm={() => {
const status = confirmAction.status;
setConfirmAction(null);
onChangeDeliveryStatus({ orderGroupId: order.id, status }).then((response) => {
if (!response.success) setFormMessage(response.error || 'Не удалось обновить статус');
else setFormMessage('');
});
}}
onCancel={() => setConfirmAction(null)}
/>
</div>
);
};