diff --git a/package.json b/package.json index ae404e9..363ec1f 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "build": "vite build", "preview": "vite preview", "lint": "eslint . --ext js,jsx", - "test": "vitest run" + "test": "vitest run", + "anonymize:1c-xml": "node scripts/anonymize-1c-xml.mjs" }, "dependencies": { "@supabase/supabase-js": "^2.52.0", diff --git a/scripts/anonymize-1c-xml.mjs b/scripts/anonymize-1c-xml.mjs new file mode 100644 index 0000000..4ea5887 --- /dev/null +++ b/scripts/anonymize-1c-xml.mjs @@ -0,0 +1,42 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { anonymize1cXml, decodeXmlBuffer } from "../src/utils/anonymize1cXml.js"; + +const printUsage = () => { + process.stdout.write("Usage: node scripts/anonymize-1c-xml.mjs [output.xml]\n"); +}; + +const buildDefaultOutputPath = (inputPath) => { + const parsed = path.parse(inputPath); + return path.join(parsed.dir, `${parsed.name}.anonymized${parsed.ext || ".xml"}`); +}; + +const main = async () => { + const [, , inputPathArg, outputPathArg] = process.argv; + + if (!inputPathArg) { + printUsage(); + process.exitCode = 1; + return; + } + + const inputPath = path.resolve(process.cwd(), inputPathArg); + const outputPath = path.resolve(process.cwd(), outputPathArg || buildDefaultOutputPath(inputPath)); + + if (inputPath === outputPath) { + throw new Error("Output path must be different from input path."); + } + + const sourceXml = decodeXmlBuffer(await fs.readFile(inputPath)); + const anonymizedXml = anonymize1cXml(sourceXml); + + await fs.writeFile(outputPath, anonymizedXml, "utf8"); + + process.stdout.write(`Anonymized XML saved to ${outputPath}\n`); +}; + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/src/components/UI/Modal.jsx b/src/components/UI/Modal.jsx index 9f8ad2d..b41bc6c 100644 --- a/src/components/UI/Modal.jsx +++ b/src/components/UI/Modal.jsx @@ -22,11 +22,11 @@ export const Modal = ({ children, isOpen, onClose, className }) => { } return ( -
+
diff --git a/src/components/UI/Panel.jsx b/src/components/UI/Panel.jsx index 76aa76f..0959cb6 100644 --- a/src/components/UI/Panel.jsx +++ b/src/components/UI/Panel.jsx @@ -1,13 +1,14 @@ import React from "react"; import { cn } from "../../lib/cn"; -export const Panel = ({ children, className }) => { +export const Panel = ({ children, className, ...props }) => { return (
{children}
diff --git a/src/components/auth/OtpLoginForm.jsx b/src/components/auth/OtpLoginForm.jsx index 01f284b..bbb6767 100644 --- a/src/components/auth/OtpLoginForm.jsx +++ b/src/components/auth/OtpLoginForm.jsx @@ -20,12 +20,14 @@ export const OtpLoginForm = ({ error, }) => { return ( - -
+ +

Платформа доставки

-

Вход по email и коду

+

+ Вход по email и коду +

Введите email, и код придет на почту. В рабочем режиме доступ определяется учетной записью в системе, а не выбором роли. diff --git a/src/components/orders/OrderDetailPanel.jsx b/src/components/orders/OrderDetailPanel.jsx index 401e434..b9badc5 100644 --- a/src/components/orders/OrderDetailPanel.jsx +++ b/src/components/orders/OrderDetailPanel.jsx @@ -1,12 +1,12 @@ import React from "react"; import { ROLE_PERMISSIONS } from "../../constants/roles"; import { - getAvailableTransitionsByRole, getDeliveryAgreementComment, getOrderStatusComment, getStatusTone, } from "../../constants/deliveryWorkflow"; import { demoUsers } from "../../data/mockAppData"; +import { getAvailableTransitionsForOrder } from "../../services/orderService"; import { formatDateTime } from "../../utils/formatters"; import { Badge } from "../UI/Badge"; import { Button } from "../UI/Button"; @@ -29,11 +29,13 @@ export const OrderDetailPanel = ({ order, currentUser, onStatusChange, + onAssignDriver, onClientMessage, onInternalMessage, onOrderNote, }) => { const [nextStatus, setNextStatus] = React.useState(order?.status || "Новый"); + const [selectedDriverId, setSelectedDriverId] = React.useState(order?.assignedDriverId || ""); const [clientReply, setClientReply] = React.useState("Подтверждаю доставку"); const [chatQuery, setChatQuery] = React.useState(""); const [activeTab, setActiveTab] = React.useState("overview"); @@ -42,6 +44,7 @@ export const OrderDetailPanel = ({ React.useEffect(() => { setNextStatus(order?.status || "Новый"); + setSelectedDriverId(order?.assignedDriverId || ""); setChatQuery(""); setActiveTab("overview"); setTeamReply("Новый комментарий для команды"); @@ -68,10 +71,12 @@ export const OrderDetailPanel = ({ { key: "chat", label: "Чат с клиентом" }, { key: "team", label: "Команда" }, ]; - const availableTransitions = getAvailableTransitionsByRole({ - status: order.status, + const availableTransitions = getAvailableTransitionsForOrder({ + order, role: currentUser.role, }); + const canAssignDriver = currentUser.role === "logistician" || currentUser.role === "admin"; + const drivers = demoUsers.filter((user) => user.role === "driver"); return (

@@ -116,7 +121,7 @@ export const OrderDetailPanel = ({ {activeTab === "overview" ? (
-
+
Данные клиента @@ -172,7 +177,7 @@ export const OrderDetailPanel = ({
-
+
Управление заказом @@ -208,6 +213,32 @@ export const OrderDetailPanel = ({
+ {canAssignDriver ? ( +
+
+
Назначение водителя
+

+ Выберите водителя для передачи заказа в этап доставки. +

+
+ + +
+ ) : null} +
Для вашей роли доступны типовые действия:
diff --git a/src/components/orders/OrderEditorPanel.jsx b/src/components/orders/OrderEditorPanel.jsx index 2e3893e..280bb94 100644 --- a/src/components/orders/OrderEditorPanel.jsx +++ b/src/components/orders/OrderEditorPanel.jsx @@ -5,14 +5,15 @@ import { Input } from "../UI/Input"; import { Panel } from "../UI/Panel"; import { Select } from "../UI/Select"; -const managerOptions = demoUsers.filter((user) => user.role === "manager" || user.role === "admin"); +const getUsers = (users) => (Array.isArray(users) && users.length ? users : demoUsers); +const getManagerOptions = (users) => getUsers(users).filter((user) => user.role === "manager" || user.role === "admin"); const initialForm = { orderNumber: "", customerName: "", customerPhone: "", customerAddress: "", messenger: "Телеграм", - managerId: managerOptions[0]?.id || "", + managerId: "", deliveryDate: "", items: "", comments: "", @@ -26,10 +27,12 @@ export const OrderEditorPanel = ({ onSaveOrder, createOnly = false, onDone, + users, }) => { const [form, setForm] = React.useState(initialForm); const [isCreateMode, setIsCreateMode] = React.useState(createOnly); const canManageOrders = currentUser.role === "manager" || currentUser.role === "admin"; + const managerOptions = getManagerOptions(users); React.useEffect(() => { if (!createOnly) { @@ -56,10 +59,10 @@ export const OrderEditorPanel = ({ customerAddress: selectedOrder.customer.address, messenger: selectedOrder.customer.messenger, managerId: selectedOrder.managerId, - deliveryDate: selectedOrder.deliverySlots[0]?.date || "", + deliveryDate: selectedOrder.deliverySlots?.[0]?.date || "", items: (selectedOrder.items || []).join("\n"), - comments: selectedOrder.comments.join(", "), - tags: selectedOrder.tags.join(", "), + comments: (selectedOrder.comments || []).join(", "), + tags: (selectedOrder.tags || []).join(", "), }); }, [isCreateMode, selectedOrder]); @@ -112,18 +115,18 @@ export const OrderEditorPanel = ({ }; return ( - +

Управление заказом

- Создание и редактирование заказа с полями клиента, канала связи и даты доставки. + Редактирование импортированного из 1С заказа с полями клиента, канала связи и даты доставки.

{!createOnly ? (
+
+ +
); }; diff --git a/src/components/orders/OrderFilters.jsx b/src/components/orders/OrderFilters.jsx index 30e442d..d124fd1 100644 --- a/src/components/orders/OrderFilters.jsx +++ b/src/components/orders/OrderFilters.jsx @@ -1,6 +1,10 @@ import React from "react"; +import { WORKFLOW_STAGES } from "../../constants/deliveryWorkflow"; import { ORDER_STATUSES } from "../../constants/orderStatuses"; +import { ROLE_LABELS } from "../../constants/roles"; import { demoUsers } from "../../data/mockAppData"; +import { Badge } from "../UI/Badge"; +import { Button } from "../UI/Button"; import { Input } from "../UI/Input"; import { Panel } from "../UI/Panel"; import { Select } from "../UI/Select"; @@ -8,74 +12,191 @@ import { Select } from "../UI/Select"; const logisticians = demoUsers.filter((user) => user.role === "logistician"); const managers = demoUsers.filter((user) => user.role === "manager"); const messengers = ["Телеграм", "ВКонтакте", "Макс", "СМС", "Эл. почта"]; +const responsibilityRoles = Object.entries(ROLE_LABELS).filter(([role]) => role !== "admin"); +const agingOptions = [ + { key: "warning", label: "Требуют внимания" }, + { key: "critical", label: "Просрочены" }, +]; export const OrderFilters = ({ filters, setFilters }) => { + const [isMobileFiltersOpen, setIsMobileFiltersOpen] = React.useState(false); + + const activeChips = [ + filters.status !== "all" ? { key: "status", label: filters.status } : null, + filters.stage !== "all" + ? { key: "stage", label: WORKFLOW_STAGES.find((stage) => stage.key === filters.stage)?.label } + : null, + filters.ownerRole !== "all" ? { key: "ownerRole", label: ROLE_LABELS[filters.ownerRole] } : null, + filters.agingState !== "all" + ? { key: "agingState", label: agingOptions.find((option) => option.key === filters.agingState)?.label } + : null, + filters.managerId !== "all" + ? { key: "managerId", label: managers.find((manager) => manager.id === filters.managerId)?.name } + : null, + filters.logisticianId !== "all" + ? { + key: "logisticianId", + label: logisticians.find((logistician) => logistician.id === filters.logisticianId)?.name, + } + : null, + filters.messenger !== "all" ? { key: "messenger", label: filters.messenger } : null, + ].filter(Boolean); + + const updateFilter = (key, value) => { + setFilters((current) => ({ ...current, [key]: value })); + }; + + const renderFilterField = (label, control, showLabel = false) => ( +
+ {showLabel ? ( + + {label} + + ) : null} + {control} +
+ ); + + const renderAdvancedFilters = ({ className = "", showLabels = false } = {}) => ( +
+
+ {renderFilterField( + "Статус", + , + showLabels, + )} + + {renderFilterField( + "Этап", + , + showLabels, + )} + + {renderFilterField( + "Ответственный отдел", + , + showLabels, + )} + + {renderFilterField( + "SLA", + , + showLabels, + )} + + {renderFilterField( + "Менеджер", + , + showLabels, + )} + + {renderFilterField( + "Логист", + , + showLabels, + )} + + {renderFilterField( + "Канал", + , + showLabels, + )} +
+
+ ); + return ( -
- - setFilters((current) => ({ ...current, query: event.target.value })) - } - /> +
+
+ updateFilter("query", event.target.value)} + /> + +
+
+
+ Активные фильтры +
+
+ {activeChips.length ? activeChips.map((chip) => {chip.label}) : Нет} +
+
+ {isMobileFiltersOpen + ? renderAdvancedFilters({ + className: "rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-3", + }) + : null} +
- - - - - - - +
+
+ updateFilter("query", event.target.value)} + /> +
+
Активные фильтры
+
+ {activeChips.length ? activeChips.map((chip) => {chip.label}) : Нет} +
+
+
+ {renderAdvancedFilters({ showLabels: true })}
); diff --git a/src/components/orders/OrdersCalendarView.jsx b/src/components/orders/OrdersCalendarView.jsx index 6375b42..014e9ca 100644 --- a/src/components/orders/OrdersCalendarView.jsx +++ b/src/components/orders/OrdersCalendarView.jsx @@ -62,6 +62,13 @@ export const OrdersCalendarView = ({ orders, onOpenOrder }) => { }, {}), [orders], ); + const agendaDays = React.useMemo( + () => + Object.entries(ordersByDay) + .sort(([left], [right]) => new Date(left) - new Date(right)) + .map(([key, dayOrders]) => ({ key, dayOrders })), + [ordersByDay], + ); return ( @@ -85,61 +92,93 @@ export const OrdersCalendarView = ({ orders, onOpenOrder }) => {
-
- {WEEK_DAYS.map((day) => ( -
- {day} +
+
Заказы по дням
+ {agendaDays.map(({ key, dayOrders }) => ( +
+
+
+ {new Date(`${key}T00:00:00`).toLocaleDateString("ru-RU", { + day: "numeric", + month: "long", + })} +
+
{dayOrders.length}
+
+
+ {dayOrders.map((order) => ( + + ))} +
))}
-
- {calendarDays.map((day, index) => { - if (!day) { +
+
+ {WEEK_DAYS.map((day) => ( +
+ {day} +
+ ))} +
+ +
+ {calendarDays.map((day, index) => { + if (!day) { + return ( +
+ ); + } + + const key = formatDayKey(day); + const dayOrders = ordersByDay[key] || []; + return (
- ); - } + key={key} + className="min-h-[132px] rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-3" + > +
+
{day.getDate()}
+
{dayOrders.length || ""}
+
- const key = formatDayKey(day); - const dayOrders = ordersByDay[key] || []; - - return ( -
-
-
{day.getDate()}
-
{dayOrders.length || ""}
-
- -
- {dayOrders.slice(0, 2).map((order) => ( - + ))} + {dayOrders.length > 2 ? ( +
+ Ещё {dayOrders.length - 2}
- - ))} - {dayOrders.length > 2 ? ( -
- Ещё {dayOrders.length - 2} -
- ) : null} + ) : null} +
-
- ); - })} + ); + })} +
); diff --git a/src/components/orders/OrdersTable.jsx b/src/components/orders/OrdersTable.jsx index 496dfe0..7f35150 100644 --- a/src/components/orders/OrdersTable.jsx +++ b/src/components/orders/OrdersTable.jsx @@ -25,7 +25,35 @@ export const OrdersTable = ({ orders, selectedOrderId, onOpenOrder }) => { {orders.length}
-
+
+ {orders.map((order) => ( + + ))} +
+ +
diff --git a/src/constants/deliveryWorkflow.js b/src/constants/deliveryWorkflow.js index 7ac1f6c..f4d702d 100644 --- a/src/constants/deliveryWorkflow.js +++ b/src/constants/deliveryWorkflow.js @@ -1,77 +1,175 @@ +export const WORKFLOW_STAGES = [ + { key: "manager", label: "Менеджер" }, + { key: "production", label: "Производство" }, + { key: "logistics", label: "Логистика" }, + { key: "delivery", label: "Доставка" }, + { key: "completed", label: "Завершено" }, +]; + +const getStageLabel = (stageKey) => + WORKFLOW_STAGES.find((stage) => stage.key === stageKey)?.label || "Без этапа"; + export const ORDER_STATUS_META = { "Новый": { comment: "Заказ создан и ожидает проверки менеджером.", ownerRole: "manager", + stageKey: "manager", + stageLabel: getStageLabel("manager"), + warningAfterHours: 24, + criticalAfterHours: 48, tone: "neutral", }, "Требует уточнения": { comment: "В заказе не хватает данных, их должен уточнить менеджер.", ownerRole: "manager", + stageKey: "manager", + stageLabel: getStageLabel("manager"), + warningAfterHours: 12, + criticalAfterHours: 24, tone: "warning", }, "Подтверждён менеджером": { comment: "Менеджер проверил заказ и передал его дальше в работу.", ownerRole: "manager", + stageKey: "manager", + stageLabel: getStageLabel("manager"), + warningAfterHours: 12, + criticalAfterHours: 24, tone: "accent", }, "В очереди производства": { comment: "Заказ передан на производство и ожидает запуска.", ownerRole: "production_lead", + stageKey: "production", + stageLabel: getStageLabel("production"), + warningAfterHours: 24, + criticalAfterHours: 48, tone: "neutral", }, "В производстве": { comment: "Заказ находится в изготовлении.", ownerRole: "production_lead", + stageKey: "production", + stageLabel: getStageLabel("production"), + warningAfterHours: 48, + criticalAfterHours: 96, tone: "accent", }, "Готов к отгрузке": { comment: "Производство завершено, можно запускать согласование доставки.", ownerRole: "production_lead", + stageKey: "production", + stageLabel: getStageLabel("production"), + warningAfterHours: 8, + criticalAfterHours: 24, tone: "accent", }, + "Ожидает ответа клиента": { + comment: "Клиенту отправлена ссылка, система ждёт подтверждения времени доставки.", + ownerRole: "logistician", + stageKey: "logistics", + stageLabel: getStageLabel("logistics"), + warningAfterHours: 1, + criticalAfterHours: 3, + tone: "warning", + }, "Ожидает согласования доставки": { comment: "Клиенту отправлено предложение выбрать дату и половину дня доставки.", ownerRole: "logistician", + stageKey: "logistics", + stageLabel: getStageLabel("logistics"), + warningAfterHours: 24, + criticalAfterHours: 96, tone: "warning", }, "Доставка согласована": { comment: "Клиент подтвердил доставку, логист может назначать рейс.", ownerRole: "logistician", + stageKey: "logistics", + stageLabel: getStageLabel("logistics"), + warningAfterHours: 12, + criticalAfterHours: 24, tone: "accent", }, + "Передан логисту": { + comment: "Согласование не завершилось автоматически, заказ передан логисту для ручной работы.", + ownerRole: "logistician", + stageKey: "logistics", + stageLabel: getStageLabel("logistics"), + warningAfterHours: 4, + criticalAfterHours: 12, + tone: "warning", + }, "Назначен водитель": { comment: "Логист распределил заказ на конкретного водителя.", ownerRole: "logistician", + stageKey: "delivery", + stageLabel: getStageLabel("delivery"), + warningAfterHours: 12, + criticalAfterHours: 24, tone: "accent", }, Загружен: { comment: "Заказ физически загружен в транспорт.", ownerRole: "driver", + stageKey: "delivery", + stageLabel: getStageLabel("delivery"), + warningAfterHours: 8, + criticalAfterHours: 24, tone: "neutral", }, "В пути": { comment: "Водитель выехал и выполняет доставку.", ownerRole: "driver", + stageKey: "delivery", + stageLabel: getStageLabel("delivery"), + warningAfterHours: 12, + criticalAfterHours: 24, tone: "accent", }, Доставлен: { comment: "Заказ успешно передан клиенту.", ownerRole: "driver", + stageKey: "completed", + stageLabel: getStageLabel("completed"), + warningAfterHours: null, + criticalAfterHours: null, tone: "accent", }, Закрыт: { comment: "Цикл заказа завершён и больше не требует действий.", ownerRole: "logistician", + stageKey: "completed", + stageLabel: getStageLabel("completed"), + warningAfterHours: null, + criticalAfterHours: null, tone: "neutral", }, Отменён: { comment: "Заказ отменён и выведен из процесса.", ownerRole: "manager", + stageKey: "completed", + stageLabel: getStageLabel("completed"), + warningAfterHours: null, + criticalAfterHours: null, tone: "danger", }, "Проблема доставки": { comment: "На этапе доставки возникла проблема и нужен ручной разбор.", ownerRole: "logistician", + stageKey: "logistics", + stageLabel: getStageLabel("logistics"), + warningAfterHours: 12, + criticalAfterHours: 48, + tone: "danger", + }, + "Платное хранение": { + comment: "Согласование доставки не достигнуто, заказ переведен на платное хранение.", + ownerRole: "logistician", + stageKey: "logistics", + stageLabel: getStageLabel("logistics"), + warningAfterHours: 24, + criticalAfterHours: 72, tone: "danger", }, }; @@ -110,26 +208,32 @@ export const ORDER_STATUS_TRANSITIONS = { "Подтверждён менеджером": ["В очереди производства", "Требует уточнения", "Отменён"], "В очереди производства": ["В производстве", "Требует уточнения", "Отменён"], "В производстве": ["Готов к отгрузке", "Требует уточнения", "Отменён"], - "Готов к отгрузке": ["Ожидает согласования доставки", "Проблема доставки", "Отменён"], + "Готов к отгрузке": ["Ожидает согласования доставки", "Ожидает ответа клиента", "Проблема доставки", "Отменён"], + "Ожидает ответа клиента": ["Доставка согласована", "Передан логисту", "Платное хранение", "Проблема доставки", "Отменён"], "Ожидает согласования доставки": ["Доставка согласована", "Проблема доставки", "Отменён"], "Доставка согласована": ["Назначен водитель", "Ожидает согласования доставки", "Проблема доставки"], + "Передан логисту": ["Доставка согласована", "Платное хранение", "Проблема доставки", "Отменён"], "Назначен водитель": ["Загружен", "Проблема доставки"], Загружен: ["В пути", "Проблема доставки"], "В пути": ["Доставлен", "Проблема доставки"], Доставлен: ["Закрыт"], "Проблема доставки": ["Ожидает согласования доставки", "Назначен водитель", "Отменён", "Закрыт"], + "Платное хранение": ["Доставка согласована", "Отменён", "Закрыт"], Закрыт: [], Отменён: [], }; export const ROLE_TRANSITION_TARGETS = { - manager: ["Новый", "Требует уточнения", "Подтверждён менеджером", "В очереди производства", "Отменён"], + manager: ORDER_STATUSES, production_lead: ["В очереди производства", "В производстве", "Готов к отгрузке", "Требует уточнения", "Отменён"], logistician: [ + "Ожидает ответа клиента", "Ожидает согласования доставки", "Доставка согласована", + "Передан логисту", "Назначен водитель", "Проблема доставки", + "Платное хранение", "Закрыт", "Отменён", ], @@ -160,6 +264,17 @@ export const getDeliveryAgreementComment = (status) => export const getStatusTone = (status) => ORDER_STATUS_META[status]?.tone || "neutral"; +export const getStatusOwnerRole = (status) => ORDER_STATUS_META[status]?.ownerRole || null; + +export const getStatusStageKey = (status) => ORDER_STATUS_META[status]?.stageKey || null; + +export const getStatusStageLabel = (status) => ORDER_STATUS_META[status]?.stageLabel || "Без этапа"; + +export const getStatusSla = (status) => ({ + warningAfterHours: ORDER_STATUS_META[status]?.warningAfterHours ?? null, + criticalAfterHours: ORDER_STATUS_META[status]?.criticalAfterHours ?? null, +}); + export const getAvailableTransitionsByRole = ({ status, role }) => { const nextStatuses = ORDER_STATUS_TRANSITIONS[status] || []; const allowedTargets = ROLE_TRANSITION_TARGETS[role] || []; diff --git a/src/data/mockAppData.js b/src/data/mockAppData.js index 2ea306d..438c432 100644 --- a/src/data/mockAppData.js +++ b/src/data/mockAppData.js @@ -79,7 +79,7 @@ export const demoUsers = [ }, ]; -export const demoOrders = [ +const baseDemoOrders = [ { id: "o-1001", orderNumber: "CD-240031", @@ -622,6 +622,158 @@ export const demoOrders = [ }, ]; +const extraOrderSeeds = [ + { suffix: 101, customerName: "Людмила Артемьева", status: "Новый", city: "Симферополь", item: "Шкаф распашной", messenger: "Телеграм", updatedAt: "2026-03-14T08:20:00Z" }, + { suffix: 102, customerName: "Павел Карпов", status: "Новый", city: "Ялта", item: "Стол обеденный", messenger: "ВКонтакте", updatedAt: "2026-03-14T10:10:00Z" }, + { suffix: 103, customerName: "Алёна Беспалова", status: "Требует уточнения", city: "Евпатория", item: "Тумба ТВ", messenger: "СМС", updatedAt: "2026-03-14T06:40:00Z" }, + { suffix: 104, customerName: "Георгий Храмов", status: "Требует уточнения", city: "Севастополь", item: "Комод", messenger: "Макс", updatedAt: "2026-03-13T17:50:00Z" }, + { suffix: 105, customerName: "Валерия Фролова", status: "Подтверждён менеджером", city: "Симферополь", item: "Кухонный фасад", messenger: "Телеграм", updatedAt: "2026-03-14T11:35:00Z" }, + { suffix: 106, customerName: "Иван Мирошниченко", status: "Подтверждён менеджером", city: "Алушта", item: "Шкаф-купе", messenger: "Эл. почта", updatedAt: "2026-03-14T09:25:00Z" }, + { suffix: 107, customerName: "Марина Ермакова", status: "В очереди производства", city: "Симферополь", item: "Столешница", messenger: "Телеграм", updatedAt: "2026-03-13T12:10:00Z" }, + { suffix: 108, customerName: "Руслан Гладков", status: "В очереди производства", city: "Ялта", item: "Гардеробная секция", messenger: "ВКонтакте", updatedAt: "2026-03-13T08:00:00Z" }, + { suffix: 109, customerName: "Светлана Коваль", status: "В очереди производства", city: "Севастополь", item: "Дверь межкомнатная", messenger: "СМС", updatedAt: "2026-03-12T13:45:00Z" }, + { suffix: 110, customerName: "Михаил Орлов", status: "В производстве", city: "Симферополь", item: "Кухня линейная", messenger: "Телеграм", updatedAt: "2026-03-13T09:30:00Z" }, + { suffix: 111, customerName: "Татьяна Шубина", status: "В производстве", city: "Ялта", item: "Стеллаж", messenger: "Макс", updatedAt: "2026-03-12T15:20:00Z" }, + { suffix: 112, customerName: "Андрей Беляев", status: "В производстве", city: "Евпатория", item: "Фасады МДФ", messenger: "Эл. почта", updatedAt: "2026-03-14T07:55:00Z" }, + { suffix: 113, customerName: "Елена Бондарь", status: "Готов к отгрузке", city: "Симферополь", item: "Пенал для кухни", messenger: "Телеграм", updatedAt: "2026-03-14T05:15:00Z" }, + { suffix: 114, customerName: "Кирилл Нестеров", status: "Готов к отгрузке", city: "Ялта", item: "Стол письменный", messenger: "СМС", updatedAt: "2026-03-14T09:45:00Z" }, + { suffix: 115, customerName: "Наталья Зотова", status: "Готов к отгрузке", city: "Севастополь", item: "Шкаф угловой", messenger: "ВКонтакте", updatedAt: "2026-03-13T18:05:00Z" }, + { suffix: 116, customerName: "Константин Матвеев", status: "Ожидает согласования доставки", city: "Симферополь", item: "Комод высокий", messenger: "Телеграм", updatedAt: "2026-03-14T08:05:00Z" }, + { suffix: 117, customerName: "Лариса Шевцова", status: "Ожидает согласования доставки", city: "Ялта", item: "Стеллаж модульный", messenger: "Макс", updatedAt: "2026-03-13T06:50:00Z" }, + { suffix: 118, customerName: "Евгений Филимонов", status: "Ожидает согласования доставки", city: "Севастополь", item: "Тумба под мойку", messenger: "СМС", updatedAt: "2026-03-12T08:15:00Z" }, + { suffix: 119, customerName: "Диана Рябова", status: "Доставка согласована", city: "Симферополь", item: "Навесной шкаф", messenger: "Телеграм", updatedAt: "2026-03-14T11:00:00Z" }, + { suffix: 120, customerName: "Олег Вишневский", status: "Доставка согласована", city: "Алушта", item: "Стол раскладной", messenger: "Эл. почта", updatedAt: "2026-03-14T04:20:00Z" }, + { suffix: 121, customerName: "Полина Исаева", status: "Назначен водитель", city: "Симферополь", item: "Кровать", messenger: "Телеграм", updatedAt: "2026-03-14T10:40:00Z" }, + { suffix: 122, customerName: "Роман Щукин", status: "Назначен водитель", city: "Ялта", item: "Прихожая", messenger: "ВКонтакте", updatedAt: "2026-03-14T09:05:00Z" }, + { suffix: 123, customerName: "Юлия Баранова", status: "Загружен", city: "Севастополь", item: "Шкаф-пенал", messenger: "СМС", updatedAt: "2026-03-14T07:30:00Z" }, + { suffix: 124, customerName: "Виктор Громыко", status: "В пути", city: "Симферополь", item: "Гарнитур в прихожую", messenger: "Телеграм", updatedAt: "2026-03-14T11:20:00Z" }, + { suffix: 125, customerName: "Инна Самойлова", status: "Доставлен", city: "Евпатория", item: "Шкаф в ванную", messenger: "Эл. почта", updatedAt: "2026-03-14T12:05:00Z" }, +]; + +const DELIVERY_STATUSES = new Set(["Назначен водитель", "Загружен", "В пути", "Доставлен", "Закрыт"]); +const LOGISTICS_OR_DELIVERY_STATUSES = new Set([ + "Ожидает согласования доставки", + "Доставка согласована", + "Назначен водитель", + "Загружен", + "В пути", + "Доставлен", + "Закрыт", +]); + +const getAgreementStatusForDemo = (status) => { + if (status === "Ожидает согласования доставки") { + return "Ожидание ответа"; + } + if (status === "Доставка согласована" || DELIVERY_STATUSES.has(status)) { + return "Подтверждено клиентом"; + } + return "Не начато"; +}; + +const getDeliverySlotStatusForDemo = (status) => { + if (status === "Ожидает согласования доставки") { + return "Ожидает подтверждения"; + } + if (status === "Доставка согласована" || status === "Назначен водитель") { + return "Подтверждён"; + } + if (status === "Загружен" || status === "В пути") { + return "В рейсе"; + } + if (status === "Доставлен" || status === "Закрыт") { + return "Завершён"; + } + return "Черновик"; +}; + +const buildExtraDemoOrder = (seed, index) => { + const logisticianId = index % 2 === 0 ? "u-logistics" : "u-logistics-2"; + const assignedDriverId = + DELIVERY_STATUSES.has(seed.status) || seed.status === "Доставка согласована" + ? "u-driver" + : null; + const scheduledDelivery = `2026-03-${String(16 + (index % 6)).padStart(2, "0")}T${index % 2 === 0 ? "09:00:00Z" : "13:00:00Z"}`; + + return { + id: `o-extra-${seed.suffix}`, + orderNumber: `CD-24${seed.suffix}`, + customer: { + name: seed.customerName, + phone: `+7 978 100-${String(seed.suffix).padStart(3, "0")}`, + messenger: seed.messenger, + address: `${seed.city}, ул. Демо, ${10 + index}`, + }, + status: seed.status, + deliveryAgreementStatus: getAgreementStatusForDemo(seed.status), + managerId: "u-manager", + logisticianIds: LOGISTICS_OR_DELIVERY_STATUSES.has(seed.status) ? [logisticianId] : [], + assignedDriverId, + driverRouteOrder: assignedDriverId ? (index % 6) + 1 : null, + createdAt: `2026-03-${String(9 + (index % 5)).padStart(2, "0")}T${String((index % 5) + 7).padStart(2, "0")}:00:00Z`, + updatedAt: seed.updatedAt, + scheduledDelivery, + items: [`${seed.item} | 1 шт`, "Комплект фурнитуры | 1 набор"], + tags: [seed.city.toLowerCase(), seed.status.toLowerCase()], + comments: [`Демо-заказ для проверки нагрузки на статусе «${seed.status}».`], + orderNotes: [ + { + id: `note-extra-${seed.suffix}`, + authorName: "Система", + text: `Контрольная демо-запись для этапа «${seed.status}».`, + createdAt: seed.updatedAt, + }, + ], + history: [ + { + id: `history-extra-${seed.suffix}`, + action: "Демо-переход", + oldStatus: null, + newStatus: seed.status, + userName: "Система", + at: seed.updatedAt, + }, + ], + chatMessages: + seed.status === "Ожидает согласования доставки" + ? [ + { + id: `chat-extra-${seed.suffix}`, + sender: "bot", + channel: seed.messenger, + text: `Клиенту отправлено согласование по заказу CD-24${seed.suffix}.`, + sentAt: seed.updatedAt, + }, + ] + : [], + internalMessages: [ + { + id: `internal-extra-${seed.suffix}`, + senderId: logisticianId, + senderName: logisticianId === "u-logistics" ? "Ольга Синицына" : "Павел Миронов", + text: `Демо-комментарий для статуса «${seed.status}».`, + sentAt: seed.updatedAt, + }, + ], + deliverySlots: LOGISTICS_OR_DELIVERY_STATUSES.has(seed.status) + ? [ + { + id: `slot-extra-${seed.suffix}`, + date: scheduledDelivery.slice(0, 10), + time: index % 2 === 0 ? "Первая половина дня" : "Вторая половина дня", + logisticianId, + status: getDeliverySlotStatusForDemo(seed.status), + }, + ] + : [], + exception: seed.status === "Проблема доставки" ? "Требуется ручной разбор логистом" : null, + }; +}; + +const extraDemoOrders = extraOrderSeeds.map(buildExtraDemoOrder); + +export const demoOrders = [...baseDemoOrders, ...extraDemoOrders]; + export const demoNotifications = [ { id: "n-1", diff --git a/src/hooks/useOrders.js b/src/hooks/useOrders.js index 838245c..3d80517 100644 --- a/src/hooks/useOrders.js +++ b/src/hooks/useOrders.js @@ -1,10 +1,13 @@ import React from "react"; import { getOrderStatusComment } from "../constants/deliveryWorkflow"; import { demoNotifications, demoOrders, demoUsers } from "../data/mockAppData"; +import { fetchUsers } from "../services/supabase/userRepository"; +import { fetchOrders, enrichOrdersWithUsers } from "../services/supabase/orderRepository"; import { reorderDriverDeliveries, } from "../services/driverDeliveries"; import { + assignDriverToOrder, applyDeliveryReschedule, applyStatusUpdate, appendChatMessageToOrder, @@ -17,37 +20,100 @@ import { filterOrdersByView, updateOrderDetails, } from "../services/orderService"; +import { getOrderAgingState } from "../services/orderViews"; +import { groupOrdersIntoDeliverySets, DELIVERY_SET_BUCKET_LABELS } from "../services/deliverySetViews"; +import { hasSupabaseConfig } from "../supabaseClient"; + +const cloneLiveUsers = (users) => (Array.isArray(users) ? users.map((user) => ({ ...user })) : []); export const useOrders = (currentUser) => { - const [orders, setOrders] = React.useState(() => cloneOrders(demoOrders)); + const [orders, setOrders] = React.useState(() => + hasSupabaseConfig ? [] : cloneOrders(demoOrders), + ); + const [users, setUsers] = React.useState(() => + hasSupabaseConfig ? [] : cloneLiveUsers(demoUsers), + ); const [filters, setFilters] = React.useState({ query: "", status: "all", + stage: "all", + ownerRole: "all", + agingState: "all", managerId: "all", logisticianId: "all", messenger: "all", }); - const [selectedOrderId, setSelectedOrderId] = React.useState(demoOrders[0]?.id ?? null); + const [selectedOrderId, setSelectedOrderId] = React.useState(() => + hasSupabaseConfig ? null : demoOrders[0]?.id ?? null, + ); const [notifications, setNotifications] = React.useState(demoNotifications); + const [isSupabaseBacked, setIsSupabaseBacked] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(hasSupabaseConfig); + const [loadError, setLoadError] = React.useState(""); - const visibleOrders = React.useMemo(() => { - return orders.filter((order) => { - if (currentUser?.role === "manager" && order.managerId !== currentUser.id) { - return false; - } - if (currentUser?.role === "logistician" && !order.logisticianIds.includes(currentUser.id)) { - return false; - } - if (currentUser?.role === "driver" && order.assignedDriverId !== currentUser.id) { - return false; - } - return true; - }); - }, [currentUser, orders]); + React.useEffect(() => { + let cancelled = false; - const filteredOrders = React.useMemo(() => { - return filterOrdersByView({ orders, currentUser, filters }).filteredOrders; + const loadLiveData = async () => { + if (!hasSupabaseConfig) { + setOrders([]); + setUsers([]); + setIsSupabaseBacked(false); + setIsLoading(false); + setLoadError(""); + return; + } + + setIsLoading(true); + setLoadError(""); + + const [usersResult, ordersResult] = await Promise.all([fetchUsers(), fetchOrders()]); + + if (cancelled) { + return; + } + + if (usersResult.error || ordersResult.error) { + const error = usersResult.error || ordersResult.error; + setLoadError(error?.message || "Не удалось загрузить данные Supabase"); + setOrders([]); + setUsers([]); + setIsSupabaseBacked(false); + setIsLoading(false); + return; + } + + const liveUsers = usersResult.data || []; + const liveOrders = enrichOrdersWithUsers(ordersResult.data || [], liveUsers); + + setUsers(liveUsers); + setOrders(liveOrders); + setIsSupabaseBacked(true); + setIsLoading(false); + }; + + loadLiveData(); + + return () => { + cancelled = true; + }; + }, []); + + React.useEffect(() => { + if (!orders.length) { + return; + } + + if (!selectedOrderId || !orders.some((order) => order.id === selectedOrderId)) { + setSelectedOrderId(orders[0].id); + } + }, [orders, selectedOrderId]); + + const orderView = React.useMemo(() => { + return filterOrdersByView({ orders, currentUser, filters }); }, [currentUser, filters, orders]); + const visibleOrders = orderView.visibleOrders; + const filteredOrders = orderView.filteredOrders; const selectedOrder = filteredOrders.find((order) => order.id === selectedOrderId) || @@ -55,6 +121,8 @@ export const useOrders = (currentUser) => { filteredOrders[0] || null; + const userMap = React.useMemo(() => new Map(users.map((user) => [user.id, user])), [users]); + React.useEffect(() => { if (!selectedOrder && filteredOrders[0]) { setSelectedOrderId(filteredOrders[0].id); @@ -164,8 +232,25 @@ export const useOrders = (currentUser) => { [updateOrder], ); + const assignDriver = React.useCallback( + ({ orderId, driverId, actorName }) => { + updateOrder( + orderId, + (order) => assignDriverToOrder(order, driverId, actorName), + (order) => ({ + id: `notification-${Date.now()}`, + type: "success", + title: driverId ? "Водитель назначен" : "Водитель снят", + description: `${order.orderNumber}: карточка доставки обновлена`, + }), + ); + }, + [updateOrder], + ); + const autoAssignLogisticians = React.useCallback(() => { - const logisticians = demoUsers.filter((user) => user.role === "logistician"); + const sourceUsers = users.length ? users : demoUsers; + const logisticians = sourceUsers.filter((user) => user.role === "logistician"); setOrders((current) => autoAssignOrders(current, logisticians)); appendNotification({ id: `notification-${Date.now()}`, @@ -173,7 +258,7 @@ export const useOrders = (currentUser) => { title: "Автораспределение выполнено", description: `Заказы распределены между ${logisticians.length || 0} логистами`, }); - }, [appendNotification]); + }, [appendNotification, users]); const saveOrderDetails = React.useCallback( ({ orderId, payload, actorName }) => { @@ -193,7 +278,8 @@ export const useOrders = (currentUser) => { const createOrder = React.useCallback( ({ payload, actorName }) => { - const logisticians = demoUsers.filter((user) => user.role === "logistician"); + const sourceUsers = users.length ? users : demoUsers; + const logisticians = sourceUsers.filter((user) => user.role === "logistician"); const nextOrder = createOrderRecord({ payload, actorName, @@ -209,7 +295,7 @@ export const useOrders = (currentUser) => { description: `${nextOrder.orderNumber}: заказ создан и ожидает подтверждения`, }); }, - [appendNotification], + [appendNotification, users], ); const saveDriverRouteOrder = React.useCallback( @@ -229,6 +315,40 @@ export const useOrders = (currentUser) => { return buildMetrics(visibleOrders); }, [visibleOrders]); + const agingAlerts = React.useMemo(() => { + return visibleOrders + .map((order) => ({ + order, + ...getOrderAgingState(order), + })) + .filter(({ agingState }) => agingState === "warning" || agingState === "critical") + .sort((left, right) => right.statusAgeHours - left.statusAgeHours); + }, [visibleOrders]); + + const agingSummary = React.useMemo(() => { + return { + warning: agingAlerts.filter((item) => item.agingState === "warning").length, + critical: agingAlerts.filter((item) => item.agingState === "critical").length, + }; + }, [agingAlerts]); + + const deliverySetBuckets = React.useMemo(() => { + const sets = groupOrdersIntoDeliverySets(visibleOrders); + const buckets = {}; + for (const bucketKey of Object.keys(DELIVERY_SET_BUCKET_LABELS)) { + buckets[bucketKey] = []; + } + for (const set of sets) { + const bucketKey = set.status || "approaching"; + if (buckets[bucketKey]) { + buckets[bucketKey].push(set); + } else { + buckets.approaching.push(set); + } + } + return buckets; + }, [visibleOrders]); + return { orders: filteredOrders, allOrders: visibleOrders, @@ -238,15 +358,25 @@ export const useOrders = (currentUser) => { filters, setFilters, notifications, + users, + userMap, + isSupabaseBacked, + isLoading, + loadError, + pushNotification: appendNotification, updateStatus, addChatMessage, addInternalMessage, addOrderNote, + assignDriver, reassignDelivery, autoAssignLogisticians, saveOrderDetails, createOrder, saveDriverRouteOrder, metrics, + agingAlerts, + agingSummary, + deliverySetBuckets, }; }; diff --git a/src/layouts/AppShell.jsx b/src/layouts/AppShell.jsx index cdb23dd..77ded6c 100644 --- a/src/layouts/AppShell.jsx +++ b/src/layouts/AppShell.jsx @@ -15,9 +15,9 @@ export const AppShell = ({ children, }) => { return ( -
-
- +
+
+

Панель @@ -51,8 +51,28 @@ export const AppShell = ({

-
- +
+ +
+
+

+ Рабочая область +

+

{sectionMeta?.label || "Панель"}

+

+ {user.name} · {ROLE_LABELS[user.role]} +

+
+
+ + +
+
+
+ +

@@ -78,6 +98,27 @@ export const AppShell = ({ {children}

+ +
+
+ {navItems.map((item) => ( + + ))} +
+
); }; diff --git a/src/pages/DashboardPage.jsx b/src/pages/DashboardPage.jsx index 5d30b71..49ca26c 100644 --- a/src/pages/DashboardPage.jsx +++ b/src/pages/DashboardPage.jsx @@ -5,23 +5,26 @@ 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 { PwaDemoPanel } from "../components/dashboard/PwaDemoPanel"; 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 { OrderEditorPanel } from "../components/orders/OrderEditorPanel"; 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"; @@ -37,8 +40,15 @@ import { filterDriverDeliveries, getDeliveryDay, } from "../services/driverDeliveries"; -import { buildKanbanColumns, filterArchiveOrders, filterRegistryOrders } from "../services/orderViews"; +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(); @@ -51,10 +61,13 @@ export const DashboardPage = () => { 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 [isCreateOrderModalOpen, setIsCreateOrderModalOpen] = React.useState(false); + const [selectedDeliverySet, setSelectedDeliverySet] = React.useState(null); const [driverFilters, setDriverFilters] = React.useState({ dateFrom: "", dateTo: "", @@ -63,7 +76,7 @@ export const DashboardPage = () => { viewMode: "active", showCompleted: false, }); - const { +const { orders, allOrders, selectedOrder, @@ -72,16 +85,23 @@ export const DashboardPage = () => { filters, setFilters, notifications, + pushNotification, updateStatus, addChatMessage, addInternalMessage, addOrderNote, + assignDriver, reassignDelivery, autoAssignLogisticians, - saveOrderDetails, - createOrder, saveDriverRouteOrder, metrics, + agingAlerts, + agingSummary, + deliverySetBuckets, + users, + isSupabaseBacked, + isLoading, + loadError, } = useOrders(user); const canManageLogistics = userRole === "logistician" || userRole === "admin"; @@ -219,11 +239,56 @@ export const DashboardPage = () => { setIsOrderModalOpen(true); }; - const handleKanbanDrop = (column) => { - if (!dragOrderId) { + const handleKanbanDrop = (event, column) => { + const droppedOrderId = resolveDraggedOrderId(event, dragOrderId); + + if (!droppedOrderId) { return; } - updateStatus(dragOrderId, column.dropStatus, user.name); + + 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); }; @@ -267,9 +332,29 @@ export const DashboardPage = () => { return sortableOrders.sort(sorters[kanbanSort] || sorters.updated_desc); }, [kanbanSort, orders]); const kanbanColumns = React.useMemo( - () => buildKanbanColumns(sortedKanbanOrders, { includeCompleted: showCompletedInKanban }), - [showCompletedInKanban, sortedKanbanOrders], + () => + 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 ; @@ -310,6 +395,7 @@ export const DashboardPage = () => { 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", @@ -319,6 +405,7 @@ export const DashboardPage = () => { } onInternalMessage={(message) => addInternalMessage(order.id, message)} onOrderNote={(note) => addOrderNote(order.id, note)} + users={users} />
); @@ -390,8 +477,27 @@ export const DashboardPage = () => { + {agingAlerts.length ? ( + +
+

Контроль зависших заказов

+

+ Смотрите на заказы, которые слишком долго находятся в одном статусе. +

+
+
+ {agingSummary.warning ? ( + Требуют внимания: {agingSummary.warning} + ) : null} + {agingSummary.critical ? ( + Просрочены: {agingSummary.critical} + ) : null} +
+
+ ) : null} +
- +

Оперативные действия

@@ -428,7 +534,7 @@ export const DashboardPage = () => {

Последние события

- {notifications.map((notification) => ( + {eventFeed.map((notification) => (
{

Реестр заказов

- Основная таблица для ежедневной работы. Создание заказа вынесено в отдельное действие. + Основная таблица для ежедневной работы. Данные сюда поступают только из 1С.

@@ -494,12 +600,10 @@ export const DashboardPage = () => { > {showCompletedInRegistry ? "Скрыть завершённые" : "Показать завершённые"} - + Импорт из 1С
- + {isOrderWorkspaceExpanded ? ( renderOrderWorkspace(selectedOrder, true) @@ -508,6 +612,7 @@ export const DashboardPage = () => { orders={registryOrders} selectedOrderId={selectedOrderId} onOpenOrder={openOrderModal} + users={users} /> )}
@@ -519,13 +624,13 @@ export const DashboardPage = () => { {ordersViewTab === "kanban" ? (
- + -
+

- Канбан показывает только отфильтрованные заказы. Карточки можно перетаскивать - между столбцами, исключения вынесены отдельно, а завершённые скрыты по умолчанию. + Канбан показывает только отфильтрованные заказы. Можно переключать вид по этапам + и по статусам, а цвет карточки показывает, чья сейчас зона ответственности.