1246 lines
54 KiB
JavaScript
1246 lines
54 KiB
JavaScript
|
||
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>
|
||
);
|
||
};
|