feat(orders): tabbed navigation for logistics blocks (Все + per-block tabs)
This commit is contained in:
parent
811e2a1254
commit
1f8a8ea2c4
|
|
@ -691,6 +691,16 @@ export const OrderDetailPanel = ({
|
|||
} catch {}
|
||||
return DEFAULT_BLOCK_ORDER;
|
||||
});
|
||||
const [activeTab, setActiveTab] = React.useState(() => {
|
||||
try {
|
||||
return localStorage.getItem("supersam-active-tab") || "all";
|
||||
} catch {}
|
||||
return "all";
|
||||
});
|
||||
const handleTabChange = (tab) => {
|
||||
setActiveTab(tab);
|
||||
try { localStorage.setItem("supersam-active-tab", tab); } catch {}
|
||||
};
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
|
||||
);
|
||||
|
|
@ -886,6 +896,353 @@ export const OrderDetailPanel = ({
|
|||
}
|
||||
};
|
||||
|
||||
// Render the inner content of a block by blockKey (used in both DnD "all" mode and single-tab mode)
|
||||
const renderBlockContent = (blockKey) => {
|
||||
if (blockKey === "manual_confirmation" && canManageDelivery) {
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
{isDeliveryAgreed
|
||||
? "Дата и время уже зафиксированы."
|
||||
: "Если клиент согласовал доставку или самовывоз по телефону, сохраните дату и время здесь."}
|
||||
</p>
|
||||
{/* 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-[var(--color-accent-contrast)]"
|
||||
: "text-[var(--color-text-muted)] hover:bg-[var(--color-accent-soft)]"
|
||||
].join(" ")}
|
||||
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-[var(--color-accent-contrast)]"
|
||||
: "text-[var(--color-text-muted)] hover:bg-[var(--color-accent-soft)]"
|
||||
].join(" ")}
|
||||
onClick={() => { setDeliveryType("pickup"); setFormMessage(""); }}
|
||||
>
|
||||
🏪 Самовывоз
|
||||
</button>
|
||||
</div>
|
||||
{deliveryType === "pickup" && (
|
||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3 text-sm text-[var(--color-text-muted)]">
|
||||
<p className="font-semibold text-[var(--color-text)]">ℹ️ Условия хранения</p>
|
||||
<p className="mt-1">Бесплатное хранение — <strong>2 рабочих дня</strong> с даты готовности.</p>
|
||||
<p>Начиная с 3-го рабочего дня — <strong>300 ₽/день</strong> платного хранения.</p>
|
||||
</div>
|
||||
)}
|
||||
{deliveryType === "delivery" && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||
Адрес доставки
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={deliveryAddress}
|
||||
onChange={(e) => setDeliveryAddress(e.target.value)}
|
||||
placeholder="Введите адрес доставки"
|
||||
className="w-full rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-sm !text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{agreedTypeMatchesTab ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-[24px] border border-[rgba(18,128,92,0.35)] bg-[var(--color-accent-soft)] p-4 !text-[var(--color-text)]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-accent)]">
|
||||
{deliveryType === "pickup" ? "Самовывоз согласован" : "Доставка согласована"}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold">
|
||||
{agreedDeliveryLabel || "Дата не указана — нажмите «Изменить дату»"}
|
||||
</p>
|
||||
</div>
|
||||
<Badge tone="accent">Согласовано</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{canEditDelivery ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => { setIsEditingDate(true); setFormMessage(""); }}
|
||||
disabled={isSavingDeliveryChoice}
|
||||
className="text-sm"
|
||||
>
|
||||
Изменить дату {deliveryType === "pickup" ? "самовывоза" : "доставки"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : deliveryType === "delivery" ? (
|
||||
<CalendarWidget
|
||||
label="Календарь доставки"
|
||||
selectedDate={deliveryDate}
|
||||
onDateChange={(dateKey) => { setDeliveryDate(dateKey); setFormMessage(""); }}
|
||||
minDateKey={minSelectableDateKey}
|
||||
isCalendarOpen={isCalendarOpen}
|
||||
setIsCalendarOpen={setIsCalendarOpen}
|
||||
currentMonth={currentMonth}
|
||||
setCurrentMonth={setCurrentMonth}
|
||||
calendarDays={calendarDays}
|
||||
monthLabel={monthLabel}
|
||||
canGoBack={canGoBack}
|
||||
timeOptions={DELIVERY_TIME_OPTIONS}
|
||||
selectedTime={deliveryTime}
|
||||
onTimeChange={(option) => { setDeliveryTime(option); setFormMessage(""); }}
|
||||
layoutClassName="flex flex-col gap-3 md:flex-row md:items-start md:relative md:z-10"
|
||||
calendarClassName="relative space-y-3 md:min-w-0 md:flex-1 md:pr-4"
|
||||
timeClassName="grid gap-2 sm:grid-cols-2 md:w-[320px] md:flex-none"
|
||||
/>
|
||||
) : (
|
||||
<CalendarWidget
|
||||
label="Календарь самовывоза"
|
||||
selectedDate={pickupDate}
|
||||
onDateChange={(dateKey) => { setPickupDate(dateKey); setFormMessage(""); }}
|
||||
minDateKey={minSelectableDateKey}
|
||||
isCalendarOpen={isCalendarOpen}
|
||||
setIsCalendarOpen={setIsCalendarOpen}
|
||||
currentMonth={currentMonth}
|
||||
setCurrentMonth={setCurrentMonth}
|
||||
calendarDays={calendarDays}
|
||||
monthLabel={monthLabel}
|
||||
canGoBack={canGoBack}
|
||||
timeOptions={DELIVERY_TIME_OPTIONS}
|
||||
selectedTime={pickupTimeSlot}
|
||||
onTimeChange={(option) => { setPickupTimeSlot(option); setFormMessage(""); }}
|
||||
layoutClassName="space-y-3"
|
||||
calendarClassName="relative"
|
||||
timeClassName="grid gap-2 sm:grid-cols-2"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
className="w-full md:w-[180px] md:flex-none md:self-start"
|
||||
onClick={() => setConfirmAction({ type: 'delivery' })}
|
||||
disabled={isSavingDeliveryChoice}
|
||||
>
|
||||
{isSavingDeliveryChoice ? "Сохраняем..." : "Согласовать"}
|
||||
</Button>
|
||||
{formMessage ? (
|
||||
<p className="text-sm text-[var(--color-text-muted)]">{formMessage}</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (blockKey === "driver_assignment" && deliveryType === "delivery") {
|
||||
return (
|
||||
<DriverAssignmentPanel
|
||||
order={order}
|
||||
userRole={userRole}
|
||||
canManageDelivery={canManageDelivery}
|
||||
isSavingDriverAssignment={isSavingDriverAssignment}
|
||||
selectedDriverId={selectedDriverId}
|
||||
onDriverSelect={(id) => { setSelectedDriverId(id); setDriverMessage(""); }}
|
||||
onConfirmDriver={() => setConfirmAction({ type: 'driver' })}
|
||||
driverMessage={driverMessage}
|
||||
drivers={drivers}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (blockKey === "shipment") {
|
||||
return (
|
||||
<DriverShipmentPanel
|
||||
order={order}
|
||||
onShipmentChange={handleShipmentChange}
|
||||
onSaveShipment={handleSaveShipment}
|
||||
isSavingShipment={isSavingShipment}
|
||||
onResetStatus={() => {
|
||||
if (onChangeDeliveryStatus) {
|
||||
onChangeDeliveryStatus({
|
||||
orderGroupId: order.id,
|
||||
status: "driver_assigned",
|
||||
}).then((response) => {
|
||||
if (!response.success) {
|
||||
setFormMessage(response.error || "Не удалось сбросить статус");
|
||||
} else {
|
||||
setFormMessage("Статус сброшен, отгрузка очищена");
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
isSavingStatusChange={isSavingStatusChange}
|
||||
groupByOrder={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (blockKey === "sms_status") {
|
||||
return (
|
||||
<SmsStatusCard order={order} userRole={userRole} />
|
||||
);
|
||||
}
|
||||
|
||||
if (blockKey === "status_actions") {
|
||||
return (
|
||||
<>
|
||||
<StatusActionPanel
|
||||
order={order}
|
||||
userRole={userRole}
|
||||
canManageDelivery={canManageDelivery}
|
||||
isSavingStatusChange={isSavingStatusChange}
|
||||
onRefreshOrder={() => {}}
|
||||
onConfirmStatus={(action) => {
|
||||
if (action.type === "hint") {
|
||||
setFormMessage(action.hint);
|
||||
} else if (action.type === "status") {
|
||||
setConfirmAction({
|
||||
type: "status",
|
||||
status: action.status,
|
||||
label: action.label,
|
||||
mismatch: action.mismatch,
|
||||
deliveryType: action.deliveryType,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{formMessage && onChangeDeliveryStatus ? (
|
||||
<p className="text-sm text-[var(--color-warning)]">{formMessage}</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (blockKey === "paid_storage" && onChangeDeliveryStatus) {
|
||||
return (
|
||||
<PaidStoragePanel
|
||||
order={order}
|
||||
onChangeDeliveryStatus={onChangeDeliveryStatus}
|
||||
isSavingStatusChange={isSavingStatusChange}
|
||||
setFormMessage={setFormMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (blockKey === "delivery_link" && order?.deliveryLink) {
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
Отправьте эту ссылку клиенту, чтобы он мог согласовать доставку или самовывоз самостоятельно.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<a
|
||||
href={order.deliveryLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-2xl bg-[var(--color-accent)] px-4 py-2.5 text-sm font-semibold text-white transition hover:opacity-90"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
Открыть страницу согласования
|
||||
</a>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(order.deliveryLink).then(() => {
|
||||
setFormMessage("Ссылка скопирована в буфер обмена");
|
||||
setTimeout(() => setFormMessage(""), 3000);
|
||||
}).catch(() => {
|
||||
setFormMessage("Не удалось скопировать ссылку");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
|
||||
</svg>
|
||||
Скопировать ссылку
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{order.invitationAccessCount > 0 ? (
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
👁 Клиент открывал страницу {order.invitationAccessCount} раз{(order.invitationLastAccessedAt || order.invitationOpenedAt) ? `, последний раз ${new Date(order.invitationLastAccessedAt || order.invitationOpenedAt).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "2-digit" })}` : ""}.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
⏳ Клиент ещё не открывал страницу согласования.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (blockKey === "order_history") {
|
||||
return (
|
||||
<OrderHistoryTimeline order={order} userRole={userRole} />
|
||||
);
|
||||
}
|
||||
|
||||
if (blockKey === "extra_data") {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{order.managerName ? (
|
||||
<div>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">Менеджер</p>
|
||||
<p className="mt-1 font-medium !text-[var(--color-text)]">{order.managerName}</p>
|
||||
{order.managerTel ? (
|
||||
<a href={`tel:${order.managerTel}`} className="text-sm text-[var(--color-accent)] hover:underline">{order.managerTel}</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">Оплата доставки</p>
|
||||
<p className="mt-1 font-medium !text-[var(--color-text)]">
|
||||
{order.isPayedShip ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="rounded-full bg-[var(--color-accent-soft)] px-2 py-0.5 text-xs font-semibold text-[var(--color-accent)]">✓ Оплачено</span>
|
||||
{order.payedShip ? <span className="text-sm">{Number(order.payedShip).toLocaleString("ru-RU")} ₽</span> : null}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-[var(--color-text-muted)]">Не оплачено</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Panel className="space-y-5 p-6">
|
||||
|
|
@ -1049,414 +1406,78 @@ export const OrderDetailPanel = ({
|
|||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* ===== Collapsible + sortable blocks (logistics/admin/manager only) ===== */}
|
||||
{/* ===== Tabbed blocks (logistics/admin/manager only) ===== */}
|
||||
{isLogisticsRole && order ? (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={blockOrder} strategy={verticalListSortingStrategy}>
|
||||
<>
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabChange("all")}
|
||||
className={["rounded-xl px-3 py-2 text-sm font-medium whitespace-nowrap transition",
|
||||
activeTab === "all"
|
||||
? "bg-[var(--color-accent)] text-white"
|
||||
: "bg-[var(--color-surface)] text-[var(--color-text-muted)]"
|
||||
].join(" ")}
|
||||
>
|
||||
Все
|
||||
</button>
|
||||
{blockOrder.map(blockKey => {
|
||||
/* manual_confirmation */
|
||||
if (blockKey === "manual_confirmation" && canManageDelivery) {
|
||||
return (
|
||||
<SortableBlock key={blockKey} id={blockKey}>
|
||||
{({ dragAttributes, dragListeners }) => (
|
||||
<CollapsibleBlock blockKey={blockKey} title={BLOCK_TITLES[blockKey]} dragAttributes={dragAttributes} dragListeners={dragListeners}>
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
{isDeliveryAgreed
|
||||
? "Дата и время уже зафиксированы."
|
||||
: "Если клиент согласовал доставку или самовывоз по телефону, сохраните дату и время здесь."}
|
||||
</p>
|
||||
{/* 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-[var(--color-accent-contrast)]"
|
||||
: "text-[var(--color-text-muted)] hover:bg-[var(--color-accent-soft)]"
|
||||
].join(" ")}
|
||||
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-[var(--color-accent-contrast)]"
|
||||
: "text-[var(--color-text-muted)] hover:bg-[var(--color-accent-soft)]"
|
||||
].join(" ")}
|
||||
onClick={() => { setDeliveryType("pickup"); setFormMessage(""); }}
|
||||
>
|
||||
🏪 Самовывоз
|
||||
</button>
|
||||
</div>
|
||||
{deliveryType === "pickup" && (
|
||||
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3 text-sm text-[var(--color-text-muted)]">
|
||||
<p className="font-semibold text-[var(--color-text)]">ℹ️ Условия хранения</p>
|
||||
<p className="mt-1">Бесплатное хранение — <strong>2 рабочих дня</strong> с даты готовности.</p>
|
||||
<p>Начиная с 3-го рабочего дня — <strong>300 ₽/день</strong> платного хранения.</p>
|
||||
</div>
|
||||
)}
|
||||
{deliveryType === "delivery" && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||
Адрес доставки
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={deliveryAddress}
|
||||
onChange={(e) => setDeliveryAddress(e.target.value)}
|
||||
placeholder="Введите адрес доставки"
|
||||
className="w-full rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-sm !text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{agreedTypeMatchesTab ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-[24px] border border-[rgba(18,128,92,0.35)] bg-[var(--color-accent-soft)] p-4 !text-[var(--color-text)]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-accent)]">
|
||||
{deliveryType === "pickup" ? "Самовывоз согласован" : "Доставка согласована"}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold">
|
||||
{agreedDeliveryLabel || "Дата не указана — нажмите «Изменить дату»"}
|
||||
</p>
|
||||
</div>
|
||||
<Badge tone="accent">Согласовано</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{canEditDelivery ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => { setIsEditingDate(true); setFormMessage(""); }}
|
||||
disabled={isSavingDeliveryChoice}
|
||||
className="text-sm"
|
||||
>
|
||||
Изменить дату {deliveryType === "pickup" ? "самовывоза" : "доставки"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : deliveryType === "delivery" ? (
|
||||
<CalendarWidget
|
||||
label="Календарь доставки"
|
||||
selectedDate={deliveryDate}
|
||||
onDateChange={(dateKey) => { setDeliveryDate(dateKey); setFormMessage(""); }}
|
||||
minDateKey={minSelectableDateKey}
|
||||
isCalendarOpen={isCalendarOpen}
|
||||
setIsCalendarOpen={setIsCalendarOpen}
|
||||
currentMonth={currentMonth}
|
||||
setCurrentMonth={setCurrentMonth}
|
||||
calendarDays={calendarDays}
|
||||
monthLabel={monthLabel}
|
||||
canGoBack={canGoBack}
|
||||
timeOptions={DELIVERY_TIME_OPTIONS}
|
||||
selectedTime={deliveryTime}
|
||||
onTimeChange={(option) => { setDeliveryTime(option); setFormMessage(""); }}
|
||||
layoutClassName="flex flex-col gap-3 md:flex-row md:items-start md:relative md:z-10"
|
||||
calendarClassName="relative space-y-3 md:min-w-0 md:flex-1 md:pr-4"
|
||||
timeClassName="grid gap-2 sm:grid-cols-2 md:w-[320px] md:flex-none"
|
||||
/>
|
||||
) : (
|
||||
<CalendarWidget
|
||||
label="Календарь самовывоза"
|
||||
selectedDate={pickupDate}
|
||||
onDateChange={(dateKey) => { setPickupDate(dateKey); setFormMessage(""); }}
|
||||
minDateKey={minSelectableDateKey}
|
||||
isCalendarOpen={isCalendarOpen}
|
||||
setIsCalendarOpen={setIsCalendarOpen}
|
||||
currentMonth={currentMonth}
|
||||
setCurrentMonth={setCurrentMonth}
|
||||
calendarDays={calendarDays}
|
||||
monthLabel={monthLabel}
|
||||
canGoBack={canGoBack}
|
||||
timeOptions={DELIVERY_TIME_OPTIONS}
|
||||
selectedTime={pickupTimeSlot}
|
||||
onTimeChange={(option) => { setPickupTimeSlot(option); setFormMessage(""); }}
|
||||
layoutClassName="space-y-3"
|
||||
calendarClassName="relative"
|
||||
timeClassName="grid gap-2 sm:grid-cols-2"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
className="w-full md:w-[180px] md:flex-none md:self-start"
|
||||
onClick={() => setConfirmAction({ type: 'delivery' })}
|
||||
disabled={isSavingDeliveryChoice}
|
||||
>
|
||||
{isSavingDeliveryChoice ? "Сохраняем..." : "Согласовать"}
|
||||
</Button>
|
||||
{formMessage ? (
|
||||
<p className="text-sm text-[var(--color-text-muted)]">{formMessage}</p>
|
||||
) : null}
|
||||
</CollapsibleBlock>
|
||||
)}
|
||||
</SortableBlock>
|
||||
);
|
||||
}
|
||||
|
||||
/* driver_assignment */
|
||||
if (blockKey === "driver_assignment" && deliveryType === "delivery") {
|
||||
return (
|
||||
<SortableBlock key={blockKey} id={blockKey}>
|
||||
{({ dragAttributes, dragListeners }) => (
|
||||
<CollapsibleBlock blockKey={blockKey} title={BLOCK_TITLES[blockKey]} dragAttributes={dragAttributes} dragListeners={dragListeners}>
|
||||
<DriverAssignmentPanel
|
||||
order={order}
|
||||
userRole={userRole}
|
||||
canManageDelivery={canManageDelivery}
|
||||
isSavingDriverAssignment={isSavingDriverAssignment}
|
||||
selectedDriverId={selectedDriverId}
|
||||
onDriverSelect={(id) => { setSelectedDriverId(id); setDriverMessage(""); }}
|
||||
onConfirmDriver={() => setConfirmAction({ type: 'driver' })}
|
||||
driverMessage={driverMessage}
|
||||
drivers={drivers}
|
||||
/>
|
||||
</CollapsibleBlock>
|
||||
)}
|
||||
</SortableBlock>
|
||||
);
|
||||
}
|
||||
|
||||
/* shipment — DriverShipmentPanel */
|
||||
if (blockKey === "shipment") {
|
||||
return (
|
||||
<SortableBlock key={blockKey} id={blockKey}>
|
||||
{({ dragAttributes, dragListeners }) => (
|
||||
<CollapsibleBlock blockKey={blockKey} title={BLOCK_TITLES[blockKey]} dragAttributes={dragAttributes} dragListeners={dragListeners}>
|
||||
<DriverShipmentPanel
|
||||
order={order}
|
||||
onShipmentChange={handleShipmentChange}
|
||||
onSaveShipment={handleSaveShipment}
|
||||
isSavingShipment={isSavingShipment}
|
||||
onResetStatus={() => {
|
||||
if (onChangeDeliveryStatus) {
|
||||
onChangeDeliveryStatus({
|
||||
orderGroupId: order.id,
|
||||
status: "driver_assigned",
|
||||
}).then((response) => {
|
||||
if (!response.success) {
|
||||
setFormMessage(response.error || "Не удалось сбросить статус");
|
||||
} else {
|
||||
setFormMessage("Статус сброшен, отгрузка очищена");
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
isSavingStatusChange={isSavingStatusChange}
|
||||
groupByOrder={true}
|
||||
/>
|
||||
</CollapsibleBlock>
|
||||
)}
|
||||
</SortableBlock>
|
||||
);
|
||||
}
|
||||
|
||||
/* sms_status */
|
||||
if (blockKey === "sms_status") {
|
||||
return (
|
||||
<SortableBlock key={blockKey} id={blockKey}>
|
||||
{({ dragAttributes, dragListeners }) => (
|
||||
<CollapsibleBlock blockKey={blockKey} title={BLOCK_TITLES[blockKey]} dragAttributes={dragAttributes} dragListeners={dragListeners}>
|
||||
<SmsStatusCard order={order} userRole={userRole} />
|
||||
</CollapsibleBlock>
|
||||
)}
|
||||
</SortableBlock>
|
||||
);
|
||||
}
|
||||
|
||||
/* status_actions */
|
||||
if (blockKey === "status_actions") {
|
||||
return (
|
||||
<SortableBlock key={blockKey} id={blockKey}>
|
||||
{({ dragAttributes, dragListeners }) => (
|
||||
<CollapsibleBlock blockKey={blockKey} title={BLOCK_TITLES[blockKey]} dragAttributes={dragAttributes} dragListeners={dragListeners}>
|
||||
<StatusActionPanel
|
||||
order={order}
|
||||
userRole={userRole}
|
||||
canManageDelivery={canManageDelivery}
|
||||
isSavingStatusChange={isSavingStatusChange}
|
||||
onRefreshOrder={() => {}}
|
||||
onConfirmStatus={(action) => {
|
||||
if (action.type === "hint") {
|
||||
setFormMessage(action.hint);
|
||||
} else if (action.type === "status") {
|
||||
setConfirmAction({
|
||||
type: "status",
|
||||
status: action.status,
|
||||
label: action.label,
|
||||
mismatch: action.mismatch,
|
||||
deliveryType: action.deliveryType,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{formMessage && onChangeDeliveryStatus ? (
|
||||
<p className="text-sm text-[var(--color-warning)]">{formMessage}</p>
|
||||
) : null}
|
||||
</CollapsibleBlock>
|
||||
)}
|
||||
</SortableBlock>
|
||||
);
|
||||
}
|
||||
|
||||
/* paid_storage */
|
||||
if (blockKey === "paid_storage" && onChangeDeliveryStatus) {
|
||||
return (
|
||||
<SortableBlock key={blockKey} id={blockKey}>
|
||||
{({ dragAttributes, dragListeners }) => (
|
||||
<CollapsibleBlock blockKey={blockKey} title={BLOCK_TITLES[blockKey]} dragAttributes={dragAttributes} dragListeners={dragListeners}>
|
||||
<PaidStoragePanel
|
||||
order={order}
|
||||
onChangeDeliveryStatus={onChangeDeliveryStatus}
|
||||
isSavingStatusChange={isSavingStatusChange}
|
||||
setFormMessage={setFormMessage}
|
||||
/>
|
||||
</CollapsibleBlock>
|
||||
)}
|
||||
</SortableBlock>
|
||||
);
|
||||
}
|
||||
|
||||
/* delivery_link */
|
||||
if (blockKey === "delivery_link" && order?.deliveryLink) {
|
||||
return (
|
||||
<SortableBlock key={blockKey} id={blockKey}>
|
||||
{({ dragAttributes, dragListeners }) => (
|
||||
<CollapsibleBlock blockKey={blockKey} title={BLOCK_TITLES[blockKey]} dragAttributes={dragAttributes} dragListeners={dragListeners}>
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
Отправьте эту ссылку клиенту, чтобы он мог согласовать доставку или самовывоз самостоятельно.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<a
|
||||
href={order.deliveryLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-2xl bg-[var(--color-accent)] px-4 py-2.5 text-sm font-semibold text-white transition hover:opacity-90"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
Открыть страницу согласования
|
||||
</a>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(order.deliveryLink).then(() => {
|
||||
setFormMessage("Ссылка скопирована в буфер обмена");
|
||||
setTimeout(() => setFormMessage(""), 3000);
|
||||
}).catch(() => {
|
||||
setFormMessage("Не удалось скопировать ссылку");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
|
||||
</svg>
|
||||
Скопировать ссылку
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{order.invitationAccessCount > 0 ? (
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
👁 Клиент открывал страницу {order.invitationAccessCount} раз{(order.invitationLastAccessedAt || order.invitationOpenedAt) ? `, последний раз ${new Date(order.invitationLastAccessedAt || order.invitationOpenedAt).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "2-digit" })}` : ""}.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
⏳ Клиент ещё не открывал страницу согласования.
|
||||
</p>
|
||||
)}
|
||||
</CollapsibleBlock>
|
||||
)}
|
||||
</SortableBlock>
|
||||
);
|
||||
}
|
||||
|
||||
/* order_history */
|
||||
if (blockKey === "order_history") {
|
||||
return (
|
||||
<SortableBlock key={blockKey} id={blockKey}>
|
||||
{({ dragAttributes, dragListeners }) => (
|
||||
<CollapsibleBlock blockKey={blockKey} title={BLOCK_TITLES[blockKey]} dragAttributes={dragAttributes} dragListeners={dragListeners}>
|
||||
<OrderHistoryTimeline order={order} userRole={userRole} />
|
||||
</CollapsibleBlock>
|
||||
)}
|
||||
</SortableBlock>
|
||||
);
|
||||
}
|
||||
|
||||
/* extra_data */
|
||||
if (blockKey === "extra_data") {
|
||||
return (
|
||||
<SortableBlock key={blockKey} id={blockKey}>
|
||||
{({ dragAttributes, dragListeners }) => (
|
||||
<CollapsibleBlock blockKey={blockKey} title={BLOCK_TITLES[blockKey]} dragAttributes={dragAttributes} dragListeners={dragListeners} defaultCollapsed={true}>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{order.managerName ? (
|
||||
<div>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">Менеджер</p>
|
||||
<p className="mt-1 font-medium !text-[var(--color-text)]">{order.managerName}</p>
|
||||
{order.managerTel ? (
|
||||
<a href={`tel:${order.managerTel}`} className="text-sm text-[var(--color-accent)] hover:underline">{order.managerTel}</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">Оплата доставки</p>
|
||||
<p className="mt-1 font-medium !text-[var(--color-text)]">
|
||||
{order.isPayedShip ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="rounded-full bg-[var(--color-accent-soft)] px-2 py-0.5 text-xs font-semibold text-[var(--color-accent)]">✓ Оплачено</span>
|
||||
{order.payedShip ? <span className="text-sm">{Number(order.payedShip).toLocaleString("ru-RU")} ₽</span> : null}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-[var(--color-text-muted)]">Не оплачено</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{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>
|
||||
</CollapsibleBlock>
|
||||
)}
|
||||
</SortableBlock>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
// Only show tab if the block would be visible (match gating conditions)
|
||||
if (blockKey === "manual_confirmation" && !canManageDelivery) return null;
|
||||
if (blockKey === "driver_assignment" && deliveryType !== "delivery") return null;
|
||||
if (blockKey === "paid_storage" && !onChangeDeliveryStatus) return null;
|
||||
if (blockKey === "delivery_link" && !order?.deliveryLink) return null;
|
||||
return (
|
||||
<button
|
||||
key={blockKey}
|
||||
type="button"
|
||||
onClick={() => handleTabChange(blockKey)}
|
||||
className={["rounded-xl px-3 py-2 text-sm font-medium whitespace-nowrap transition",
|
||||
activeTab === blockKey
|
||||
? "bg-[var(--color-accent)] text-white"
|
||||
: "bg-[var(--color-surface)] text-[var(--color-text-muted)]"
|
||||
].join(" ")}
|
||||
>
|
||||
{BLOCK_TITLES[blockKey]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
{/* Block content — "all" mode = DnD + collapsible, single-tab = just the block content */}
|
||||
{activeTab === "all" ? (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={blockOrder} strategy={verticalListSortingStrategy}>
|
||||
{blockOrder.map(blockKey => {
|
||||
const renderWith = (extraProps = {}) => {
|
||||
if (blockKey === "extra_data") return { ...extraProps, defaultCollapsed: true };
|
||||
return extraProps;
|
||||
};
|
||||
// Check visibility gates
|
||||
if (blockKey === "manual_confirmation" && !canManageDelivery) return null;
|
||||
if (blockKey === "driver_assignment" && deliveryType !== "delivery") return null;
|
||||
if (blockKey === "paid_storage" && !onChangeDeliveryStatus) return null;
|
||||
if (blockKey === "delivery_link" && !order?.deliveryLink) return null;
|
||||
return (
|
||||
<SortableBlock key={blockKey} id={blockKey}>
|
||||
{({ dragAttributes, dragListeners }) => (
|
||||
<CollapsibleBlock blockKey={blockKey} title={BLOCK_TITLES[blockKey]} dragAttributes={dragAttributes} dragListeners={dragListeners} {...renderWith()}>
|
||||
{renderBlockContent(blockKey)}
|
||||
</CollapsibleBlock>
|
||||
)}
|
||||
</SortableBlock>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
<Panel className="space-y-4 p-4">
|
||||
<div className="font-bold border-b pb-2 mb-2">{BLOCK_TITLES[activeTab]}</div>
|
||||
{renderBlockContent(activeTab)}
|
||||
</Panel>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* ===== Driver-only blocks (not sortable/collapsible) ===== */}
|
||||
|
|
|
|||
Loading…
Reference in New Issue