999 lines
42 KiB
JavaScript
999 lines
42 KiB
JavaScript
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>
|
||
);
|
||
};
|