1419 lines
66 KiB
JavaScript
1419 lines
66 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 { Select } from "../UI/Select";
|
||
import { Panel } from "../UI/Panel";
|
||
import { DriverShipmentPanel } from "../driver/DriverShipmentPanel";
|
||
import { supabase } from "../../supabaseClient";
|
||
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 normalizeNom = (nom) => {
|
||
if (!nom) return '';
|
||
// 1C escapes backslashes: "СФ Т\\ЕА-33584" → normalize for comparison
|
||
return String(nom).replace(/\\\\/g, '\\').trim();
|
||
};
|
||
|
||
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 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 matchesStopWord = (name, stopWords) => {
|
||
if (!stopWords || !stopWords.length) return false;
|
||
const lower = name.toLowerCase();
|
||
return stopWords.some((sw) => lower.includes(sw.toLowerCase()));
|
||
};
|
||
|
||
const useStopWords = () => {
|
||
const [stopWords, setStopWords] = React.useState([]);
|
||
const [active, setActive] = React.useState(true);
|
||
React.useEffect(() => {
|
||
if (!supabase) return;
|
||
Promise.all([
|
||
supabase.from("stop_words").select("word").then(r => r.data || []),
|
||
supabase.from("stop_words_scope").select("scope").eq("id", 1).single().then(r => r.data),
|
||
]).then(([words, scopeRow]) => {
|
||
setStopWords(words.map((d) => d.word));
|
||
setActive(scopeRow?.scope !== "client_only");
|
||
});
|
||
}, []);
|
||
return { stopWords, active };
|
||
};
|
||
|
||
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, 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>
|
||
);
|
||
};
|
||
|
||
|
||
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,
|
||
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?.deliveryAddress || order?.customerAddress || "");
|
||
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));
|
||
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"].includes(order.deliveryStatus || order.delivery_status);
|
||
const canEditDelivery = canManageDelivery && ["admin", "mega_admin", "logistician"].includes(userRole);
|
||
const [isEditingDate, setIsEditingDate] = React.useState(false);
|
||
const agreedDeliveryLabel = [
|
||
formatDeliveryDateDisplay(order.deliveryDate),
|
||
order.deliveryTime || order.deliveryHalfDay,
|
||
].filter((value) => value && value !== "Нет данных").join(" · ");
|
||
|
||
const handleShipmentChange = React.useCallback((state) => {
|
||
setShipmentState(state);
|
||
}, []);
|
||
|
||
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.trim() ? { 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)]">
|
||
{(order.deliveryType === "pickup" || order.deliveryStatus === "pickup" || order.delivery_status === "pickup") ? "Карточка группы самовывоза" : "Карточка группы доставки"}
|
||
</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 = order.deliveryType === "pickup" || order.deliveryStatus === "pickup" || order.delivery_status === "pickup";
|
||
const deliveryTypeLabel = isPickup
|
||
? "Самовывоз"
|
||
: (order.deliveryStatus === "requires_address" || order.delivery_status === "requires_address")
|
||
? "Доставка (требуется адрес)"
|
||
: "Доставка";
|
||
const dateLabel = isPickup ? "Дата самовывоза" : "Дата доставки";
|
||
const timeLabel = isPickup ? "Время самовывоза" : "Время доставки";
|
||
const addressLabel = isPickup ? "Адрес самовывоза" : "Адрес доставки";
|
||
// For pickup orders, hide the delivery address if it's just a placeholder like "Самовывоз"
|
||
const effectiveAddress = isPickup
|
||
? (order.deliveryAddress && order.deliveryAddress !== "Самовывоз" && order.deliveryAddress !== "самовывоз" ? order.deliveryAddress : "")
|
||
: order.deliveryAddress;
|
||
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>
|
||
{(order.deliveryStatus === "requires_address" || order.delivery_status === "requires_address") && (
|
||
<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>
|
||
<span className="font-bold">{mainNumbers.join(", ")}</span>
|
||
{extraNumbers.length > 0 && (
|
||
<span className="text-[var(--color-text-muted)]"> +{extraNumbers.length} сч.</span>
|
||
)}
|
||
</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>
|
||
<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)]">{(order.deliveryType === "pickup" || order.deliveryStatus === "pickup" || order.delivery_status === "pickup") ? "Статус самовывоза" : "Статус доставки"}</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>
|
||
)}
|
||
{isDeliveryAgreed && !isEditingDate ? (
|
||
<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)]">
|
||
{(order.deliveryType === "pickup" || order.deliveryStatus === "pickup" || order.delivery_status === "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"
|
||
>
|
||
Изменить {(order.deliveryType === "pickup" || order.deliveryStatus === "pickup" || order.delivery_status === "pickup") ? "дату самовывоза" : "дату доставки"}
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
) : deliveryType === "delivery" ? (
|
||
<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>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
<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>{pickupDate ? formatDateForDisplay(pickupDate) : "Выберите дату"}</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:relative md:z-50">
|
||
<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 === pickupDate;
|
||
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) { setPickupDate(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 className="grid gap-2 sm:grid-cols-2">
|
||
{DELIVERY_TIME_OPTIONS.map((option) => (
|
||
<button key={option} type="button" aria-pressed={pickupTimeSlot === option} className={["min-h-[54px] rounded-2xl border px-4 text-left text-sm font-medium transition", pickupTimeSlot === 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={() => { setPickupTimeSlot(option); setFormMessage(""); }}>
|
||
{option}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<Button
|
||
className="w-full md:w-[180px] md:flex-none md:self-start"
|
||
onClick={handleSaveDeliveryChoice}
|
||
disabled={isSavingDeliveryChoice}
|
||
>
|
||
{isSavingDeliveryChoice ? "Сохраняем..." : "Согласовать"}
|
||
</Button>
|
||
{formMessage ? (
|
||
<p className="text-sm text-[var(--color-text-muted)]">{formMessage}</p>
|
||
) : null}
|
||
</Panel>
|
||
) : null}
|
||
|
||
|
||
{canManageDelivery && ["manager", "logistician", "admin", "mega_admin"].includes(userRole) ? (
|
||
<Panel className="space-y-4 p-5">
|
||
<div>
|
||
<strong>Назначение водителя</strong>
|
||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||
{(() => {
|
||
const ds = order.deliveryStatus || order.delivery_status;
|
||
if (["loaded", "on_route", "delivered"].includes(ds)) {
|
||
return "Доставка в процессе — сменить водителя нельзя.";
|
||
}
|
||
return order.assignedDriverId
|
||
? "Назначен водитель. Вы можете изменить назначение."
|
||
: "Выберите водителя для доставки.";
|
||
})()}
|
||
</p>
|
||
</div>
|
||
{order.assignedDriverId ? (
|
||
<div className="rounded-[24px] border border-[rgba(59,130,246,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">
|
||
{order.assignedDriverName || "Неизвестно"}
|
||
</p>
|
||
</div>
|
||
<Badge tone="accent">Назначен</Badge>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{(() => {
|
||
const ds = order.deliveryStatus || order.delivery_status;
|
||
const isDriverLocked = ["loaded", "on_route", "delivered"].includes(ds);
|
||
return !isDriverLocked ? (
|
||
<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>
|
||
) : null;
|
||
})()}
|
||
{driverMessage ? (
|
||
<p className="text-sm text-[var(--color-text-muted)]">{driverMessage}</p>
|
||
) : null}
|
||
</Panel>
|
||
) : null}
|
||
|
||
|
||
{["manager", "logistician", "admin", "mega_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: "Ожидает согласования", manual: true },
|
||
{ value: "agreed", label: "Согласовано", manual: false, hint: "Согласуйте дату доставки выше" },
|
||
{ value: "driver_assigned", label: "Назначен водитель", manual: false, hint: "Назначьте водителя из списка" },
|
||
{ value: "loaded", label: "Загружено", manual: true },
|
||
{ value: "on_route", label: "В пути", manual: true },
|
||
{ value: "delivered", label: "Доставлено", manual: true },
|
||
{ value: "pickup", label: "Самовывоз", manual: true },
|
||
{ value: "requires_address", label: "Требуется адрес", manual: true },
|
||
{ value: "problem", label: "Проблема", manual: true },
|
||
{ value: "cancelled", label: "Отменено", manual: true },
|
||
].map((statusOption) => {
|
||
const isCurrent = (order.deliveryStatus || order.delivery_status) === statusOption.value;
|
||
const isClickable = statusOption.manual !== false && !isCurrent;
|
||
return (
|
||
<div key={statusOption.value} className="relative group">
|
||
<Button
|
||
variant={isCurrent ? "primary" : "secondary"}
|
||
onClick={() => {
|
||
if (!isClickable) {
|
||
setFormMessage(statusOption.hint || "");
|
||
return;
|
||
}
|
||
setFormMessage("");
|
||
onChangeDeliveryStatus({
|
||
orderGroupId: order.id,
|
||
status: statusOption.value,
|
||
}).then((response) => {
|
||
if (!response.success) {
|
||
setFormMessage(response.error || "Не удалось обновить статус");
|
||
} else {
|
||
setFormMessage("");
|
||
}
|
||
});
|
||
}}
|
||
disabled={isSavingDeliveryChoice}
|
||
>
|
||
{statusOption.label}
|
||
</Button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
{formMessage ? (
|
||
<p className="text-sm text-[var(--color-warning)]">{formMessage}</p>
|
||
) : null}
|
||
</Panel>
|
||
) : null}
|
||
|
||
|
||
{["manager", "logistician", "admin", "mega_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>
|
||
{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 === "problem" || currentStatus === "cancelled" || currentStatus === "paid_storage") {
|
||
statusOptions = [];
|
||
} else {
|
||
statusOptions = [
|
||
{ value: "delivered", 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={isSavingDeliveryChoice}
|
||
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}
|
||
</div>
|
||
);
|
||
};
|