supersam/src/components/orders/OrderDetailPanel.jsx

969 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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.

import React from "react";
import { formatDateTime } from "../../utils/formatters";
import { Badge } from "../UI/Badge";
import { Button } from "../UI/Button";
import { Select } from "../UI/Select";
import { Panel } from "../UI/Panel";
import { DriverShipmentPanel } from "../driver/DriverShipmentPanel";
import {
getOrderGroupDeliveryStatusLabel,
getOrderGroupDisplayStatusLabel,
getOrderGroupStatusTone,
} from "../../services/orderGroupViews";
const DELIVERY_TIME_OPTIONS = ["Первая половина дня", "Вторая половина дня"];
const WEEK_DAY_LABELS = ["ПН", "ВТ", "СР", "ЧТ", "ПТ", "СБ", "ВС"];
const DELIVERY_TIME_ALIASES = {
"До обеда": "Первая половина дня",
"После обеда": "Вторая половина дня",
};
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 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)
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) {
if (parsed[0].orderList && Array.isArray(parsed[0].orderList)) {
return parsed[0].orderList;
}
return parsed;
}
}
return [];
};
const getErrorMessage = (error, fallbackMessage) => {
if (!error) {
return fallbackMessage;
}
if (error instanceof Error) {
return error.message || fallbackMessage;
}
if (typeof error === "string") {
return error || fallbackMessage;
}
return error?.message || fallbackMessage;
};
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 isSelectableCalendarDate = (date, minDateKey) => {
const dateKey = toDateKey(date);
return dateKey >= minDateKey && !isWeekendDate(date);
};
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 orders = parseOrderList(order);
const totalPositions = orders.reduce((sum, o) => sum + (o.items?.length || 0), 0);
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)]">
{totalPositions > 0 ? `${totalPositions} поз.` : ''}
<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>
{orderItem.items && orderItem.items.length > 0 ? (
<div className="space-y-2">
{orderItem.items.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>
) : (
<p className="text-sm text-[var(--color-text-muted)]">Позиции не указаны</p>
)}
</div>
))
)}
</div>
)}
</div>
);
};
const PaidStoragePanel = ({ order, onChangeDeliveryStatus, isSavingDeliveryChoice, 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={isSavingDeliveryChoice}
>
Отменить платное хранение
</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={isSavingDeliveryChoice}
>
Да, перевести
</Button>
<Button
variant="secondary"
onClick={() => setShowConfirm(false)}
disabled={isSavingDeliveryChoice}
>
Отмена
</Button>
</div>
</div>
) : (
<Button
variant="secondary"
onClick={() => setShowConfirm(true)}
disabled={isSavingDeliveryChoice}
>
Перевести в платное хранение
</Button>
)}
</Panel>
);
};
export const OrderDetailPanel = ({
order,
canManageDelivery = false,
onSaveManualDeliveryChoice,
isSavingDeliveryChoice = false,
drivers = [],
onAssignDriver,
onChangeDeliveryStatus,
userRole,
}) => {
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 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 || "");
}, [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));
setFormMessage("");
}, [order?.id, order?.deliveryDate, order?.deliveryHalfDay, order?.deliveryTime]);
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 = (order.deliveryStatus || order.delivery_status) === "agreed";
const agreedDeliveryLabel = [
formatDeliveryDateDisplay(order.deliveryDate),
order.deliveryTime || order.deliveryHalfDay,
].filter((value) => value && value !== "Нет данных").join(" · ");
const handleShipmentChange = React.useCallback((state) => {
setShipmentState(state);
}, []);
const handleSaveDeliveryChoice = async () => {
if (!deliveryDate || !deliveryTime) {
setFormMessage("Укажите дату и половину дня доставки.");
return;
}
if (!isFutureDeliveryDate(deliveryDate)) {
setFormMessage("Выберите дату доставки позже сегодняшнего дня.");
return;
}
try {
const result = await onSaveManualDeliveryChoice?.({
orderGroupId: order.id,
deliveryDate,
deliveryTime,
});
if (result?.success) {
setFormMessage("Доставка согласована вручную.");
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)]">
Карточка группы доставки
</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)]">
{order.displaySubtitle || [order.customerPhone, order.customerDate].filter(Boolean).join(" · ") || "Не указано"}
</p>
</div>
<Badge tone={getOrderGroupStatusTone(order)}>{getOrderGroupDisplayStatusLabel(order)}</Badge>
</div>
<div className="grid gap-3 rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 md:grid-cols-3">
<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)]">{formatDeliveryDateDisplay(order.deliveryDate)}</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)]">{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)]">{order.assignedDriverId ? renderValue(order.assignedDriverName) : "Не назначен"}</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>
<div className="">
<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)]">{renderValue(order.deliveryAddress)}</p>
</div>
</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)]">{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>
<div>
<p className="text-xs text-[var(--color-text-muted)]">Не готово</p>
<p className="font-medium !text-[var(--color-text)]">{order.notReadyCount ?? 0}</p>
</div>
<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)]">Статус доставки</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>
{isDeliveryAgreed ? (
<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)]">
Доставка согласована
</p>
<p className="mt-1 text-lg font-semibold">
{agreedDeliveryLabel || "Дата и время сохранены"}
</p>
</div>
<Badge tone="accent">Согласовано</Badge>
</div>
</div>
) : (
<div className="flex flex-col gap-3 md:flex-row md:items-start md:relative md:z-10">
<div className="space-y-3 md:relative md:z-30 md:min-w-0 md:flex-1 md:pr-4">
<button
type="button"
aria-label="Дата доставки"
aria-expanded={isCalendarOpen}
className="flex min-h-[54px] w-full items-center justify-between rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 text-left text-sm font-medium !text-[var(--color-text)] transition hover:border-[var(--color-accent)] focus:border-[var(--color-accent)] focus:outline-none"
onClick={() => setIsCalendarOpen((current) => !current)}
>
<span>{formatDateForDisplay(deliveryDate)}</span>
<span aria-hidden="true" className="text-[var(--color-text-muted)]"></span>
</button>
{isCalendarOpen ? (
<div className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface)] p-4 shadow-soft md:absolute md:left-0 md:top-full md:z-50 md:mt-3 md:w-[min(460px,calc(100vw-3rem))]">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Календарь доставки
</p>
<h4
className="mt-1 text-base font-semibold capitalize"
style={{ color: "var(--color-text)" }}
>
{monthLabel}
</h4>
</div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={!canGoBack}
aria-label="Предыдущий месяц"
className="flex h-9 w-9 items-center justify-center rounded-full border border-[var(--color-border)] text-sm text-[var(--color-text-muted)] transition hover:border-[var(--color-accent)] hover:!text-[var(--color-text)] disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => setCurrentMonth((month) => addMonths(month, -1))}
>
</button>
<button
type="button"
aria-label="Следующий месяц"
className="flex h-9 w-9 items-center justify-center rounded-full border border-[var(--color-border)] text-sm text-[var(--color-text-muted)] transition hover:border-[var(--color-accent)] hover:!text-[var(--color-text)]"
onClick={() => setCurrentMonth((month) => addMonths(month, 1))}
>
</button>
</div>
</div>
<div className="mt-4 grid grid-cols-7 gap-1 text-center text-[10px] font-semibold uppercase text-[var(--color-text-muted)]">
{WEEK_DAY_LABELS.map((day) => (
<div key={day} className="px-1 py-1">
{day}
</div>
))}
</div>
<div className="mt-1 grid grid-cols-7 gap-1">
{calendarDays.map((day, index) => {
if (!day) {
return <div key={`empty-${index}`} className="aspect-square" />;
}
const dateKey = toDateKey(day);
const isWeekend = isWeekendDate(day);
const isSelectable = isSelectableCalendarDate(day, minSelectableDateKey);
const isSelected = dateKey === deliveryDate;
const isDisabled = !isSelectable;
const dayNumber = String(day.getDate()).padStart(2, "0");
return (
<button
key={dateKey}
type="button"
disabled={isDisabled}
title={isWeekend ? "Выходной, доставки нет" : isSelectable ? "Можно выбрать" : "Недоступно"}
className={[
"relative flex aspect-square items-center justify-center rounded-xl border text-sm font-semibold transition",
isSelected
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] !text-[var(--color-text)]"
: isWeekend
? "border-dashed border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)]"
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:!text-[var(--color-text)]",
isDisabled ? "cursor-not-allowed opacity-45" : "",
].join(" ")}
onClick={() => {
if (isDisabled) {
return;
}
setDeliveryDate(dateKey);
setFormMessage("");
setIsCalendarOpen(false);
}}
>
<span>{dayNumber}</span>
{isWeekend ? (
<span
aria-hidden="true"
className="absolute inset-x-2 top-1/2 h-px -rotate-12 bg-[var(--color-text-muted)] opacity-70"
/>
) : null}
</button>
);
})}
</div>
<p className="mt-2 text-xs text-[var(--color-text-muted)]">
Выходные отмечены пунктиром и недоступны.
</p>
</div>
) : null}
</div>
<div className="grid gap-2 sm:grid-cols-2 md:w-[320px] md:flex-none">
{DELIVERY_TIME_OPTIONS.map((option) => (
<button
key={option}
type="button"
aria-pressed={deliveryTime === option}
className={[
"min-h-[54px] rounded-2xl border px-4 text-left text-sm font-medium transition",
deliveryTime === option
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] !text-[var(--color-text)]"
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:!text-[var(--color-text)]",
].join(" ")}
onClick={() => {
setDeliveryTime(option);
setFormMessage("");
}}
>
{option}
</button>
))}
</div>
<Button
className="w-full md:w-[180px] md:flex-none md:self-start"
onClick={handleSaveDeliveryChoice}
disabled={isSavingDeliveryChoice}
>
{isSavingDeliveryChoice ? "Сохраняем..." : "Согласовать"}
</Button>
</div>
)}
{formMessage ? (
<p className="text-sm text-[var(--color-text-muted)]">{formMessage}</p>
) : null}
</Panel>
) : null}
{canManageDelivery && ["manager", "logistician", "admin"].includes(userRole) ? (
<Panel className="space-y-4 p-5">
<div>
<strong>Назначение водителя</strong>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
{order.assignedDriverId
? `Назначен водитель: ${order.assignedDriverName || "Неизвестно"}. Вы можете изменить назначение.`
: "Выберите водителя для доставки."}
</p>
</div>
<div className="grid gap-3 md:grid-cols-[minmax(16rem,24rem)_auto]">
<Select
className="h-[46px] py-0"
value={selectedDriverId}
onChange={(e) => {
setSelectedDriverId(e.target.value);
setDriverMessage("");
}}
disabled={isSavingDeliveryChoice}
>
<option value="">{order.assignedDriverId ? "Сменить водителя..." : "Выберите водителя..."}</option>
{drivers.map((driver) => (
<option key={driver.id} value={driver.id}>{driver.name || driver.email}</option>
))}
</Select>
<Button
className="md:px-4 md:py-2 md:whitespace-nowrap md:self-start"
onClick={handleAssignDriver}
disabled={isSavingDeliveryChoice || !selectedDriverId}
>
{isSavingDeliveryChoice ? "Назначаем..." : "Назначить"}
</Button>
</div>
{driverMessage ? (
<p className="text-sm text-[var(--color-text-muted)]">{driverMessage}</p>
) : null}
</Panel>
) : null}
{["manager", "logistician", "admin"].includes(userRole) && 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>
<div className="flex flex-wrap gap-2">
{[
{ value: "pending_confirmation", label: "Ожидает согласования" },
{ value: "agreed", label: "Согласовано" },
{ value: "driver_assigned", label: "Назначен водитель" },
{ value: "loaded", label: "Загружено" },
{ value: "on_route", label: "В пути" },
{ value: "delivered", label: "Доставлено" },
{ value: "problem", label: "Проблема" },
{ value: "cancelled", label: "Отменено" },
].map((statusOption) => (
<Button
key={statusOption.value}
variant={
(order.deliveryStatus || order.delivery_status) === statusOption.value ? "primary" : "secondary"}
onClick={() => {
onChangeDeliveryStatus({
orderGroupId: order.id,
status: statusOption.value,
}).then((response) => {
if (!response.success) {
setFormMessage(response.error || "Не удалось обновить статус");
} else {
setFormMessage("");
}
});
}}
disabled={isSavingDeliveryChoice}
>
{statusOption.label}
</Button>
))}
</div>
</Panel>
) : null}
{["manager", "logistician", "admin"].includes(userRole) && order && onChangeDeliveryStatus ? (
<PaidStoragePanel
order={order}
onChangeDeliveryStatus={onChangeDeliveryStatus}
isSavingDeliveryChoice={isSavingDeliveryChoice}
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>
<div className="flex flex-wrap gap-2">
{[
{ value: "loaded", label: "Загружено" },
{ value: "on_route", label: "В пути" },
{ value: "delivered", label: "Доставлено" },
{ value: "problem", label: "Проблема" },
].map((statusOption) => (
<Button
key={statusOption.value}
variant={
(order.deliveryStatus || order.delivery_status) === statusOption.value ? "primary" : "secondary"}
onClick={() => {
if (statusOption.value === "delivered" && shipmentState && !shipmentState.canMarkDelivered) {
setFormMessage("Укажите причину для каждой неотгруженной позиции перед завершением доставки.");
return;
}
onChangeDeliveryStatus({
orderGroupId: order.id,
status: statusOption.value,
}).then((response) => {
if (!response.success) {
setFormMessage(response.error || "Не удалось обновить статус");
} else {
setFormMessage("");
}
});
}}
disabled={isSavingDeliveryChoice}
>
{statusOption.label}
</Button>
))}
</div>
{formMessage ? (
<p className="text-sm text-[var(--color-warning)]">{formMessage}</p>
) : null}
</Panel>
) : null}
<Panel className="space-y-4 p-5">
<strong>Номера заказов</strong>
{renderList(order.orderNumbers)}
</Panel>
<Panel className="space-y-4 p-5">
<CollapsibleOrderComposition order={order} />
</Panel>
{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}
</div>
);
};