refactor: Phase 2 decomposition — extract CalendarWidget, StatusActionPanel, DriverAssignmentPanel
- CalendarWidget: eliminates duplicated calendar JSX (delivery + pickup variants) - StatusActionPanel: status action buttons with onConfirmStatus callback - DriverAssignmentPanel: driver selection with onDriverSelect/onConfirmDriver callbacks - OrderDetailPanel reduced from 1510 to 1261 lines - No UI changes — all props passed through, all class names preserved
This commit is contained in:
parent
4dde64ff5a
commit
129175fed7
|
|
@ -0,0 +1,185 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const WEEK_DAY_LABELS = ["ПН", "ВТ", "СР", "ЧТ", "ПТ", "СБ", "ВС"];
|
||||||
|
|
||||||
|
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 isWeekendDate = (date) => {
|
||||||
|
const day = date.getDay();
|
||||||
|
return day === 0 || day === 6;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 CalendarWidget = ({
|
||||||
|
label,
|
||||||
|
selectedDate,
|
||||||
|
onDateChange,
|
||||||
|
minDateKey,
|
||||||
|
isCalendarOpen,
|
||||||
|
setIsCalendarOpen,
|
||||||
|
currentMonth,
|
||||||
|
setCurrentMonth,
|
||||||
|
calendarDays,
|
||||||
|
monthLabel,
|
||||||
|
canGoBack,
|
||||||
|
timeOptions,
|
||||||
|
selectedTime,
|
||||||
|
onTimeChange,
|
||||||
|
layoutClassName,
|
||||||
|
calendarClassName,
|
||||||
|
timeClassName,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={layoutClassName}>
|
||||||
|
<div className={calendarClassName}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={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>{selectedDate ? formatDateForDisplay(selectedDate) : "Выберите дату"}</span>
|
||||||
|
<span aria-hidden="true" className="text-[var(--color-text-muted)]">▾</span>
|
||||||
|
</button>
|
||||||
|
{isCalendarOpen ? (
|
||||||
|
<div className="rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-3 shadow-lg absolute left-0 top-full z-50 mt-2 w-[300px]">
|
||||||
|
<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)]">
|
||||||
|
{label}
|
||||||
|
</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) => new Date(month.getFullYear(), month.getMonth() - 1, 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) => new Date(month.getFullYear(), month.getMonth() + 1, 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, minDateKey);
|
||||||
|
const isSelected = dateKey === selectedDate;
|
||||||
|
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;
|
||||||
|
onDateChange(dateKey);
|
||||||
|
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={timeClassName}>
|
||||||
|
{timeOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={selectedTime === option}
|
||||||
|
className={[
|
||||||
|
"min-h-[54px] rounded-2xl border px-4 text-left text-sm font-medium transition",
|
||||||
|
selectedTime === 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={() => onTimeChange(option)}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { CalendarWidget, formatDateForDisplay };
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Badge } from "../UI/Badge";
|
||||||
|
import { Button } from "../UI/Button";
|
||||||
|
import { Select } from "../UI/Select";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
|
||||||
|
const DriverAssignmentPanel = ({
|
||||||
|
order,
|
||||||
|
userRole,
|
||||||
|
canManageDelivery,
|
||||||
|
isSavingDeliveryChoice,
|
||||||
|
selectedDriverId,
|
||||||
|
onDriverSelect,
|
||||||
|
onConfirmDriver,
|
||||||
|
driverMessage,
|
||||||
|
drivers,
|
||||||
|
}) => {
|
||||||
|
if (!canManageDelivery || !["manager", "logistician", "admin", "mega_admin"].includes(userRole) || !order) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPickupOrder = order.deliveryType === "pickup" || order.deliveryStatus === "pickup" || order.delivery_status === "pickup";
|
||||||
|
if (isPickupOrder) return null;
|
||||||
|
|
||||||
|
const ds = order.deliveryStatus || order.delivery_status;
|
||||||
|
const isDriverLocked = ["loaded", "on_route", "delivered"].includes(ds);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel className="space-y-4 p-5">
|
||||||
|
<div>
|
||||||
|
<strong>Назначение водителя</strong>
|
||||||
|
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
|
{(() => {
|
||||||
|
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}
|
||||||
|
{!isDriverLocked ? (
|
||||||
|
<div className="grid gap-3 md:grid-cols-[minmax(16rem,24rem)_auto]">
|
||||||
|
<Select
|
||||||
|
className="h-[46px] py-0"
|
||||||
|
value={selectedDriverId}
|
||||||
|
onChange={(e) => {
|
||||||
|
onDriverSelect(e.target.value);
|
||||||
|
}}
|
||||||
|
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={onConfirmDriver}
|
||||||
|
disabled={isSavingDeliveryChoice || !selectedDriverId}
|
||||||
|
>
|
||||||
|
{isSavingDeliveryChoice ? "Назначаем..." : "Назначить"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{driverMessage ? (
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">{driverMessage}</p>
|
||||||
|
) : null}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { DriverAssignmentPanel };
|
||||||
|
|
@ -42,9 +42,11 @@ import React from "react";
|
||||||
import { formatDateTime } from "../../utils/formatters";
|
import { formatDateTime } from "../../utils/formatters";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
import { Button } from "../UI/Button";
|
import { Button } from "../UI/Button";
|
||||||
import { Select } from "../UI/Select";
|
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
import { DriverShipmentPanel } from "../driver/DriverShipmentPanel";
|
import { DriverShipmentPanel } from "../driver/DriverShipmentPanel";
|
||||||
|
import { CalendarWidget } from "./CalendarWidget";
|
||||||
|
import { StatusActionPanel } from "./StatusActionPanel";
|
||||||
|
import { DriverAssignmentPanel } from "./DriverAssignmentPanel";
|
||||||
import { supabase } from "../../supabaseClient";
|
import { supabase } from "../../supabaseClient";
|
||||||
import {
|
import {
|
||||||
getOrderGroupDeliveryStatusLabel,
|
getOrderGroupDeliveryStatusLabel,
|
||||||
|
|
@ -55,7 +57,6 @@ import {
|
||||||
import { getErrorMessage, normalizeNom } from "../../utils/deliveryUtils";
|
import { getErrorMessage, normalizeNom } from "../../utils/deliveryUtils";
|
||||||
|
|
||||||
const DELIVERY_TIME_OPTIONS = ["Первая половина дня", "Вторая половина дня"];
|
const DELIVERY_TIME_OPTIONS = ["Первая половина дня", "Вторая половина дня"];
|
||||||
const WEEK_DAY_LABELS = ["ПН", "ВТ", "СР", "ЧТ", "ПТ", "СБ", "ВС"];
|
|
||||||
const STATUS_LABELS = DELIVERY_GROUP_STATUS_LABELS;
|
const STATUS_LABELS = DELIVERY_GROUP_STATUS_LABELS;
|
||||||
|
|
||||||
const ConfirmModal = ({ open, title, message, onConfirm, onCancel }) => {
|
const ConfirmModal = ({ open, title, message, onConfirm, onCancel }) => {
|
||||||
|
|
@ -253,11 +254,6 @@ const isFutureDeliveryDate = (value) => {
|
||||||
return !isWeekendDate(parsedDate) && toDateKey(parsedDate) >= getNextSelectableDateKey();
|
return !isWeekendDate(parsedDate) && toDateKey(parsedDate) >= getNextSelectableDateKey();
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSelectableCalendarDate = (date, minDateKey) => {
|
|
||||||
const dateKey = toDateKey(date);
|
|
||||||
return dateKey >= minDateKey && !isWeekendDate(date);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDateForDisplay = (value) => {
|
const formatDateForDisplay = (value) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return "Выберите дату";
|
return "Выберите дату";
|
||||||
|
|
@ -950,193 +946,45 @@ export const OrderDetailPanel = ({
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : deliveryType === "delivery" ? (
|
) : deliveryType === "delivery" ? (
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:relative md:z-10">
|
<CalendarWidget
|
||||||
<div className="relative space-y-3 md:min-w-0 md:flex-1 md:pr-4">
|
label="Календарь доставки"
|
||||||
<button
|
selectedDate={deliveryDate}
|
||||||
type="button"
|
onDateChange={(dateKey) => { setDeliveryDate(dateKey); setFormMessage(""); }}
|
||||||
aria-label="Дата доставки"
|
minDateKey={minSelectableDateKey}
|
||||||
aria-expanded={isCalendarOpen}
|
isCalendarOpen={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"
|
setIsCalendarOpen={setIsCalendarOpen}
|
||||||
onClick={() => setIsCalendarOpen((current) => !current)}
|
currentMonth={currentMonth}
|
||||||
>
|
setCurrentMonth={setCurrentMonth}
|
||||||
<span>{formatDateForDisplay(deliveryDate)}</span>
|
calendarDays={calendarDays}
|
||||||
<span aria-hidden="true" className="text-[var(--color-text-muted)]">▾</span>
|
monthLabel={monthLabel}
|
||||||
</button>
|
canGoBack={canGoBack}
|
||||||
{isCalendarOpen ? (
|
timeOptions={DELIVERY_TIME_OPTIONS}
|
||||||
<div className="rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-3 shadow-lg absolute left-0 top-full z-50 mt-2 w-[300px]">
|
selectedTime={deliveryTime}
|
||||||
<div className="flex items-center justify-between gap-3">
|
onTimeChange={(option) => { setDeliveryTime(option); setFormMessage(""); }}
|
||||||
<div>
|
layoutClassName="flex flex-col gap-3 md:flex-row md:items-start md:relative md:z-10"
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
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"
|
||||||
</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">
|
<CalendarWidget
|
||||||
<div className="relative">
|
label="Календарь самовывоза"
|
||||||
<button
|
selectedDate={pickupDate}
|
||||||
type="button"
|
onDateChange={(dateKey) => { setPickupDate(dateKey); setFormMessage(""); }}
|
||||||
aria-label="Дата самовывоза"
|
minDateKey={minSelectableDateKey}
|
||||||
aria-expanded={isCalendarOpen}
|
isCalendarOpen={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"
|
setIsCalendarOpen={setIsCalendarOpen}
|
||||||
onClick={() => setIsCalendarOpen((current) => !current)}
|
currentMonth={currentMonth}
|
||||||
>
|
setCurrentMonth={setCurrentMonth}
|
||||||
<span>{pickupDate ? formatDateForDisplay(pickupDate) : "Выберите дату"}</span>
|
calendarDays={calendarDays}
|
||||||
<span aria-hidden="true" className="text-[var(--color-text-muted)]">▾</span>
|
monthLabel={monthLabel}
|
||||||
</button>
|
canGoBack={canGoBack}
|
||||||
{isCalendarOpen ? (
|
timeOptions={DELIVERY_TIME_OPTIONS}
|
||||||
<div className="rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-3 shadow-lg absolute left-0 top-full z-50 mt-2 w-[300px]">
|
selectedTime={pickupTimeSlot}
|
||||||
<div className="flex items-center justify-between gap-3">
|
onTimeChange={(option) => { setPickupTimeSlot(option); setFormMessage(""); }}
|
||||||
<div>
|
layoutClassName="space-y-3"
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">Календарь самовывоза</p>
|
calendarClassName="relative"
|
||||||
<h4 className="mt-1 text-base font-semibold capitalize" style={{ color: "var(--color-text)" }}>{monthLabel}</h4>
|
timeClassName="grid gap-2 sm:grid-cols-2"
|
||||||
</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>
|
|
||||||
<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
|
<Button
|
||||||
className="w-full md:w-[180px] md:flex-none md:self-start"
|
className="w-full md:w-[180px] md:flex-none md:self-start"
|
||||||
|
|
@ -1152,119 +1000,34 @@ export const OrderDetailPanel = ({
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
||||||
{canManageDelivery && ["manager", "logistician", "admin", "mega_admin"].includes(userRole) && !isPickupOrder ? (
|
<DriverAssignmentPanel
|
||||||
<Panel className="space-y-4 p-5">
|
order={order}
|
||||||
<div>
|
userRole={userRole}
|
||||||
<strong>Назначение водителя</strong>
|
canManageDelivery={canManageDelivery}
|
||||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
isSavingDeliveryChoice={isSavingDeliveryChoice}
|
||||||
{(() => {
|
selectedDriverId={selectedDriverId}
|
||||||
const ds = order.deliveryStatus || order.delivery_status;
|
onDriverSelect={(id) => { setSelectedDriverId(id); setDriverMessage(""); }}
|
||||||
if (["loaded", "on_route", "delivered"].includes(ds)) {
|
onConfirmDriver={() => setConfirmAction({ type: 'driver' })}
|
||||||
return "Доставка в процессе — сменить водителя нельзя.";
|
driverMessage={driverMessage}
|
||||||
}
|
drivers={drivers}
|
||||||
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={() => setConfirmAction({ type: 'driver' })}
|
|
||||||
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 ? (
|
<StatusActionPanel
|
||||||
<Panel className="space-y-4 p-5">
|
order={order}
|
||||||
<div>
|
userRole={userRole}
|
||||||
<strong>Статус доставки</strong>
|
canManageDelivery={canManageDelivery}
|
||||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
isSavingDeliveryChoice={isSavingDeliveryChoice}
|
||||||
Измените статус, если водитель забыл обновить или нужна корректировка.
|
onConfirmStatus={(action) => {
|
||||||
</p>
|
if (action.type === "hint") {
|
||||||
</div>
|
setFormMessage(action.hint);
|
||||||
<div className="flex flex-wrap gap-2">
|
} else if (action.type === "status") {
|
||||||
{[
|
setConfirmAction({ type: "status", status: action.status });
|
||||||
{ value: "pending_confirmation", label: "Ожидает согласования", manual: true },
|
}
|
||||||
{ value: "agreed", label: "Согласовано", manual: false, hint: "Согласуйте дату доставки выше" },
|
}}
|
||||||
{ value: "driver_assigned", label: "Назначен водитель", manual: false, hint: "Назначьте водителя из списка" },
|
/>
|
||||||
{ value: "loaded", label: "Загружено", manual: true },
|
{formMessage && ["manager", "logistician", "admin", "mega_admin"].includes(userRole) && order && onChangeDeliveryStatus ? (
|
||||||
{ value: "on_route", label: "В пути", manual: true },
|
<p className="text-sm text-[var(--color-warning)]">{formMessage}</p>
|
||||||
{ 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;
|
|
||||||
}
|
|
||||||
setConfirmAction({ type: 'status', status: statusOption.value });
|
|
||||||
}}
|
|
||||||
disabled={isSavingDeliveryChoice}
|
|
||||||
>
|
|
||||||
{statusOption.label}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{formMessage ? (
|
|
||||||
<p className="text-sm text-[var(--color-warning)]">{formMessage}</p>
|
|
||||||
) : null}
|
|
||||||
</Panel>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Badge } from "../UI/Badge";
|
||||||
|
import { Button } from "../UI/Button";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
import { DELIVERY_GROUP_STATUS_LABELS } from "../../services/orderGroupViews";
|
||||||
|
|
||||||
|
const STATUS_LABELS = DELIVERY_GROUP_STATUS_LABELS;
|
||||||
|
|
||||||
|
const StatusActionPanel = ({
|
||||||
|
order,
|
||||||
|
userRole,
|
||||||
|
canManageDelivery,
|
||||||
|
isSavingDeliveryChoice,
|
||||||
|
onConfirmStatus,
|
||||||
|
}) => {
|
||||||
|
if (!canManageDelivery || !["manager", "logistician", "admin", "mega_admin"].includes(userRole) || !order) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentStatus = order.deliveryStatus || order.delivery_status;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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 = currentStatus === statusOption.value;
|
||||||
|
const isClickable = statusOption.manual !== false && !isCurrent;
|
||||||
|
return (
|
||||||
|
<div key={statusOption.value} className="relative group">
|
||||||
|
<Button
|
||||||
|
variant={isCurrent ? "primary" : "secondary"}
|
||||||
|
onClick={() => {
|
||||||
|
if (!isClickable) {
|
||||||
|
onConfirmStatus?.({ type: "hint", hint: statusOption.hint || "" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onConfirmStatus?.({ type: "status", status: statusOption.value });
|
||||||
|
}}
|
||||||
|
disabled={isSavingDeliveryChoice}
|
||||||
|
>
|
||||||
|
{statusOption.label}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { StatusActionPanel };
|
||||||
Loading…
Reference in New Issue