969 lines
40 KiB
JavaScript
969 lines
40 KiB
JavaScript
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>
|
||
);
|
||
};
|