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 { 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 { CalendarWidget } from "./CalendarWidget";
|
||||
import { StatusActionPanel } from "./StatusActionPanel";
|
||||
import { DriverAssignmentPanel } from "./DriverAssignmentPanel";
|
||||
import { supabase } from "../../supabaseClient";
|
||||
import {
|
||||
getOrderGroupDeliveryStatusLabel,
|
||||
|
|
@ -55,7 +57,6 @@ import {
|
|||
import { getErrorMessage, normalizeNom } from "../../utils/deliveryUtils";
|
||||
|
||||
const DELIVERY_TIME_OPTIONS = ["Первая половина дня", "Вторая половина дня"];
|
||||
const WEEK_DAY_LABELS = ["ПН", "ВТ", "СР", "ЧТ", "ПТ", "СБ", "ВС"];
|
||||
const STATUS_LABELS = DELIVERY_GROUP_STATUS_LABELS;
|
||||
|
||||
const ConfirmModal = ({ open, title, message, onConfirm, onCancel }) => {
|
||||
|
|
@ -253,11 +254,6 @@ const isFutureDeliveryDate = (value) => {
|
|||
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 "Выберите дату";
|
||||
|
|
@ -950,193 +946,45 @@ export const OrderDetailPanel = ({
|
|||
) : null}
|
||||
</div>
|
||||
) : deliveryType === "delivery" ? (
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:relative md:z-10">
|
||||
<div className="relative space-y-3 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-[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)]">
|
||||
Календарь доставки
|
||||
</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>
|
||||
<CalendarWidget
|
||||
label="Календарь доставки"
|
||||
selectedDate={deliveryDate}
|
||||
onDateChange={(dateKey) => { setDeliveryDate(dateKey); setFormMessage(""); }}
|
||||
minDateKey={minSelectableDateKey}
|
||||
isCalendarOpen={isCalendarOpen}
|
||||
setIsCalendarOpen={setIsCalendarOpen}
|
||||
currentMonth={currentMonth}
|
||||
setCurrentMonth={setCurrentMonth}
|
||||
calendarDays={calendarDays}
|
||||
monthLabel={monthLabel}
|
||||
canGoBack={canGoBack}
|
||||
timeOptions={DELIVERY_TIME_OPTIONS}
|
||||
selectedTime={deliveryTime}
|
||||
onTimeChange={(option) => { setDeliveryTime(option); setFormMessage(""); }}
|
||||
layoutClassName="flex flex-col gap-3 md:flex-row md:items-start md:relative md:z-10"
|
||||
calendarClassName="relative space-y-3 md:min-w-0 md:flex-1 md:pr-4"
|
||||
timeClassName="grid gap-2 sm:grid-cols-2 md:w-[320px] md:flex-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<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-[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)]">Календарь самовывоза</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>
|
||||
<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>
|
||||
<CalendarWidget
|
||||
label="Календарь самовывоза"
|
||||
selectedDate={pickupDate}
|
||||
onDateChange={(dateKey) => { setPickupDate(dateKey); setFormMessage(""); }}
|
||||
minDateKey={minSelectableDateKey}
|
||||
isCalendarOpen={isCalendarOpen}
|
||||
setIsCalendarOpen={setIsCalendarOpen}
|
||||
currentMonth={currentMonth}
|
||||
setCurrentMonth={setCurrentMonth}
|
||||
calendarDays={calendarDays}
|
||||
monthLabel={monthLabel}
|
||||
canGoBack={canGoBack}
|
||||
timeOptions={DELIVERY_TIME_OPTIONS}
|
||||
selectedTime={pickupTimeSlot}
|
||||
onTimeChange={(option) => { setPickupTimeSlot(option); setFormMessage(""); }}
|
||||
layoutClassName="space-y-3"
|
||||
calendarClassName="relative"
|
||||
timeClassName="grid gap-2 sm:grid-cols-2"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
className="w-full md:w-[180px] md:flex-none md:self-start"
|
||||
|
|
@ -1152,119 +1000,34 @@ export const OrderDetailPanel = ({
|
|||
) : null}
|
||||
|
||||
|
||||
{canManageDelivery && ["manager", "logistician", "admin", "mega_admin"].includes(userRole) && !isPickupOrder ? (
|
||||
<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={() => 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}
|
||||
<DriverAssignmentPanel
|
||||
order={order}
|
||||
userRole={userRole}
|
||||
canManageDelivery={canManageDelivery}
|
||||
isSavingDeliveryChoice={isSavingDeliveryChoice}
|
||||
selectedDriverId={selectedDriverId}
|
||||
onDriverSelect={(id) => { setSelectedDriverId(id); setDriverMessage(""); }}
|
||||
onConfirmDriver={() => setConfirmAction({ type: 'driver' })}
|
||||
driverMessage={driverMessage}
|
||||
drivers={drivers}
|
||||
/>
|
||||
|
||||
|
||||
{["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;
|
||||
}
|
||||
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>
|
||||
<StatusActionPanel
|
||||
order={order}
|
||||
userRole={userRole}
|
||||
canManageDelivery={canManageDelivery}
|
||||
isSavingDeliveryChoice={isSavingDeliveryChoice}
|
||||
onConfirmStatus={(action) => {
|
||||
if (action.type === "hint") {
|
||||
setFormMessage(action.hint);
|
||||
} else if (action.type === "status") {
|
||||
setConfirmAction({ type: "status", status: action.status });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{formMessage && ["manager", "logistician", "admin", "mega_admin"].includes(userRole) && order && onChangeDeliveryStatus ? (
|
||||
<p className="text-sm text-[var(--color-warning)]">{formMessage}</p>
|
||||
) : null}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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