supersam/src/pages/DashboardPage.jsx

999 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React from "react";
import { Navigate } from "react-router-dom";
import { ORDER_STATUSES } from "../constants/orderStatuses";
import { ROLE_LABELS, ROLE_PERMISSIONS } from "../constants/roles";
import {
DRIVER_STATUSES,
getOrderStatusComment,
getStatusStageKey,
LOGISTICS_STATUSES,
PRODUCTION_STATUSES,
} from "../constants/deliveryWorkflow";
import { AuditPanel } from "../components/admin/AuditPanel";
import { UserDirectoryPanel } from "../components/admin/UserDirectoryPanel";
import { UserOnboardingPanel } from "../components/admin/UserOnboardingPanel";
import { DeliverySetDetailPanel } from "../components/logistics/DeliverySetDetailPanel";
import { DriverDeliveryDetail } from "../components/driver/DriverDeliveryDetail";
import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
import { KpiCard } from "../components/dashboard/KpiCard";
import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard";
import { ProductionQueuePanel } from "../components/dashboard/ProductionQueuePanel";
import { RoleWorkspacePanel } from "../components/dashboard/RoleWorkspacePanel";
import { BotControlPanel } from "../components/logistics/BotControlPanel";
import { OrdersCalendarView } from "../components/orders/OrdersCalendarView";
import { OrderDetailPanel } from "../components/orders/OrderDetailPanel";
import { OrderFilters } from "../components/orders/OrderFilters";
import { OrdersKanbanBoard } from "../components/orders/OrdersKanbanBoard";
import { OrdersTable } from "../components/orders/OrdersTable";
import { Badge } from "../components/UI/Badge";
import { Button } from "../components/UI/Button";
import { Modal } from "../components/UI/Modal";
import { Panel } from "../components/UI/Panel";
import { SegmentedTabs } from "../components/UI/SegmentedTabs";
import { Select } from "../components/UI/Select";
import { useAuth } from "../context/AuthContext";
import { useOrders } from "../hooks/useOrders";
import { AppShell } from "../layouts/AppShell";
import {
filterDriverDeliveries,
getDeliveryDay,
} from "../services/driverDeliveries";
import {
buildKanbanColumns,
filterArchiveOrders,
filterKanbanColumnsByStage,
filterRegistryOrders,
} from "../services/orderViews";
import { getKanbanDropResolution } from "../services/orderService";
import { formatDateTime } from "../utils/formatters";
import { resolveDraggedOrderId } from "../components/orders/ordersKanbanDrag";
export const DashboardPage = () => {
const { user, signOut } = useAuth();
const userRole = user?.role;
const [activeSection, setActiveSection] = React.useState("overview");
const [overviewTab, setOverviewTab] = React.useState("pulse");
const [ordersViewTab, setOrdersViewTab] = React.useState("registry");
const [isOrderModalOpen, setIsOrderModalOpen] = React.useState(false);
const [isOrderWorkspaceExpanded, setIsOrderWorkspaceExpanded] = React.useState(false);
const [dragOrderId, setDragOrderId] = React.useState(null);
const [dropColumnKey, setDropColumnKey] = React.useState(null);
const [kanbanNotice, setKanbanNotice] = React.useState(null);
const [kanbanMode, setKanbanMode] = React.useState("by_stage");
const [kanbanDepartmentFilter, setKanbanDepartmentFilter] = React.useState("all");
const [kanbanSort, setKanbanSort] = React.useState("updated_desc");
const [showCompletedInRegistry, setShowCompletedInRegistry] = React.useState(false);
const [showCompletedInKanban, setShowCompletedInKanban] = React.useState(false);
const [selectedDeliverySet, setSelectedDeliverySet] = React.useState(null);
const [driverFilters, setDriverFilters] = React.useState({
dateFrom: "",
dateTo: "",
city: "all",
timeSlot: "all",
viewMode: "active",
showCompleted: false,
});
const {
orders,
allOrders,
selectedOrder,
selectedOrderId,
setSelectedOrderId,
filters,
setFilters,
notifications,
pushNotification,
updateStatus,
addChatMessage,
addInternalMessage,
addOrderNote,
assignDriver,
reassignDelivery,
autoAssignLogisticians,
saveDriverRouteOrder,
metrics,
agingAlerts,
agingSummary,
deliverySetBuckets,
users,
isSupabaseBacked,
isLoading,
loadError,
} = useOrders(user);
const canManageLogistics = userRole === "logistician" || userRole === "admin";
const productionOrders = allOrders.filter((order) => PRODUCTION_STATUSES.includes(order.status));
const logisticsOrders = allOrders.filter((order) => LOGISTICS_STATUSES.includes(order.status));
const driverOrders =
userRole === "driver"
? allOrders
: allOrders.filter((order) => DRIVER_STATUSES.includes(order.status));
const selectedLogisticsOrder =
logisticsOrders.find((order) => order.id === selectedOrderId) || logisticsOrders[0] || null;
const registryOrders = React.useMemo(
() => filterRegistryOrders(orders, { includeCompleted: showCompletedInRegistry }),
[orders, showCompletedInRegistry],
);
const archiveOrders = React.useMemo(() => filterArchiveOrders(orders), [orders]);
const driverPlannedOrders = React.useMemo(
() => filterDriverDeliveries(driverOrders, driverFilters),
[driverFilters, driverOrders],
);
const todayKey = React.useMemo(() => new Date().toISOString().slice(0, 10), []);
const tomorrowKey = React.useMemo(() => {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
return tomorrow.toISOString().slice(0, 10);
}, []);
const driverTodayOrders = React.useMemo(
() => driverOrders.filter((order) => getDeliveryDay(order) === todayKey),
[driverOrders, todayKey],
);
const driverTomorrowOrders = React.useMemo(
() => driverOrders.filter((order) => getDeliveryDay(order) === tomorrowKey),
[driverOrders, tomorrowKey],
);
const driverCompletedOrders = React.useMemo(
() => driverOrders.filter((order) => ["Доставлен", "Закрыт"].includes(order.status)),
[driverOrders],
);
const navItems = React.useMemo(() => {
if (userRole === "driver") {
return [
{
key: "overview",
label: "Обзор",
description: "Краткая сводка по вашим текущим и ближайшим доставкам.",
},
{
key: "deliveries",
label: "Мои доставки",
description: "План маршрута, фильтры по дням и быстрые действия по доставкам.",
badge: String(driverOrders.length),
},
];
}
const base = [
{
key: "overview",
label: "Обзор",
description: "Сводка по загрузке, событиям и проблемным точкам.",
},
{
key: "orders",
label: "Заказы",
description: "Реестр, календарь доставок, канбан, история и архив заказов.",
badge: String(allOrders.length),
},
];
if (userRole === "production_lead" || userRole === "admin") {
base.push({
key: "production",
label: "Производство",
description: "Очередь производства, приоритеты и контроль готовности.",
badge: String(productionOrders.length),
});
}
if (userRole === "logistician" || userRole === "admin") {
base.push({
key: "logistics",
label: "Логистика",
description: "Слоты доставки, чатботы и ручная обработка исключений.",
badge: String(logisticsOrders.length),
});
}
if (userRole === "admin") {
base.push({
key: "admin",
label: "Администрирование",
description: "Пользователи, аудит, ошибки интеграций и контроль доступа.",
badge: String(notifications.length),
});
}
base.push({
key: "references",
label: "Справочники",
description: "Статусы заказа, роли и правила маршрутизации по процессу.",
badge: String(ORDER_STATUSES.length),
});
return base;
}, [
allOrders.length,
driverOrders.length,
logisticsOrders.length,
notifications.length,
productionOrders.length,
userRole,
]);
React.useEffect(() => {
if (!navItems.some((item) => item.key === activeSection)) {
setActiveSection(navItems[0]?.key || "overview");
}
}, [activeSection, navItems]);
React.useEffect(() => {
setIsOrderWorkspaceExpanded(false);
}, [activeSection]);
React.useEffect(() => {
if (activeSection !== "orders") {
setOrdersViewTab("registry");
}
}, [activeSection]);
const sectionMeta = navItems.find((item) => item.key === activeSection) || navItems[0];
const openOrderModal = (orderId) => {
setSelectedOrderId(orderId);
setIsOrderModalOpen(true);
};
const handleKanbanDrop = (event, column) => {
const droppedOrderId = resolveDraggedOrderId(event, dragOrderId);
if (!droppedOrderId) {
return;
}
const draggedOrder = allOrders.find((order) => order.id === droppedOrderId);
if (!draggedOrder) {
setDragOrderId(null);
setDropColumnKey(null);
return;
}
const isSameColumn =
(kanbanMode === "by_stage" && getStatusStageKey(draggedOrder.status) === column.key) ||
(kanbanMode === "by_status" && draggedOrder.status === column.key);
if (isSameColumn) {
setDragOrderId(null);
setDropColumnKey(null);
return;
}
const { nextStatus, reason } = getKanbanDropResolution({
order: draggedOrder,
column,
role: user.role,
});
if (!nextStatus || nextStatus === draggedOrder.status) {
setKanbanNotice({
tone: "warning",
title: "Перенос недоступен",
description: `${draggedOrder.orderNumber}: ${reason || `для роли ${ROLE_LABELS[user.role]} этот переход сейчас недоступен`}`,
});
pushNotification({
id: `notification-${Date.now()}`,
type: "warning",
title: "Перенос недоступен",
description: `${draggedOrder.orderNumber}: ${reason || `для роли ${ROLE_LABELS[user.role]} этот переход сейчас недоступен`}`,
});
setDragOrderId(null);
setDropColumnKey(null);
return;
}
setKanbanNotice(null);
updateStatus(droppedOrderId, nextStatus, user.name);
setDragOrderId(null);
setDropColumnKey(null);
};
const overviewTabs = [
{ key: "pulse", label: "Пульс" },
{ key: "events", label: "События" },
{ key: "exceptions", label: "Исключения" },
];
const ordersTabs = [
{ key: "registry", label: "Реестр" },
{ key: "calendar", label: "Календарь" },
{ key: "kanban", label: "Канбан" },
{ key: "history", label: "История" },
{ key: "archive", label: "Архив" },
];
const orderHistoryFeed = allOrders
.flatMap((order) =>
order.history.map((entry) => ({
...entry,
orderNumber: order.orderNumber,
customerName: order.customer.name,
})),
)
.sort((left, right) => new Date(right.at) - new Date(left.at));
const sortedKanbanOrders = React.useMemo(() => {
const sortableOrders = [...orders];
const sorters = {
updated_desc: (left, right) => new Date(right.updatedAt) - new Date(left.updatedAt),
created_desc: (left, right) => new Date(right.createdAt) - new Date(left.createdAt),
delivery_asc: (left, right) =>
new Date(left.scheduledDelivery) - new Date(right.scheduledDelivery),
client_asc: (left, right) => left.customer.name.localeCompare(right.customer.name),
order_asc: (left, right) => left.orderNumber.localeCompare(right.orderNumber),
};
return sortableOrders.sort(sorters[kanbanSort] || sorters.updated_desc);
}, [kanbanSort, orders]);
const kanbanColumns = React.useMemo(
() =>
buildKanbanColumns(sortedKanbanOrders, {
includeCompleted: showCompletedInKanban,
mode: kanbanMode,
}),
[kanbanMode, showCompletedInKanban, sortedKanbanOrders],
);
const visibleKanbanColumns = React.useMemo(
() => filterKanbanColumnsByStage(kanbanColumns, kanbanDepartmentFilter),
[kanbanColumns, kanbanDepartmentFilter],
);
const eventFeed = React.useMemo(() => {
const agingEvents = agingAlerts.slice(0, 4).map((alert) => ({
id: `aging-${alert.order.id}`,
title:
alert.agingState === "critical"
? `Просрочен ${alert.order.orderNumber}`
: `Требует внимания ${alert.order.orderNumber}`,
description: `${alert.order.customer.name}: ${alert.order.status} · ${alert.statusAgeLabel}`,
}));
return [...agingEvents, ...notifications].slice(0, 8);
}, [agingAlerts, notifications]);
if (!user) {
return <Navigate to="/login" replace />;
}
const renderOrderWorkspace = (order, isExpanded) => {
if (!order) {
return (
<Panel className="flex min-h-[320px] items-center justify-center p-6">
<p className="text-sm text-[var(--color-text-muted)]">Выберите заказ из таблицы.</p>
</Panel>
);
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-lg font-semibold">{order.orderNumber}</h3>
<p className="text-sm text-[var(--color-text-muted)]">
{order.customer.name} · {order.customer.address}
</p>
</div>
<div className="flex flex-wrap gap-3">
{!isExpanded ? (
<Button variant="secondary" onClick={() => setIsOrderWorkspaceExpanded(true)}>
Развернуть в рабочую область
</Button>
) : (
<Button variant="secondary" onClick={() => setIsOrderWorkspaceExpanded(false)}>
Вернуть к таблице
</Button>
)}
</div>
</div>
<OrderDetailPanel
order={order}
currentUser={user}
onStatusChange={(nextStatus) => updateStatus(order.id, nextStatus, user.name)}
onAssignDriver={(driverId) => assignDriver({ orderId: order.id, driverId, actorName: user.name })}
onClientMessage={(text) =>
addChatMessage(order.id, {
sender: "client",
channel: order.customer.messenger,
text,
})
}
onInternalMessage={(message) => addInternalMessage(order.id, message)}
onOrderNote={(note) => addOrderNote(order.id, note)}
users={users}
/>
</div>
);
};
const renderActiveTab = () => {
if (activeSection === "overview") {
if (user.role === "driver") {
return (
<div className="space-y-6 xl:space-y-8">
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<KpiCard label="Сегодня" value={driverTodayOrders.length} hint="Точки на текущий день" />
<KpiCard label="Завтра" value={driverTomorrowOrders.length} hint="Загрузка на следующий день" />
<KpiCard label="В плане" value={driverPlannedOrders.length} hint="С учётом текущих фильтров" />
<KpiCard label="Завершено" value={driverCompletedOrders.length} hint="Финализированные доставки" />
</section>
<section className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<Panel className="space-y-5 p-6">
<h3 className="text-lg font-semibold">Как пользоваться</h3>
<div className="space-y-3 text-sm leading-6 text-[var(--color-text-muted)]">
<p>1. Откройте «Мои доставки» и отфильтруйте день, город и половину дня.</p>
<p>2. Перетащите карточки внутри дня, чтобы выстроить удобный порядок точек.</p>
<p>3. Откройте карточку доставки и отметьте: загружен, в пути, доставлен или проблема.</p>
</div>
</Panel>
<Panel className="space-y-5 p-6">
<h3 className="text-lg font-semibold">Ближайшие адреса</h3>
<div className="space-y-3">
{driverPlannedOrders.slice(0, 3).map((order) => (
<button
key={order.id}
className="w-full rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-left transition hover:bg-[var(--color-accent-soft)]"
onClick={() => openOrderModal(order.id)}
type="button"
>
<div className="font-medium">{order.customer.address}</div>
<div className="mt-2 text-sm text-[var(--color-text-muted)]">
{order.orderNumber} · {order.customer.name}
</div>
</button>
))}
</div>
</Panel>
</section>
</div>
);
}
return (
<div className="space-y-6 xl:space-y-8">
<SegmentedTabs items={overviewTabs} activeKey={overviewTab} onChange={setOverviewTab} />
{overviewTab === "pulse" ? (
<div className="space-y-6">
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<KpiCard label="Активные заказы" value={metrics.total} hint="Видимость по роли" />
<KpiCard
label="Готово к отгрузке"
value={metrics.readyToShip}
hint="Можно запускать доставку"
/>
<KpiCard
label="Ждут согласования"
value={metrics.awaitingDeliveryCoordination}
hint="Клиент ещё не подтвердил доставку"
/>
<KpiCard label="Проблемные" value={metrics.exceptions} hint="Нужна ручная реакция" />
</section>
{agingAlerts.length ? (
<Panel className="flex flex-wrap items-center justify-between gap-4 p-5">
<div>
<h3 className="text-lg font-semibold">Контроль зависших заказов</h3>
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
Смотрите на заказы, которые слишком долго находятся в одном статусе.
</p>
</div>
<div className="flex flex-wrap gap-2">
{agingSummary.warning ? (
<Badge tone="warning">Требуют внимания: {agingSummary.warning}</Badge>
) : null}
{agingSummary.critical ? (
<Badge tone="danger">Просрочены: {agingSummary.critical}</Badge>
) : null}
</div>
</Panel>
) : null}
<section className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<RoleWorkspacePanel role={user.role} deliverySetBuckets={deliverySetBuckets} />
<Panel className="space-y-5 p-6">
<h3 className="text-lg font-semibold">Оперативные действия</h3>
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-[24px] bg-[var(--color-surface-strong)] p-5">
<div className="text-sm text-[var(--color-text-muted)]">Заказов в работе</div>
<div className="mt-3 text-2xl font-semibold">{metrics.total}</div>
</div>
<div className="rounded-[24px] bg-[var(--color-surface-strong)] p-5">
<div className="text-sm text-[var(--color-text-muted)]">Нужна логистика</div>
<div className="mt-3 text-2xl font-semibold">{metrics.inLogistics}</div>
</div>
</div>
{user.role === "admin" || user.role === "logistician" ? (
<div className="flex flex-wrap gap-3">
<Button variant="secondary" onClick={autoAssignLogisticians}>
Автораспределение логистов
</Button>
</div>
) : null}
</Panel>
</section>
</div>
) : null}
{overviewTab === "events" ? (
<Panel className="p-6">
<h3 className="text-lg font-semibold">Последние события</h3>
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{eventFeed.map((notification) => (
<div
key={notification.id}
className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-5"
>
<div className="text-sm font-semibold">{notification.title}</div>
<div className="mt-3 text-sm leading-6 text-[var(--color-text-muted)]">
{notification.description}
</div>
</div>
))}
</div>
</Panel>
) : null}
{overviewTab === "exceptions" ? (
<div className="grid gap-6 xl:grid-cols-[1fr_1fr]">
<AuditPanel order={selectedOrder} />
<Panel className="p-6">
<h3 className="text-lg font-semibold">Проблемные заказы</h3>
<div className="mt-5 space-y-3">
{allOrders
.filter((order) => order.status === "Проблема доставки")
.map((order) => (
<button
key={order.id}
className="w-full rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-left hover:bg-[var(--color-accent-soft)]"
onClick={() => openOrderModal(order.id)}
type="button"
>
<div className="font-medium">{order.orderNumber}</div>
<div className="mt-2 text-sm text-[var(--color-text-muted)]">
{order.customer.name} · {getOrderStatusComment(order.status)}
</div>
</button>
))}
</div>
</Panel>
</div>
) : null}
</div>
);
}
if (activeSection === "orders") {
return (
<div className="space-y-6 xl:space-y-8">
<SegmentedTabs items={ordersTabs} activeKey={ordersViewTab} onChange={setOrdersViewTab} />
{ordersViewTab === "registry" ? (
<div className="space-y-6">
<Panel className="flex flex-wrap items-center justify-between gap-3 p-4">
<div>
<h3 className="text-lg font-semibold">Реестр заказов</h3>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
Основная таблица для ежедневной работы. Данные сюда поступают только из 1С.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant={showCompletedInRegistry ? "secondary" : "ghost"}
onClick={() => setShowCompletedInRegistry((current) => !current)}
>
{showCompletedInRegistry ? "Скрыть завершённые" : "Показать завершённые"}
</Button>
<Badge tone="neutral">Импорт из 1С</Badge>
</div>
</Panel>
<OrderFilters filters={filters} setFilters={setFilters} users={users} />
{isOrderWorkspaceExpanded ? (
renderOrderWorkspace(selectedOrder, true)
) : (
<OrdersTable
orders={registryOrders}
selectedOrderId={selectedOrderId}
onOpenOrder={openOrderModal}
users={users}
/>
)}
</div>
) : null}
{ordersViewTab === "calendar" ? (
<OrdersCalendarView orders={registryOrders} onOpenOrder={openOrderModal} />
) : null}
{ordersViewTab === "kanban" ? (
<div className="space-y-4">
<OrderFilters filters={filters} setFilters={setFilters} users={users} />
<Panel className="p-4">
<div className="grid gap-3 xl:grid-cols-[1.2fr_220px_220px_auto] xl:items-center">
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
Канбан показывает только отфильтрованные заказы. Можно переключать вид по этапам
и по статусам, а цвет карточки показывает, чья сейчас зона ответственности.
</p>
<Select value={kanbanSort} onChange={(event) => setKanbanSort(event.target.value)}>
<option value="updated_desc">Сначала недавно обновлённые</option>
<option value="created_desc">Сначала новые заказы</option>
<option value="delivery_asc">Сначала ближайшая доставка</option>
<option value="client_asc">По имени клиента</option>
<option value="order_asc">По номеру заказа</option>
</Select>
<Button
size="sm"
variant={showCompletedInKanban ? "secondary" : "ghost"}
onClick={() => setShowCompletedInKanban((current) => !current)}
>
{showCompletedInKanban ? "Скрыть завершённые" : "Показать завершённые"}
</Button>
<div className="flex flex-wrap justify-start gap-2 xl:justify-end">
{agingSummary.warning ? (
<Button
size="sm"
variant={filters.agingState === "warning" ? "secondary" : "ghost"}
onClick={() =>
setFilters((current) => ({
...current,
agingState: current.agingState === "warning" ? "all" : "warning",
}))
}
>
Требуют внимания: {agingSummary.warning}
</Button>
) : null}
{agingSummary.critical ? (
<Button
size="sm"
variant={filters.agingState === "critical" ? "secondary" : "ghost"}
onClick={() =>
setFilters((current) => ({
...current,
agingState: current.agingState === "critical" ? "all" : "critical",
}))
}
>
Просрочены: {agingSummary.critical}
</Button>
) : null}
</div>
</div>
</Panel>
<OrdersKanbanBoard
columns={visibleKanbanColumns}
currentMode={kanbanMode}
departmentFilter={kanbanDepartmentFilter}
notice={kanbanNotice}
onDepartmentFilterChange={setKanbanDepartmentFilter}
onModeChange={setKanbanMode}
onOpenOrder={openOrderModal}
onDragStart={(orderId) => {
setKanbanNotice(null);
setDragOrderId(orderId);
}}
onDragEnd={() => {
setDragOrderId(null);
setDropColumnKey(null);
}}
onDragOverColumn={setDropColumnKey}
onDragLeaveColumn={(columnKey) =>
setDropColumnKey((current) => (current === columnKey ? null : current))
}
onDropColumn={handleKanbanDrop}
dropColumnKey={dropColumnKey}
/>
</div>
) : null}
{ordersViewTab === "history" ? (
<Panel className="p-6">
<h3 className="text-lg font-semibold">История заказов по сотрудникам</h3>
<p className="mt-3 text-sm leading-6 text-[var(--color-text-muted)]">
Лента показывает, кто и по какому заказу менял статус или выполнял действие.
</p>
<div className="mt-5 space-y-4">
{orderHistoryFeed.map((entry) => (
<div
key={`${entry.orderNumber}-${entry.id}`}
className="rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-medium">
{entry.orderNumber} · {entry.customerName}
</div>
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
{entry.userName} · {entry.action}
</div>
</div>
<div className="text-sm text-[var(--color-text-muted)]">
{formatDateTime(entry.at)}
</div>
</div>
<div className="mt-3 text-sm text-[var(--color-text-muted)]">
{entry.oldStatus || "Начало"} {entry.newStatus}
</div>
</div>
))}
</div>
</Panel>
) : null}
{ordersViewTab === "archive" ? (
<div className="space-y-6">
<Panel className="flex flex-wrap items-center justify-between gap-3 p-4">
<div>
<h3 className="text-lg font-semibold">Архив заказов</h3>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
Завершённые заказы вынесены отдельно, чтобы не перегружать реестр и канбан.
</p>
</div>
<Badge tone="neutral">{archiveOrders.length}</Badge>
</Panel>
<OrdersTable
orders={archiveOrders}
selectedOrderId={selectedOrderId}
onOpenOrder={openOrderModal}
users={users}
/>
</div>
) : null}
</div>
);
}
if (activeSection === "production") {
return (
<div className="space-y-6 xl:space-y-8">
<ProductionQueuePanel orders={allOrders} />
<OrdersTable
orders={productionOrders}
selectedOrderId={selectedOrderId}
onOpenOrder={openOrderModal}
users={users}
/>
</div>
);
}
if (activeSection === "logistics") {
return (
<div className="space-y-6 xl:space-y-8">
<LogisticsReadinessBoard
deliverySetBuckets={deliverySetBuckets}
onSelectSet={(set) => setSelectedDeliverySet(set)}
/>
{selectedDeliverySet ? (
<DeliverySetDetailPanel
deliverySet={selectedDeliverySet}
onClose={() => setSelectedDeliverySet(null)}
/>
) : null}
<section className="grid gap-6 xl:grid-cols-[1.08fr_0.92fr]">
<BotControlPanel
selectedOrder={selectedLogisticsOrder}
canManageLogistics={canManageLogistics}
onSendBotMessage={(message) =>
selectedLogisticsOrder && addChatMessage(selectedLogisticsOrder.id, message)
}
onReschedule={(slot) =>
selectedLogisticsOrder &&
reassignDelivery(selectedLogisticsOrder.id, slot, user.name)
}
/>
<Panel className="space-y-5 p-6">
<div>
<h3 className="text-lg font-semibold">Логика каналов</h3>
<p className="mt-3 text-sm leading-6 text-[var(--color-text-muted)]">
Единый интеграционный слой сохраняет входящие и исходящие события в историю
заказа, а ответы клиента автоматически меняют статус согласования доставки.
</p>
</div>
<div className="space-y-4 text-sm leading-6 text-[var(--color-text-muted)]">
<p>СМС и электронная почта: быстрый старт для первого подтверждения доставки.</p>
<p>ВКонтакте: обратные вызовы и кнопки с привязкой к идентификатору заказа.</p>
<p>Макс: преобразование входящих событий в единую модель чата.</p>
</div>
</Panel>
</section>
<Panel className="p-6">
<h3 className="text-lg font-semibold">Заказы в логистике</h3>
<p className="mt-3 text-sm leading-6 text-[var(--color-text-muted)]">
Отдельные заказы из наборов доставки, где нужно согласование, перенос или ручная
реакция на исключения.
</p>
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{logisticsOrders.map((order) => (
<button
key={order.id}
className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-5 text-left transition hover:bg-[var(--color-accent-soft)]"
onClick={() => setSelectedOrderId(order.id)}
type="button"
>
<div className="font-semibold">{order.orderNumber}</div>
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
{order.customer.name} · {order.customer.messenger}
</p>
<p className="mt-3 text-sm text-[var(--color-text)]">{order.status}</p>
</button>
))}
</div>
</Panel>
</div>
);
}
if (activeSection === "references") {
return (
<div className="space-y-6 xl:space-y-8">
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<Panel className="p-6">
<h3 className="text-lg font-semibold">Статусы заказа</h3>
<div className="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{ORDER_STATUSES.map((status, index) => (
<div
key={status}
className="rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4"
>
<div className="text-xs uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Шаг {index + 1}
</div>
<div className="mt-3 text-sm font-medium">{status}</div>
<div className="mt-2 text-sm leading-6 text-[var(--color-text-muted)]">
{getOrderStatusComment(status)}
</div>
</div>
))}
</div>
</Panel>
<Panel className="p-6">
<h3 className="text-lg font-semibold">Роли и зоны ответственности</h3>
<div className="mt-5 space-y-4">
{Object.entries(ROLE_LABELS).map(([roleKey, roleLabel]) => (
<div
key={roleKey}
className="rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4"
>
<div className="font-medium">{roleLabel}</div>
<div className="mt-3 flex flex-wrap gap-2">
{ROLE_PERMISSIONS[roleKey].map((permission) => (
<span
key={permission}
className="rounded-full bg-[var(--color-accent-soft)] px-3 py-1 text-xs text-[var(--color-text)]"
>
{permission}
</span>
))}
</div>
</div>
))}
</div>
</Panel>
</div>
</div>
);
}
if (activeSection === "deliveries") {
return (
<div className="space-y-6 xl:space-y-8">
<DriverDeliveryPlanner
orders={driverOrders}
filters={driverFilters}
setFilters={setDriverFilters}
onOpenOrder={openOrderModal}
onStatusChange={(orderId, nextStatus) => updateStatus(orderId, nextStatus, user.name)}
onReorder={(orderedIds) =>
saveDriverRouteOrder({
orderedIds,
actorName: user.name,
})
}
/>
</div>
);
}
return (
<div className="space-y-6 xl:space-y-8">
<div className="grid gap-6 xl:grid-cols-[1fr_1fr]">
<AuditPanel order={selectedOrder} />
<UserDirectoryPanel currentUser={user} users={users} />
</div>
<UserOnboardingPanel />
</div>
);
};
return (
<AppShell
user={user}
onSignOut={signOut}
navItems={navItems}
activeSection={activeSection}
onSectionChange={setActiveSection}
sectionMeta={sectionMeta}
>
{isLoading ? (
<Panel className="border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
Загружаем данные из Supabase...
</Panel>
) : null}
{isSupabaseBacked ? (
<Panel className="border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
Данные загружены из Supabase, живой контур активен.
</Panel>
) : null}
{loadError ? (
<Panel className="border border-dashed border-[var(--color-danger)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-danger)]">
{loadError}
</Panel>
) : null}
{renderActiveTab()}
<Modal isOpen={isOrderModalOpen} onClose={() => setIsOrderModalOpen(false)}>
{user.role === "driver" ? (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-xl font-semibold">Карточка доставки</h3>
<p className="text-sm text-[var(--color-text-muted)]">
Только нужные водителю данные: адрес, клиент, состав заказа и быстрые действия.
</p>
</div>
<Button variant="ghost" onClick={() => setIsOrderModalOpen(false)}>
Закрыть
</Button>
</div>
<DriverDeliveryDetail
order={selectedOrder}
onStatusChange={(nextStatus) =>
selectedOrder && updateStatus(selectedOrder.id, nextStatus, user.name)
}
users={users}
/>
</div>
) : (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-xl font-semibold">Карточка заказа</h3>
<p className="text-sm text-[var(--color-text-muted)]">
Просмотр без потери контекста списка. При необходимости можно раскрыть в рабочую область.
</p>
</div>
<div className="flex flex-wrap gap-3">
<Button
variant="secondary"
onClick={() => {
setIsOrderModalOpen(false);
setActiveSection("orders");
setIsOrderWorkspaceExpanded(true);
}}
>
Раскрыть полностью
</Button>
<Button variant="ghost" onClick={() => setIsOrderModalOpen(false)}>
Закрыть
</Button>
</div>
</div>
{renderOrderWorkspace(selectedOrder, false)}
</div>
)}
</Modal>
</AppShell>
);
};