refactor: align delivery flow with 1c imports

This commit is contained in:
Codex 2026-04-13 16:24:04 +03:00
parent a534d53e61
commit 1798e3acfd
28 changed files with 2090 additions and 366 deletions

View File

@ -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",

View File

@ -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 <input.xml> [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;
});

View File

@ -22,11 +22,11 @@ export const Modal = ({ children, isOpen, onClose, className }) => {
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[rgba(9,17,15,0.45)] p-4">
<div className="fixed inset-0 z-50 flex items-end justify-center bg-[rgba(9,17,15,0.45)] p-0 md:items-center md:p-4">
<div className="absolute inset-0" onClick={onClose} />
<div
className={cn(
"relative max-h-[92vh] w-full max-w-[1220px] overflow-y-auto rounded-[32px] border border-[var(--color-border)] bg-[var(--color-base)] p-4 shadow-soft md:p-6",
"relative h-[100dvh] w-full overflow-y-auto rounded-none border border-[var(--color-border)] bg-[var(--color-base)] p-4 shadow-soft md:h-auto md:max-h-[92vh] md:max-w-[1220px] md:rounded-[32px] md:p-6",
className,
)}
>

View File

@ -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 (
<section
className={cn(
"rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-soft backdrop-blur",
className,
)}
{...props}
>
{children}
</section>

View File

@ -20,12 +20,14 @@ export const OtpLoginForm = ({
error,
}) => {
return (
<Panel className="w-full max-w-md p-8">
<div className="mb-8 space-y-2">
<Panel className="w-full max-w-md p-5 sm:p-8">
<div className="mb-6 space-y-2 sm:mb-8">
<p className="text-sm uppercase tracking-[0.28em] text-[var(--color-text-muted)]">
Платформа доставки
</p>
<h1 className="text-3xl font-semibold">Вход по email и коду</h1>
<h1 className="text-2xl font-semibold leading-tight sm:text-3xl">
Вход по email и коду
</h1>
<p className="text-sm text-[var(--color-text-muted)]">
Введите email, и код придет на почту. В рабочем режиме доступ определяется учетной
записью в системе, а не выбором роли.

View File

@ -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 (
<div className="space-y-5">
@ -116,7 +121,7 @@ export const OrderDetailPanel = ({
{activeTab === "overview" ? (
<div className="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
<div className="space-y-4">
<div className="order-2 space-y-4 xl:order-none">
<Panel className="p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<strong>Данные клиента</strong>
@ -172,7 +177,7 @@ export const OrderDetailPanel = ({
</Panel>
</div>
<div className="space-y-4">
<div className="order-1 space-y-4 xl:order-none">
<Panel className="space-y-4 p-5">
<div>
<strong>Управление заказом</strong>
@ -208,6 +213,32 @@ export const OrderDetailPanel = ({
</Button>
</div>
{canAssignDriver ? (
<div className="space-y-3 rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface)] p-4">
<div>
<div className="font-medium">Назначение водителя</div>
<p className="mt-2 text-sm leading-6 text-[var(--color-text-muted)]">
Выберите водителя для передачи заказа в этап доставки.
</p>
</div>
<Select value={selectedDriverId} onChange={(event) => setSelectedDriverId(event.target.value)}>
<option value="">Не назначен</option>
{drivers.map((driver) => (
<option key={driver.id} value={driver.id}>
{driver.name}
</option>
))}
</Select>
<Button
variant="secondary"
disabled={(selectedDriverId || "") === (order.assignedDriverId || "")}
onClick={() => onAssignDriver?.(selectedDriverId || null)}
>
Сохранить водителя
</Button>
</div>
) : null}
<div className="text-sm text-[var(--color-text-muted)]">
Для вашей роли доступны типовые действия:
</div>

View File

@ -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 (
<Panel className="space-y-4 p-5">
<Panel className="space-y-4 p-5 pb-24">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-lg font-semibold">Управление заказом</h3>
<p className="text-sm text-[var(--color-text-muted)]">
Создание и редактирование заказа с полями клиента, канала связи и даты доставки.
Редактирование импортированного из 1С заказа с полями клиента, канала связи и даты доставки.
</p>
</div>
{!createOnly ? (
<div className="flex flex-wrap gap-2">
<Button variant="secondary" onClick={resetForCreate} disabled={!canManageOrders}>
Новый заказ
Импортированный заказ
</Button>
<Button variant="ghost" onClick={resetForSelected}>
Сбросить
@ -134,7 +137,7 @@ export const OrderEditorPanel = ({
<div className="grid gap-3 md:grid-cols-2">
<Input
placeholder="Номер заказа"
placeholder="Номер заказа из 1С"
value={form.orderNumber}
onChange={(event) => updateField("orderNumber", event.target.value)}
/>
@ -149,17 +152,17 @@ export const OrderEditorPanel = ({
))}
</Select>
<Input
placeholder="Имя клиента"
placeholder="Имя клиента из 1С"
value={form.customerName}
onChange={(event) => updateField("customerName", event.target.value)}
/>
<Input
placeholder="Телефон"
placeholder="Телефон клиента"
value={form.customerPhone}
onChange={(event) => updateField("customerPhone", event.target.value)}
/>
<Input
placeholder="Адрес доставки"
placeholder="Адрес доставки из 1С"
value={form.customerAddress}
onChange={(event) => updateField("customerAddress", event.target.value)}
/>
@ -199,9 +202,11 @@ export const OrderEditorPanel = ({
onChange={(event) => updateField("comments", event.target.value)}
/>
<div className="sticky bottom-0 -mx-5 border-t border-[var(--color-border)] bg-[var(--color-base)]/96 px-5 pb-1 pt-4 backdrop-blur">
<Button className="w-full" onClick={handleSubmit} disabled={!canManageOrders}>
{isCreateMode ? "Создать заказ" : "Сохранить изменения"}
{isCreateMode ? "Сохранить импорт" : "Сохранить изменения"}
</Button>
</div>
</Panel>
);
};

View File

@ -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 }) => {
return (
<Panel className="p-4">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
<Input
placeholder="Поиск по заказу, клиенту, тегу"
value={filters.query}
onChange={(event) =>
setFilters((current) => ({ ...current, query: event.target.value }))
}
/>
const [isMobileFiltersOpen, setIsMobileFiltersOpen] = React.useState(false);
<Select
value={filters.status}
onChange={(event) =>
setFilters((current) => ({ ...current, status: event.target.value }))
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) => (
<div className="flex min-w-0 flex-col gap-2">
{showLabel ? (
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
{label}
</span>
) : null}
{control}
</div>
);
const renderAdvancedFilters = ({ className = "", showLabels = false } = {}) => (
<div className={className}>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-7">
{renderFilterField(
"Статус",
<Select value={filters.status} onChange={(event) => updateFilter("status", event.target.value)}>
<option value="all">Все статусы</option>
{ORDER_STATUSES.map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</Select>
</Select>,
showLabels,
)}
<Select
value={filters.managerId}
onChange={(event) =>
setFilters((current) => ({ ...current, managerId: event.target.value }))
}
>
{renderFilterField(
"Этап",
<Select value={filters.stage} onChange={(event) => updateFilter("stage", event.target.value)}>
<option value="all">Все этапы</option>
{WORKFLOW_STAGES.map((stage) => (
<option key={stage.key} value={stage.key}>
{stage.label}
</option>
))}
</Select>,
showLabels,
)}
{renderFilterField(
"Ответственный отдел",
<Select value={filters.ownerRole} onChange={(event) => updateFilter("ownerRole", event.target.value)}>
<option value="all">Все зоны ответственности</option>
{responsibilityRoles.map(([role, label]) => (
<option key={role} value={role}>
{label}
</option>
))}
</Select>,
showLabels,
)}
{renderFilterField(
"SLA",
<Select value={filters.agingState} onChange={(event) => updateFilter("agingState", event.target.value)}>
<option value="all">Без фильтра по SLA</option>
{agingOptions.map((option) => (
<option key={option.key} value={option.key}>
{option.label}
</option>
))}
</Select>,
showLabels,
)}
{renderFilterField(
"Менеджер",
<Select value={filters.managerId} onChange={(event) => updateFilter("managerId", event.target.value)}>
<option value="all">Все менеджеры</option>
{managers.map((manager) => (
<option key={manager.id} value={manager.id}>
{manager.name}
</option>
))}
</Select>
</Select>,
showLabels,
)}
<Select
value={filters.logisticianId}
onChange={(event) =>
setFilters((current) => ({ ...current, logisticianId: event.target.value }))
}
>
{renderFilterField(
"Логист",
<Select value={filters.logisticianId} onChange={(event) => updateFilter("logisticianId", event.target.value)}>
<option value="all">Все логисты</option>
{logisticians.map((logistician) => (
<option key={logistician.id} value={logistician.id}>
{logistician.name}
</option>
))}
</Select>
</Select>,
showLabels,
)}
<Select
value={filters.messenger}
onChange={(event) =>
setFilters((current) => ({ ...current, messenger: event.target.value }))
}
>
{renderFilterField(
"Канал",
<Select value={filters.messenger} onChange={(event) => updateFilter("messenger", event.target.value)}>
<option value="all">Все каналы</option>
{messengers.map((messenger) => (
<option key={messenger} value={messenger}>
{messenger}
</option>
))}
</Select>
</Select>,
showLabels,
)}
</div>
</div>
);
return (
<Panel className="p-4">
<div className="flex flex-col gap-3 md:hidden">
<div className="flex items-center gap-3">
<Input
placeholder="Поиск по заявке, клиенту, телефону"
value={filters.query}
onChange={(event) => updateFilter("query", event.target.value)}
/>
<Button size="sm" variant="secondary" onClick={() => setIsMobileFiltersOpen((current) => !current)}>
Фильтры
</Button>
</div>
<div>
<div className="text-xs uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
Активные фильтры
</div>
<div className="mt-2 flex flex-wrap gap-2">
{activeChips.length ? activeChips.map((chip) => <Badge key={chip.key}>{chip.label}</Badge>) : <Badge>Нет</Badge>}
</div>
</div>
{isMobileFiltersOpen
? renderAdvancedFilters({
className: "rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-3",
})
: null}
</div>
<div className="hidden md:flex md:flex-col md:gap-4">
<div className="grid gap-3 xl:grid-cols-[minmax(22rem,1.35fr)_minmax(0,1fr)] xl:items-start">
<Input
placeholder="Поиск по заявке, клиенту, телефону"
value={filters.query}
onChange={(event) => updateFilter("query", event.target.value)}
/>
<div className="min-h-[44px] rounded-[20px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] px-4 py-3">
<div className="text-xs uppercase tracking-[0.14em] text-[var(--color-text-muted)]">Активные фильтры</div>
<div className="mt-2 flex flex-wrap gap-2">
{activeChips.length ? activeChips.map((chip) => <Badge key={chip.key}>{chip.label}</Badge>) : <Badge>Нет</Badge>}
</div>
</div>
</div>
{renderAdvancedFilters({ showLabels: true })}
</div>
</Panel>
);

View File

@ -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 (
<Panel className="space-y-5 p-6">
@ -85,6 +92,37 @@ export const OrdersCalendarView = ({ orders, onOpenOrder }) => {
</div>
</div>
<div className="space-y-3 md:hidden">
<div className="text-xs uppercase tracking-[0.14em] text-[var(--color-text-muted)]">Заказы по дням</div>
{agendaDays.map(({ key, dayOrders }) => (
<div key={key} className="rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4">
<div className="mb-3 flex items-center justify-between gap-2">
<div className="text-sm font-semibold">
{new Date(`${key}T00:00:00`).toLocaleDateString("ru-RU", {
day: "numeric",
month: "long",
})}
</div>
<div className="text-xs text-[var(--color-text-muted)]">{dayOrders.length}</div>
</div>
<div className="space-y-2">
{dayOrders.map((order) => (
<button
key={order.id}
type="button"
onClick={() => onOpenOrder(order.id)}
className="w-full rounded-[16px] bg-[var(--color-surface)] px-3 py-3 text-left text-sm hover:bg-[var(--color-accent-soft)]"
>
<div className="font-medium">{order.orderNumber}</div>
<div className="mt-1 text-xs text-[var(--color-text-muted)]">{order.customer.name}</div>
</button>
))}
</div>
</div>
))}
</div>
<div className="hidden md:block">
<div className="grid grid-cols-7 gap-3 text-xs uppercase tracking-[0.12em] text-[var(--color-text-muted)]">
{WEEK_DAYS.map((day) => (
<div key={day} className="px-2">
@ -93,13 +131,13 @@ export const OrdersCalendarView = ({ orders, onOpenOrder }) => {
))}
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-7">
<div className="mt-3 grid grid-cols-7 gap-3">
{calendarDays.map((day, index) => {
if (!day) {
return (
<div
key={`empty-${index}`}
className="hidden min-h-[132px] rounded-[24px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface)]/50 md:block"
className="min-h-[132px] rounded-[24px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface)]/50"
/>
);
}
@ -141,6 +179,7 @@ export const OrdersCalendarView = ({ orders, onOpenOrder }) => {
);
})}
</div>
</div>
</Panel>
);
};

View File

@ -25,7 +25,35 @@ export const OrdersTable = ({ orders, selectedOrderId, onOpenOrder }) => {
<Badge tone="neutral">{orders.length}</Badge>
</div>
<div className="overflow-x-auto">
<div className="space-y-3 p-4 md:hidden">
{orders.map((order) => (
<button
key={order.id}
type="button"
onClick={() => onOpenOrder(order.id)}
className={[
"w-full rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-left transition",
selectedOrderId === order.id ? "bg-[var(--color-accent-soft)]" : "",
].join(" ")}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-medium">{order.orderNumber}</div>
<div className="mt-1 text-sm text-[var(--color-text-muted)]">{order.customer.name}</div>
</div>
<Badge tone={getStatusTone(order.status)}>{order.status}</Badge>
</div>
<div className="mt-3 text-sm text-[var(--color-text-muted)]">{buildOrderSummary(order)}</div>
<div className="mt-3 flex flex-wrap items-center gap-3 text-xs text-[var(--color-text-muted)]">
<span>{order.customer.phone}</span>
<span>{resolveUserName(order.managerId)}</span>
<span>{formatDateTime(order.updatedAt)}</span>
</div>
</button>
))}
</div>
<div className="hidden overflow-x-auto md:block">
<table className="min-w-full border-collapse">
<thead className="bg-[var(--color-surface-strong)] text-left text-xs uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
<tr>

View File

@ -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] || [];

View File

@ -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",

View File

@ -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,
};
};

View File

@ -15,9 +15,9 @@ export const AppShell = ({
children,
}) => {
return (
<div className="min-h-screen px-4 py-5 md:px-6 md:py-8">
<div className="mx-auto grid max-w-[1540px] gap-5 xl:grid-cols-[220px_1fr] xl:gap-8">
<Panel className="flex h-fit flex-col gap-5 p-4">
<div className="min-h-screen px-3 py-4 sm:px-4 md:px-6 md:py-8">
<div className="mx-auto max-w-[1540px] space-y-4 xl:grid xl:grid-cols-[220px_1fr] xl:gap-8 xl:space-y-0">
<Panel className="hidden h-fit flex-col gap-5 p-4 xl:flex">
<div>
<p className="text-xs uppercase tracking-[0.24em] text-[var(--color-text-muted)]">
Панель
@ -51,8 +51,28 @@ export const AppShell = ({
</div>
</Panel>
<div className="space-y-6 xl:space-y-8">
<Panel className="p-4 md:p-5">
<div className="min-w-0 space-y-5 pb-24 xl:space-y-8 xl:pb-0">
<Panel className="p-4 xl:hidden">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
Рабочая область
</p>
<h2 className="mt-2 truncate text-xl font-semibold">{sectionMeta?.label || "Панель"}</h2>
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
{user.name} · {ROLE_LABELS[user.role]}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<ThemeToggle />
<Button size="sm" variant="ghost" onClick={onSignOut}>
Выйти
</Button>
</div>
</div>
</Panel>
<Panel className="hidden p-4 md:p-5 xl:block">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
@ -78,6 +98,27 @@ export const AppShell = ({
{children}
</div>
</div>
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-[var(--color-border)] bg-[rgba(244,247,245,0.94)] px-3 py-3 backdrop-blur xl:hidden">
<div className="mx-auto flex max-w-[1540px] gap-2 overflow-x-auto">
{navItems.map((item) => (
<button
key={item.key}
className={[
"flex min-w-[120px] flex-1 items-center justify-center gap-2 rounded-[18px] px-3 py-3 text-sm transition",
activeSection === item.key
? "bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
: "bg-[var(--color-surface)] text-[var(--color-text-muted)]",
].join(" ")}
onClick={() => onSectionChange(item.key)}
type="button"
>
<span className="truncate font-medium">{item.label}</span>
{item.badge ? <Badge tone={activeSection === item.key ? "neutral" : "accent"}>{item.badge}</Badge> : null}
</button>
))}
</div>
</div>
</div>
);
};

View File

@ -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 <Navigate to="/login" replace />;
@ -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}
/>
</div>
);
@ -390,8 +477,27 @@ export const DashboardPage = () => {
<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} />
<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">
@ -428,7 +534,7 @@ export const DashboardPage = () => {
<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">
{notifications.map((notification) => (
{eventFeed.map((notification) => (
<div
key={notification.id}
className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-5"
@ -483,7 +589,7 @@ export const DashboardPage = () => {
<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">
@ -494,12 +600,10 @@ export const DashboardPage = () => {
>
{showCompletedInRegistry ? "Скрыть завершённые" : "Показать завершённые"}
</Button>
<Button size="sm" onClick={() => setIsCreateOrderModalOpen(true)}>
Добавить заказ
</Button>
<Badge tone="neutral">Импорт из 1С</Badge>
</div>
</Panel>
<OrderFilters filters={filters} setFilters={setFilters} />
<OrderFilters filters={filters} setFilters={setFilters} users={users} />
{isOrderWorkspaceExpanded ? (
renderOrderWorkspace(selectedOrder, true)
@ -508,6 +612,7 @@ export const DashboardPage = () => {
orders={registryOrders}
selectedOrderId={selectedOrderId}
onOpenOrder={openOrderModal}
users={users}
/>
)}
</div>
@ -519,13 +624,13 @@ export const DashboardPage = () => {
{ordersViewTab === "kanban" ? (
<div className="space-y-4">
<OrderFilters filters={filters} setFilters={setFilters} />
<OrderFilters filters={filters} setFilters={setFilters} users={users} />
<Panel className="p-4">
<div className="grid gap-3 md:grid-cols-[1fr_260px_auto] md:items-center">
<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>
@ -541,65 +646,62 @@ export const DashboardPage = () => {
>
{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>
<div
className={[
"grid gap-3",
showCompletedInKanban ? "xl:grid-cols-5" : "xl:grid-cols-4",
].join(" ")}
>
{kanbanColumns.map((column) => (
<Panel key={column.key} className="rounded-[20px] p-3">
<div className="mb-3 flex items-center justify-between gap-3 px-1">
<h3 className="text-sm font-semibold text-[var(--color-text)]">{column.title}</h3>
<span className="text-sm text-[var(--color-text-muted)]">{column.items.length}</span>
</div>
<div
className={[
"min-h-[280px] space-y-2 rounded-[16px] border border-dashed p-2 transition",
dropColumnKey === column.key
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
: "border-[var(--color-border)] bg-[var(--color-surface-strong)]",
].join(" ")}
onDragOver={(event) => {
event.preventDefault();
setDropColumnKey(column.key);
<OrdersKanbanBoard
columns={visibleKanbanColumns}
currentMode={kanbanMode}
departmentFilter={kanbanDepartmentFilter}
notice={kanbanNotice}
onDepartmentFilterChange={setKanbanDepartmentFilter}
onModeChange={setKanbanMode}
onOpenOrder={openOrderModal}
onDragStart={(orderId) => {
setKanbanNotice(null);
setDragOrderId(orderId);
}}
onDragLeave={() =>
setDropColumnKey((current) => (current === column.key ? null : current))
}
onDrop={() => handleKanbanDrop(column)}
>
{column.items.map((order) => (
<div
key={order.id}
className="w-full cursor-grab rounded-[14px] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 text-left text-[var(--color-text)] shadow-sm transition hover:border-[var(--color-accent)] hover:bg-[var(--color-accent-soft)] active:cursor-grabbing"
onClick={() => openOrderModal(order.id)}
onDragStart={() => setDragOrderId(order.id)}
onDragEnd={() => {
setDragOrderId(null);
setDropColumnKey(null);
}}
draggable
>
<div className="font-medium">{order.orderNumber}</div>
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
{order.customer.name}
</div>
<div className="mt-2 text-sm text-[var(--color-text-muted)]">
{(order.items?.[0] || "Состав не указан").split("|")[0].trim()}
</div>
<div className="mt-2 text-xs text-[var(--color-text-muted)]">
{order.status}
</div>
</div>
))}
</div>
</Panel>
))}
</div>
onDragOverColumn={setDropColumnKey}
onDragLeaveColumn={(columnKey) =>
setDropColumnKey((current) => (current === columnKey ? null : current))
}
onDropColumn={handleKanbanDrop}
dropColumnKey={dropColumnKey}
/>
</div>
) : null}
@ -652,6 +754,7 @@ export const DashboardPage = () => {
orders={archiveOrders}
selectedOrderId={selectedOrderId}
onOpenOrder={openOrderModal}
users={users}
/>
</div>
) : null}
@ -668,6 +771,7 @@ export const DashboardPage = () => {
orders={productionOrders}
selectedOrderId={selectedOrderId}
onOpenOrder={openOrderModal}
users={users}
/>
</div>
);
@ -676,6 +780,18 @@ export const DashboardPage = () => {
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}
@ -706,10 +822,10 @@ export const DashboardPage = () => {
</section>
<Panel className="p-6">
<h3 className="text-lg font-semibold">Готовые заказы и слоты</h3>
<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) => (
@ -808,7 +924,7 @@ export const DashboardPage = () => {
<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} />
<UserDirectoryPanel currentUser={user} users={users} />
</div>
<UserOnboardingPanel />
</div>
@ -824,6 +940,21 @@ export const DashboardPage = () => {
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" ? (
@ -844,6 +975,7 @@ export const DashboardPage = () => {
onStatusChange={(nextStatus) =>
selectedOrder && updateStatus(selectedOrder.id, nextStatus, user.name)
}
users={users}
/>
</div>
) : (
@ -875,33 +1007,6 @@ export const DashboardPage = () => {
</div>
)}
</Modal>
<Modal
isOpen={isCreateOrderModalOpen}
onClose={() => setIsCreateOrderModalOpen(false)}
className="max-w-[920px]"
>
<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={() => setIsCreateOrderModalOpen(false)}>
Закрыть
</Button>
</div>
<OrderEditorPanel
currentUser={user}
selectedOrder={null}
onCreateOrder={createOrder}
onSaveOrder={saveOrderDetails}
createOnly
onDone={() => setIsCreateOrderModalOpen(false)}
/>
</div>
</Modal>
</AppShell>
);
};

View File

@ -45,7 +45,7 @@ export const LoginPage = () => {
};
return (
<div className="flex min-h-screen items-center justify-center px-4 py-10">
<div className="flex min-h-screen items-center justify-center px-3 py-6 sm:px-4 sm:py-10">
<OtpLoginForm
email={email}
setEmail={setEmail}

View File

@ -1,6 +1,7 @@
import React from "react";
import { Navigate, createBrowserRouter } from "react-router-dom";
import App from "./App";
import { ClientDeliveryPage } from "./pages/ClientDeliveryPage";
import { DashboardPage } from "./pages/DashboardPage";
import { LoginPage } from "./pages/LoginPage";
import { NotFoundPage } from "./pages/NotFoundPage";
@ -18,6 +19,10 @@ export const router = createBrowserRouter([
path: "login",
element: <LoginPage />,
},
{
path: "delivery/:token",
element: <ClientDeliveryPage />,
},
{
path: "dashboard",
element: <DashboardPage />,

View File

@ -1,9 +1,18 @@
import { demoOrders } from "../data/mockAppData";
import {
getAvailableTransitionsByRole,
getStatusOwnerRole,
getStatusStageKey,
LOGISTICS_STATUSES,
PRODUCTION_STATUSES,
} from "../constants/deliveryWorkflow";
import { getOrderAgingState } from "./orderViews";
const DELIVERY_HANDOFF_STATUS = "Назначен водитель";
const DELIVERY_HANDOFF_REQUIRES_DRIVER_MESSAGE =
"Сначала назначьте водителя, потом заказ можно передать в доставку.";
const DELIVERY_HANDOFF_ROLE_MESSAGE =
"Логист может передать заказ в доставку только через статус «Назначен водитель».";
export const cloneOrders = (orders = demoOrders) =>
orders.map((order) => ({
@ -40,32 +49,61 @@ export const buildSearchBlob = (order) => {
.toLowerCase();
};
export const filterOrdersByView = ({ orders, currentUser, filters }) => {
export const filterOrdersByView = ({ orders, currentUser, filters, now }) => {
const normalizedFilters = {
query: "",
status: "all",
stage: "all",
ownerRole: "all",
agingState: "all",
managerId: "all",
logisticianId: "all",
messenger: "all",
...filters,
};
const visibleOrders = 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) {
if (!currentUser) {
return false;
}
if (currentUser.role === "manager" || currentUser.role === "admin") {
return true;
}
return getStatusOwnerRole(order.status) === currentUser.role;
});
const normalizedQuery = filters.query.trim().toLowerCase();
const normalizedQuery = normalizedFilters.query.trim().toLowerCase();
const filteredOrders = visibleOrders.filter((order) => {
if (filters.status !== "all" && order.status !== filters.status) {
const stageKey = getStatusStageKey(order.status);
const ownerRole = getStatusOwnerRole(order.status);
const { agingState } = getOrderAgingState(order, { now });
if (normalizedFilters.status !== "all" && order.status !== normalizedFilters.status) {
return false;
}
if (filters.managerId !== "all" && order.managerId !== filters.managerId) {
if (normalizedFilters.stage !== "all" && stageKey !== normalizedFilters.stage) {
return false;
}
if (filters.logisticianId !== "all" && !order.logisticianIds.includes(filters.logisticianId)) {
if (normalizedFilters.ownerRole !== "all" && ownerRole !== normalizedFilters.ownerRole) {
return false;
}
if (filters.messenger !== "all" && order.customer.messenger !== filters.messenger) {
if (normalizedFilters.agingState !== "all" && agingState !== normalizedFilters.agingState) {
return false;
}
if (normalizedFilters.managerId !== "all" && order.managerId !== normalizedFilters.managerId) {
return false;
}
if (
normalizedFilters.logisticianId !== "all" &&
!order.logisticianIds.includes(normalizedFilters.logisticianId)
) {
return false;
}
if (
normalizedFilters.messenger !== "all" &&
order.customer.messenger !== normalizedFilters.messenger
) {
return false;
}
if (normalizedQuery && !buildSearchBlob(order).includes(normalizedQuery)) {
@ -92,7 +130,68 @@ export const appendHistoryEntry = (order, entry) => ({
export const getAvailableTransitions = ({ status, role }) =>
getAvailableTransitionsByRole({ status, role });
export const getAvailableTransitionsForOrder = ({ order, role }) =>
getAvailableTransitionsByRole({ status: order.status, role }).filter((nextStatus) => {
if (nextStatus === DELIVERY_HANDOFF_STATUS && !order.assignedDriverId) {
return false;
}
return true;
});
export const getTransitionBlockedReason = ({ order, nextStatus, role }) => {
if (nextStatus === DELIVERY_HANDOFF_STATUS && !order.assignedDriverId) {
return DELIVERY_HANDOFF_REQUIRES_DRIVER_MESSAGE;
}
const roleTransitions = getAvailableTransitionsByRole({ status: order.status, role });
if (!roleTransitions.includes(nextStatus) && role === "logistician" && ["Загружен", "В пути", "Доставлен"].includes(nextStatus)) {
return DELIVERY_HANDOFF_ROLE_MESSAGE;
}
return "Этот переход сейчас недоступен для вашей роли.";
};
export const getKanbanDropResolution = ({ order, column, role }) => {
const allowedStatuses = getAvailableTransitionsForOrder({ order, role });
const nextStatus = column.statuses.find((status) => allowedStatuses.includes(status));
if (nextStatus) {
return {
nextStatus,
reason: null,
};
}
if (column.statuses.includes(DELIVERY_HANDOFF_STATUS)) {
return {
nextStatus: null,
reason: getTransitionBlockedReason({
order,
nextStatus: DELIVERY_HANDOFF_STATUS,
role,
}),
};
}
return {
nextStatus: null,
reason: getTransitionBlockedReason({
order,
nextStatus: column.statuses[0],
role,
}),
};
};
const deriveAgreementStatus = (currentOrder, nextStatus) => {
if (nextStatus === "Ожидает ответа клиента") {
return currentOrder.deliveryAgreementStatus === "Подтверждено клиентом"
? "Отправлено клиенту"
: "Ожидание ответа";
}
if (nextStatus === "Ожидает согласования доставки") {
return currentOrder.deliveryAgreementStatus === "Подтверждено клиентом"
? "Отправлено клиенту"
@ -103,6 +202,16 @@ const deriveAgreementStatus = (currentOrder, nextStatus) => {
return "Подтверждено клиентом";
}
if (nextStatus === "Передан логисту") {
return currentOrder.deliveryAgreementStatus === "Подтверждено клиентом"
? "Перенос запрошен"
: "Нет ответа";
}
if (nextStatus === "Платное хранение") {
return "Нет ответа";
}
if (nextStatus === "Проблема доставки") {
return currentOrder.deliveryAgreementStatus === "Не начато"
? "Ошибка отправки"
@ -129,6 +238,25 @@ export const applyStatusUpdate = (order, nextStatus, actorName) => {
);
};
export const assignDriverToOrder = (order, driverId, actorName) => {
const normalizedDriverId = driverId || null;
return appendHistoryEntry(
{
...order,
assignedDriverId: normalizedDriverId,
driverRouteOrder: normalizedDriverId ? order.driverRouteOrder : null,
updatedAt: new Date().toISOString(),
},
{
action: "Назначение водителя",
oldStatus: order.status,
newStatus: order.status,
userName: actorName,
},
);
};
export const appendChatMessageToOrder = (order, message) => ({
...order,
updatedAt: new Date().toISOString(),

View File

@ -1,34 +1,40 @@
import { describe, expect, it } from "vitest";
import { getStatusOwnerRole } from "../constants/deliveryWorkflow";
import { demoOrders, demoUsers } from "../data/mockAppData";
import {
assignDriverToOrder,
applyStatusUpdate,
autoAssignOrders,
buildMetrics,
cloneOrders,
createOrderRecord,
filterOrdersByView,
getKanbanDropResolution,
getAvailableTransitions,
} from "./orderService";
describe("orderService", () => {
it("filters manager orders to owned items only", () => {
it("lets manager see the whole pipeline", () => {
const result = filterOrdersByView({
orders: cloneOrders(demoOrders),
currentUser: demoUsers[0],
filters: {
query: "",
status: "all",
stage: "all",
ownerRole: "all",
agingState: "all",
managerId: "all",
logisticianId: "all",
messenger: "all",
},
});
expect(result.visibleOrders).toHaveLength(7);
expect(result.filteredOrders.every((order) => order.managerId === demoUsers[0].id)).toBe(true);
expect(result.visibleOrders).toHaveLength(demoOrders.length);
expect(result.filteredOrders).toHaveLength(demoOrders.length);
});
it("filters driver orders to assigned deliveries only", () => {
it("shows employees all orders in their responsibility zone", () => {
const driver = demoUsers.find((user) => user.role === "driver");
const result = filterOrdersByView({
orders: cloneOrders(demoOrders),
@ -36,14 +42,40 @@ describe("orderService", () => {
filters: {
query: "",
status: "all",
stage: "all",
ownerRole: "all",
agingState: "all",
managerId: "all",
logisticianId: "all",
messenger: "all",
},
});
expect(result.visibleOrders).toHaveLength(4);
expect(result.visibleOrders.every((order) => order.assignedDriverId === driver.id)).toBe(true);
const expectedDriverOrders = demoOrders.filter((order) => getStatusOwnerRole(order.status) === "driver");
expect(result.visibleOrders).toHaveLength(expectedDriverOrders.length);
expect(result.visibleOrders.every((order) => getStatusOwnerRole(order.status) === "driver")).toBe(true);
});
it("finds orders by customer phone, stage, owner role and aging state", () => {
const result = filterOrdersByView({
orders: cloneOrders(demoOrders),
currentUser: demoUsers[0],
filters: {
query: "+7 978 000-12-31",
status: "all",
stage: "logistics",
ownerRole: "logistician",
agingState: "warning",
managerId: "all",
logisticianId: "all",
messenger: "all",
},
now: "2026-03-15T12:00:00Z",
});
expect(result.filteredOrders).toHaveLength(1);
expect(result.filteredOrders[0].orderNumber).toBe("CD-240031");
});
it("updates status and prepends history record", () => {
@ -54,6 +86,14 @@ describe("orderService", () => {
expect(nextOrder.history[0].newStatus).toBe("Доставка согласована");
});
it("assigns a driver and records the action in history", () => {
const nextOrder = assignDriverToOrder(demoOrders[0], "u-driver", "Ольга Синицына");
expect(nextOrder.assignedDriverId).toBe("u-driver");
expect(nextOrder.history[0].action).toBe("Назначение водителя");
expect(nextOrder.history[0].userName).toBe("Ольга Синицына");
});
it("returns role-scoped transitions for logistics stage", () => {
const transitions = getAvailableTransitions({
status: "Ожидает согласования доставки",
@ -63,6 +103,50 @@ describe("orderService", () => {
expect(transitions).toEqual(["Доставка согласована", "Проблема доставки", "Отменён"]);
});
it("lets manager move orders across the full workflow from the shared board", () => {
const transitions = getAvailableTransitions({
status: "Ожидает согласования доставки",
role: "manager",
});
expect(transitions).toEqual(["Доставка согласована", "Проблема доставки", "Отменён"]);
});
it("lets logisticians hand orders off into delivery only when a driver is assigned", () => {
const assignedOrder = {
...demoOrders.find((order) => order.status === "Доставка согласована"),
assignedDriverId: "u-driver",
};
const blockedOrder = {
...assignedOrder,
assignedDriverId: null,
};
const deliveryColumn = {
key: "delivery",
title: "Доставка",
statuses: ["Назначен водитель", "Загружен", "В пути"],
};
expect(
getKanbanDropResolution({
order: assignedOrder,
column: deliveryColumn,
role: "logistician",
}),
).toMatchObject({ nextStatus: "Назначен водитель" });
expect(
getKanbanDropResolution({
order: blockedOrder,
column: deliveryColumn,
role: "logistician",
}),
).toMatchObject({
nextStatus: null,
reason: "Сначала назначьте водителя, потом заказ можно передать в доставку.",
});
});
it("creates a new order draft with assigned logistician", () => {
const logisticians = demoUsers.filter((user) => user.role === "logistician");
const order = createOrderRecord({
@ -99,9 +183,25 @@ describe("orderService", () => {
it("builds dashboard metrics", () => {
const metrics = buildMetrics(demoOrders);
expect(metrics.total).toBe(7);
expect(metrics.readyToShip).toBe(1);
expect(metrics.awaitingDeliveryCoordination).toBe(1);
expect(metrics.exceptions).toBe(1);
expect(metrics.total).toBe(demoOrders.length);
expect(metrics.readyToShip).toBe(demoOrders.filter((order) => order.status === "Готов к отгрузке").length);
expect(metrics.awaitingDeliveryCoordination).toBe(
demoOrders.filter((order) => order.status === "Ожидает согласования доставки").length,
);
expect(metrics.exceptions).toBe(demoOrders.filter((order) => order.status === "Проблема доставки").length);
});
it("ships with an expanded demo dataset across the workflow", () => {
expect(demoOrders.length).toBeGreaterThanOrEqual(32);
expect(new Set(demoOrders.map((order) => order.status)).size).toBeGreaterThanOrEqual(10);
expect(
demoOrders.some((order) => order.status === "Доставка согласована" && order.assignedDriverId),
).toBe(true);
expect(
demoOrders.every((order) => Number.isFinite(new Date(order.createdAt).getTime())),
).toBe(true);
expect(
demoOrders.every((order) => Number.isFinite(new Date(order.scheduledDelivery).getTime())),
).toBe(true);
});
});

View File

@ -1,45 +1,41 @@
import {
ORDER_STATUSES,
WORKFLOW_STAGES,
getStatusOwnerRole,
getStatusSla,
getStatusStageKey,
getStatusStageLabel,
} from "../constants/deliveryWorkflow";
const COMPLETED_ORDER_STATUSES = new Set(["Доставлен", "Закрыт", "Отменён"]);
const ARCHIVE_ONLY_ORDER_STATUSES = new Set(["Закрыт", "Отменён"]);
const EXCEPTION_ORDER_STATUSES = new Set(["Проблема доставки"]);
const KANBAN_BASE_COLUMNS = [
{
key: "new",
title: "В работе",
statuses: [
"Новый",
"Требует уточнения",
"Подтверждён менеджером",
"В очереди производства",
"В производстве",
"Готов к отгрузке",
],
dropStatus: "В производстве",
},
{
key: "coordination",
title: "Согласование доставки",
statuses: ["Ожидает согласования доставки", "Доставка согласована", "Назначен водитель"],
dropStatus: "Ожидает согласования доставки",
},
{
key: "execution",
title: "Исполнение",
statuses: ["Загружен", "В пути"],
dropStatus: "В пути",
},
{
key: "exceptions",
title: "Исключения",
statuses: ["Проблема доставки"],
dropStatus: "Проблема доставки",
},
];
const formatAgeLabel = (hours) => {
if (!Number.isFinite(hours) || hours < 1) {
return "меньше часа";
}
const COMPLETED_COLUMN = {
key: "completed",
title: "Завершённые",
statuses: [...COMPLETED_ORDER_STATUSES],
dropStatus: "Закрыт",
if (hours < 24) {
return `${Math.floor(hours)}ч в статусе`;
}
const days = Math.floor(hours / 24);
const remainingHours = Math.floor(hours % 24);
if (remainingHours === 0) {
return `${days}д в статусе`;
}
return `${days}д ${remainingHours}ч в статусе`;
};
const getStatusEnteredAt = (order) => {
const matchingEntries = (order.history || [])
.filter((entry) => entry.newStatus === order.status)
.sort((left, right) => new Date(right.at) - new Date(left.at));
return matchingEntries[0]?.at || order.updatedAt || order.createdAt || new Date().toISOString();
};
export const isCompletedOrderStatus = (status) => COMPLETED_ORDER_STATUSES.has(status);
@ -52,11 +48,120 @@ export const filterRegistryOrders = (orders, { includeCompleted = false } = {})
export const filterArchiveOrders = (orders) =>
orders.filter((order) => isCompletedOrderStatus(order.status));
export const buildKanbanColumns = (orders, { includeCompleted = false } = {}) => {
const columns = includeCompleted ? [...KANBAN_BASE_COLUMNS, COMPLETED_COLUMN] : KANBAN_BASE_COLUMNS;
export const getOrderAgingState = (order, { now = new Date().toISOString() } = {}) => {
const enteredAt = getStatusEnteredAt(order);
const statusAgeHours = Math.max(
0,
(new Date(now).getTime() - new Date(enteredAt).getTime()) / (1000 * 60 * 60),
);
const { warningAfterHours, criticalAfterHours } = getStatusSla(order.status);
if (criticalAfterHours !== null && statusAgeHours >= criticalAfterHours) {
return {
enteredAt,
statusAgeHours,
statusAgeLabel: formatAgeLabel(statusAgeHours),
agingState: "critical",
};
}
if (warningAfterHours !== null && statusAgeHours >= warningAfterHours) {
return {
enteredAt,
statusAgeHours,
statusAgeLabel: formatAgeLabel(statusAgeHours),
agingState: "warning",
};
}
return {
enteredAt,
statusAgeHours,
statusAgeLabel: formatAgeLabel(statusAgeHours),
agingState: "normal",
};
};
const enrichOrderForKanban = (order, options) => {
const aging = getOrderAgingState(order, options);
return {
...order,
ownerRole: getStatusOwnerRole(order.status),
stageKey: getStatusStageKey(order.status),
stageLabel: getStatusStageLabel(order.status),
...aging,
};
};
const buildStageColumns = (orders, { includeCompleted }) => {
const mainStages = WORKFLOW_STAGES.filter((stage) => stage.key !== "completed");
const columns = mainStages.map((stage) => ({
key: stage.key,
title: stage.label,
stageKey: stage.key,
statuses: ORDER_STATUSES.filter((status) => getStatusStageKey(status) === stage.key),
items: orders.filter((order) => order.stageKey === stage.key),
}));
columns.push({
key: "delivered",
title: "Доставлен",
stageKey: "completed",
statuses: ["Доставлен"],
dropStatus: "Доставлен",
items: orders.filter((order) => order.status === "Доставлен"),
});
columns.push({
key: "completed",
title: "Завершено",
stageKey: "completed",
statuses: ["Закрыт", "Отменён"],
items: includeCompleted
? orders.filter((order) => ARCHIVE_ONLY_ORDER_STATUSES.has(order.status))
: [],
});
return columns;
};
const buildStatusColumns = (orders, { includeCompleted }) => {
const statuses = includeCompleted
? ORDER_STATUSES
: ORDER_STATUSES.filter((status) => !ARCHIVE_ONLY_ORDER_STATUSES.has(status));
return statuses.map((status) => ({
key: status,
title: status,
stageKey: getStatusStageKey(status),
statuses: [status],
dropStatus: status,
items: orders.filter((order) => order.status === status),
}));
};
export const buildKanbanColumns = (
orders,
{ includeCompleted = false, mode = "by_stage", now = new Date().toISOString() } = {},
) => {
const enrichedOrders = orders.map((order) => enrichOrderForKanban(order, { now }));
const columns =
mode === "by_status"
? buildStatusColumns(enrichedOrders, { includeCompleted })
: buildStageColumns(enrichedOrders, { includeCompleted });
return columns.map((column) => ({
...column,
items: orders.filter((order) => column.statuses.includes(order.status)),
warningCount: column.items.filter((item) => item.agingState === "warning").length,
criticalCount: column.items.filter((item) => item.agingState === "critical").length,
}));
};
export const filterKanbanColumnsByStage = (columns, stageKey = "all") => {
if (stageKey === "all") {
return columns;
}
return columns.filter((column) => column.stageKey === stageKey);
};

View File

@ -1,17 +1,61 @@
import { describe, expect, it } from "vitest";
import {
buildKanbanColumns,
filterKanbanColumnsByStage,
getOrderAgingState,
filterArchiveOrders,
filterRegistryOrders,
isCompletedOrderStatus,
} from "./orderViews";
const baseOrders = [
{ id: "1", status: "Новый" },
{ id: "2", status: "Ожидает согласования доставки" },
{ id: "3", status: "Проблема доставки" },
{ id: "4", status: "Закрыт" },
{ id: "5", status: "Доставлен" },
{
id: "1",
orderNumber: "CD-1",
customer: { name: "Иван Петров" },
items: ["Шкаф | 1 шт"],
status: "Новый",
updatedAt: "2026-03-15T08:00:00Z",
history: [{ id: "h1", newStatus: "Новый", at: "2026-03-15T08:00:00Z" }],
},
{
id: "2",
orderNumber: "CD-2",
customer: { name: "Мария Соколова" },
items: ["Кухня | 1 шт"],
status: "Ожидает согласования доставки",
updatedAt: "2026-03-13T10:00:00Z",
history: [
{ id: "h2", newStatus: "Ожидает согласования доставки", at: "2026-03-13T10:00:00Z" },
],
},
{
id: "3",
orderNumber: "CD-3",
customer: { name: "Анна Белова" },
items: ["Стеклопакет | 2 шт"],
status: "Проблема доставки",
updatedAt: "2026-03-12T09:00:00Z",
history: [{ id: "h3", newStatus: "Проблема доставки", at: "2026-03-12T09:00:00Z" }],
},
{
id: "4",
orderNumber: "CD-4",
customer: { name: "Егор Громов" },
items: ["Дверь | 1 шт"],
status: "Закрыт",
updatedAt: "2026-03-14T09:00:00Z",
history: [{ id: "h4", newStatus: "Закрыт", at: "2026-03-14T09:00:00Z" }],
},
{
id: "5",
orderNumber: "CD-5",
customer: { name: "Ольга Светлова" },
items: ["Фурнитура | 1 набор"],
status: "Доставлен",
updatedAt: "2026-03-14T11:00:00Z",
history: [{ id: "h5", newStatus: "Доставлен", at: "2026-03-14T11:00:00Z" }],
},
];
describe("orderViews", () => {
@ -33,17 +77,92 @@ describe("orderViews", () => {
expect(archive.map((order) => order.id)).toEqual(["4", "5"]);
});
it("builds kanban with separate exceptions and optional completed column", () => {
const withoutCompleted = buildKanbanColumns(baseOrders, { includeCompleted: false });
const withCompleted = buildKanbanColumns(baseOrders, { includeCompleted: true });
it("keeps completed drop-zone visible in stage mode and fills it only on demand", () => {
const withoutCompleted = buildKanbanColumns(baseOrders, {
includeCompleted: false,
mode: "by_stage",
now: "2026-03-15T12:00:00Z",
});
const withCompleted = buildKanbanColumns(baseOrders, {
includeCompleted: true,
mode: "by_stage",
now: "2026-03-15T12:00:00Z",
});
expect(withoutCompleted.map((column) => column.key)).toEqual([
"new",
"coordination",
"execution",
"exceptions",
"manager",
"production",
"logistics",
"delivery",
"delivered",
"completed",
]);
expect(withoutCompleted.find((column) => column.key === "exceptions")?.items).toHaveLength(1);
expect(withoutCompleted.find((column) => column.key === "logistics")?.items).toHaveLength(2);
expect(withoutCompleted.find((column) => column.key === "delivered")?.items).toHaveLength(1);
expect(withoutCompleted.find((column) => column.key === "completed")?.items).toHaveLength(0);
expect(withCompleted.map((column) => column.key)).toContain("completed");
expect(withCompleted.find((column) => column.key === "delivered")?.items).toHaveLength(1);
});
it("builds status-based kanban columns with per-card metadata", () => {
const columns = buildKanbanColumns(baseOrders, {
includeCompleted: false,
mode: "by_status",
now: "2026-03-15T12:00:00Z",
});
expect(columns[0].items[0]).toMatchObject({
ownerRole: "manager",
stageKey: "manager",
stageLabel: "Менеджер",
agingState: "normal",
});
expect(columns.map((column) => column.key)).toContain("Ожидает согласования доставки");
expect(columns.map((column) => column.key)).toContain("Доставлен");
expect(columns.map((column) => column.key)).not.toContain("Закрыт");
});
it("marks orders as warning or critical based on time in status", () => {
expect(
getOrderAgingState(baseOrders[0], { now: "2026-03-15T12:00:00Z" }),
).toMatchObject({ agingState: "normal" });
expect(
getOrderAgingState(baseOrders[1], { now: "2026-03-15T12:00:00Z" }),
).toMatchObject({ agingState: "warning" });
expect(
getOrderAgingState(baseOrders[2], { now: "2026-03-15T12:00:00Z" }),
).toMatchObject({ agingState: "critical" });
});
it("filters kanban columns by department for stage and status modes", () => {
const stageColumns = buildKanbanColumns(baseOrders, {
includeCompleted: true,
mode: "by_stage",
now: "2026-03-15T12:00:00Z",
});
const statusColumns = buildKanbanColumns(baseOrders, {
includeCompleted: true,
mode: "by_status",
now: "2026-03-15T12:00:00Z",
});
expect(filterKanbanColumnsByStage(stageColumns, "logistics").map((column) => column.key)).toEqual([
"logistics",
]);
expect(
filterKanbanColumnsByStage(statusColumns, "logistics").map((column) => column.key),
).toEqual([
"Ожидает ответа клиента",
"Ожидает согласования доставки",
"Доставка согласована",
"Передан логисту",
"Проблема доставки",
"Платное хранение",
]);
expect(filterKanbanColumnsByStage(statusColumns, "delivery").map((column) => column.key)).toEqual([
"Назначен водитель",
"Загружен",
"В пути",
]);
});
});

238
src/utils/anonymize1cXml.js Normal file
View File

@ -0,0 +1,238 @@
const FIRST_NAMES = [
"Алексей",
"Иван",
"Мария",
"Ольга",
"Дмитрий",
"Елена",
"Павел",
"Наталья",
];
const LAST_NAMES = [
"Тестов",
"Примеров",
"Сценариев",
"Демин",
"Маршрутов",
"Логистова",
"Контуров",
"Доставкин",
];
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const detectXmlEncoding = (buffer) => {
const asciiHeader = buffer.subarray(0, 256).toString("ascii");
const match = asciiHeader.match(/encoding=["']([^"']+)["']/i);
return match ? match[1].toLowerCase() : "utf-8";
};
const resolveDecoderEncoding = (encoding) => {
if (["windows-1251", "cp1251", "win-1251"].includes(encoding)) {
return "windows-1251";
}
return "utf-8";
};
export const decodeXmlBuffer = (buffer) => {
const encoding = resolveDecoderEncoding(detectXmlEncoding(buffer));
return new TextDecoder(encoding).decode(buffer);
};
export const normalizeXmlEncodingDeclaration = (xml) => {
if (/^<\?xml[\s\S]*encoding=/i.test(xml)) {
return xml.replace(/encoding=["'][^"']+["']/i, 'encoding="UTF-8"');
}
return xml;
};
const padDatePart = (value) => String(value).padStart(2, "0");
const buildDateFormats = (now) => {
const day = padDatePart(now.getDate());
const month = padDatePart(now.getMonth() + 1);
const year = String(now.getFullYear());
const shortYear = year.slice(-2);
return {
ddMmYy: `${day}.${month}.${shortYear}`,
ddMmYyyy: `${day}.${month}.${year}`,
ddMmYyyyDashed: `${day}-${month}-${year}`,
yyyyMmDdDotted: `${year}.${month}.${day}`,
yyyyMmDdCompact: `${year}${month}${day}`,
};
};
export const normalizeXmlDates = (xml, now = new Date()) => {
const formats = buildDateFormats(now);
return xml
.replace(/\b\d{8}(?=-\d{2}:\d{2}(?::\d{2})?\b)/g, formats.yyyyMmDdCompact)
.replace(/\b\d{4}\.\d{2}\.\d{2}(?=-\d{2}:\d{2}(?::\d{2})?\b)/g, formats.yyyyMmDdDotted)
.replace(/\b\d{2}-\d{2}-\d{4}\b/g, formats.ddMmYyyyDashed)
.replace(/\b\d{2}\.\d{2}\.\d{4}\b/g, formats.ddMmYyyy)
.replace(/\b\d{2}\.\d{2}\.\d{2}\b/g, formats.ddMmYy);
};
const replaceTagValue = (block, tagName, nextValue) => {
const pattern = new RegExp(`(<${tagName}>)([\\s\\S]*?)(</${tagName}>)`, "g");
return block.replace(pattern, (_match, open, currentValue, close) => {
if (!currentValue.trim()) {
return `${open}${currentValue}${close}`;
}
return `${open}${nextValue}${close}`;
});
};
const replaceAttributeValue = (block, attributeName, nextValue) => {
const pattern = new RegExp(`(${attributeName}=")([^"]*)(")`, "g");
return block.replace(pattern, (_match, open, currentValue, close) => {
if (!currentValue.trim()) {
return `${open}${currentValue}${close}`;
}
return `${open}${nextValue}${close}`;
});
};
const readTagValue = (block, tagName) => {
const match = block.match(new RegExp(`<${tagName}>([\\s\\S]*?)</${tagName}>`));
return match ? match[1].trim() : "";
};
const readAttributeValue = (block, attributeName) => {
const match = block.match(new RegExp(`${attributeName}="([^"]*)"`));
return match ? match[1].trim() : "";
};
const buildPhone = (index) => `+7999000${String(index).padStart(4, "0")}`;
const buildEmail = (index) => `demo.user${String(index).padStart(3, "0")}@example.test`;
const buildCustomerId = (index) => String(700000 + index);
const buildSiteUserId = (index) => String(900000 + index);
const buildOrderId = (index) => `ORDER-${String(index).padStart(5, "0")}`;
const buildIdentity = (index) => {
const firstName = FIRST_NAMES[(index - 1) % FIRST_NAMES.length];
const lastName = `${LAST_NAMES[(index - 1) % LAST_NAMES.length]} ${String(index).padStart(3, "0")}`;
return {
firstName,
lastName,
fio: `${lastName} ${firstName}`,
email: buildEmail(index),
telephone: buildPhone(index),
customerId: buildCustomerId(index),
siteUserId: buildSiteUserId(index),
};
};
const normalizeOrderAliases = (value) => {
const variants = new Set([value]);
const trimmed = value.trim();
if (/^СФ\s+/i.test(trimmed)) {
variants.add(trimmed.replace(/^СФ\s+/i, ""));
}
return Array.from(variants).filter(Boolean);
};
const getOrderReplacements = (xml) => {
const values = new Set();
const attrPattern = /nom="([^"]+)"/g;
const tagPattern = /<invoice_no>([\s\S]*?)<\/invoice_no>/g;
for (const match of xml.matchAll(attrPattern)) {
const value = match[1].trim();
if (value) {
values.add(value);
}
}
for (const match of xml.matchAll(tagPattern)) {
const value = match[1].trim();
if (value) {
values.add(value);
}
}
const replacements = new Map();
Array.from(values).forEach((value, index) => {
const replacement = buildOrderId(index + 1);
for (const variant of normalizeOrderAliases(value)) {
replacements.set(variant, replacement);
}
});
return replacements;
};
const anonymizeAccountBlock = (block, orderMap, identityMap) => {
const originalNom = readAttributeValue(block, "nom");
const originalFio = readAttributeValue(block, "fio");
const originalTel = readAttributeValue(block, "tel");
const identityKey = originalFio || originalTel || originalNom || `account-${identityMap.size + 1}`;
const identity = identityMap.get(identityKey) || buildIdentity(identityMap.size + 1);
identityMap.set(identityKey, identity);
let nextBlock = block;
if (originalNom && orderMap.has(originalNom)) {
nextBlock = replaceAttributeValue(nextBlock, "nom", orderMap.get(originalNom));
}
nextBlock = replaceAttributeValue(nextBlock, "fio", identity.fio);
nextBlock = replaceAttributeValue(nextBlock, "tel", identity.telephone.replace(/\D/g, "").slice(-10));
return nextBlock;
};
const anonymizeOrderBlock = (block, orderMap, identityMap) => {
const originalInvoiceNo = readTagValue(block, "invoice_no");
const originalFirstName = readTagValue(block, "firstname");
const originalLastName = readTagValue(block, "lastname");
const originalEmail = readTagValue(block, "email");
const originalPhone = readTagValue(block, "telephone");
const identityKey =
[originalFirstName, originalLastName, originalEmail, originalPhone].filter(Boolean).join("|") ||
`order-${identityMap.size + 1}`;
const identity = identityMap.get(identityKey) || buildIdentity(identityMap.size + 1);
identityMap.set(identityKey, identity);
let nextBlock = block;
if (originalInvoiceNo && orderMap.has(originalInvoiceNo)) {
nextBlock = replaceTagValue(nextBlock, "invoice_no", orderMap.get(originalInvoiceNo));
}
nextBlock = replaceTagValue(nextBlock, "customer_id_1с", identity.customerId);
nextBlock = replaceTagValue(nextBlock, "id_user_sait", identity.siteUserId);
nextBlock = replaceTagValue(nextBlock, "firstname", identity.firstName);
nextBlock = replaceTagValue(nextBlock, "lastname", identity.lastName);
nextBlock = replaceTagValue(nextBlock, "email", identity.email);
nextBlock = replaceTagValue(nextBlock, "telephone", identity.telephone);
nextBlock = replaceTagValue(nextBlock, "fam", identity.lastName);
nextBlock = replaceTagValue(nextBlock, "name", identity.firstName);
return nextBlock;
};
export const anonymize1cXml = (xml, options = {}) => {
const { now = new Date() } = options;
const normalizedXml = normalizeXmlDates(normalizeXmlEncodingDeclaration(xml), now);
const orderMap = getOrderReplacements(normalizedXml);
const identityMap = new Map();
let result = normalizedXml.replace(/<Account\b[\s\S]*?<\/Account>/g, (block) =>
anonymizeAccountBlock(block, orderMap, identityMap),
);
result = result.replace(/<orderid\b[\s\S]*?<\/orderid>/g, (block) =>
anonymizeOrderBlock(block, orderMap, identityMap),
);
for (const [originalValue, replacement] of orderMap.entries()) {
result = result.replace(new RegExp(escapeRegExp(originalValue), "g"), replacement);
}
return result;
};

View File

@ -0,0 +1,99 @@
import { Buffer } from "node:buffer";
import { describe, expect, it } from "vitest";
import {
anonymize1cXml,
decodeXmlBuffer,
normalizeXmlDates,
normalizeXmlEncodingDeclaration,
} from "./anonymize1cXml";
const FIXED_NOW = new Date("2026-04-13T10:00:00Z");
describe("anonymize1cXml", () => {
it("anonymizes Account-based 1C export blocks, preserves structure, and rewrites dates to today", () => {
const source = `
<root>
<Account nom="СФ TEST-001" fio="Иванов Иван Иванович" tel="9781234567" data="25.12.25">
<Associated_bill>TEST-001 (25.12.25); TEST-009 (25.12.25)</Associated_bill>
<SMS>20260317-08:34</SMS>
<Ship>2026.03.17-09:34</Ship>
</Account>
</root>`;
const result = anonymize1cXml(source, { now: FIXED_NOW });
expect(result).toContain('<Account nom="ORDER-00001"');
expect(result).not.toContain("Иванов Иван Иванович");
expect(result).not.toContain("9781234567");
expect(result).not.toContain("TEST-001");
expect(result).toContain('data="13.04.26"');
expect(result).toContain("<SMS>20260413-08:34</SMS>");
expect(result).toContain("<Ship>2026.04.13-09:34</Ship>");
expect(result).toContain("<Associated_bill>ORDER-00001 (13.04.26); TEST-009 (13.04.26)</Associated_bill>");
});
it("anonymizes order-based XML export blocks with stable identities and rewrites invoice dates to today", () => {
const source = `
<orders>
<orderid>
<invoice_no>20431</invoice_no>
<invoice_date>12-12-2024 13:00:00</invoice_date>
<customer_id_1с>1857</customer_id_1с>
<id_user_sait>195</id_user_sait>
<firstname>Виталий</firstname>
<lastname>Вохт</lastname>
<email>wil-1944@mail.ru</email>
<telephone>+7(978)7777777</telephone>
<address>
<fam>Вохт</fam>
<name>Виталий</name>
<town>Ялта</town>
</address>
</orderid>
</orders>`;
const result = anonymize1cXml(source, { now: FIXED_NOW });
expect(result).toContain("<invoice_no>ORDER-00001</invoice_no>");
expect(result).toContain("<invoice_date>13-04-2026 13:00:00</invoice_date>");
expect(result).not.toContain("Виталий");
expect(result).not.toContain("Вохт");
expect(result).not.toContain("wil-1944@mail.ru");
expect(result).not.toContain("+7(978)7777777");
expect(result).toContain("<town>Ялта</town>");
expect(result).toContain("@example.test");
expect(result).toContain("<customer_id_1с>700001</customer_id_1с>");
expect(result).toContain("<id_user_sait>900001</id_user_sait>");
});
it("normalizes standalone XML dates to the provided current day while preserving time", () => {
const source = `
<root>
<invoice_date>12-12-2024 13:00:00</invoice_date>
<data>25.12.25</data>
<sms>20260317-08:34</sms>
<ship>2026.03.17-09:34</ship>
</root>`;
const result = normalizeXmlDates(source, FIXED_NOW);
expect(result).toContain("<invoice_date>13-04-2026 13:00:00</invoice_date>");
expect(result).toContain("<data>13.04.26</data>");
expect(result).toContain("<sms>20260413-08:34</sms>");
expect(result).toContain("<ship>2026.04.13-09:34</ship>");
});
it("decodes windows-1251 XML headers correctly and normalizes output encoding", () => {
const bytes = Buffer.from([
0x3c,0x3f,0x78,0x6d,0x6c,0x20,0x76,0x65,0x72,0x73,0x69,0x6f,0x6e,0x3d,0x22,0x31,0x2e,0x30,0x22,0x20,
0x65,0x6e,0x63,0x6f,0x64,0x69,0x6e,0x67,0x3d,0x22,0x57,0x49,0x4e,0x44,0x4f,0x57,0x53,0x2d,0x31,0x32,0x35,0x31,0x22,0x3f,0x3e,
0x3c,0x72,0x6f,0x6f,0x74,0x3e,0xc8,0xe2,0xe0,0xed,0x3c,0x2f,0x72,0x6f,0x6f,0x74,0x3e
]);
const decoded = decodeXmlBuffer(bytes);
expect(decoded).toContain('encoding="WINDOWS-1251"');
expect(decoded).toContain("Иван");
expect(normalizeXmlEncodingDeclaration(decoded)).toContain('encoding="UTF-8"');
});
});

View File

@ -1,15 +1,28 @@
import { format } from "date-fns";
import { format, isValid } from "date-fns";
const toValidDate = (value) => {
if (!value) {
return null;
}
const nextDate = new Date(value);
return isValid(nextDate) ? nextDate : null;
};
export const formatDateTime = (value) => {
if (!value) {
const date = toValidDate(value);
if (!date) {
return "Не указано";
}
return format(new Date(value), "dd.MM.yyyy HH:mm");
return format(date, "dd.MM.yyyy HH:mm");
};
export const formatDate = (value) => {
if (!value) {
const date = toValidDate(value);
if (!date) {
return "Не указано";
}
return format(new Date(value), "dd.MM.yyyy");
return format(date, "dd.MM.yyyy");
};

View File

@ -26,7 +26,7 @@ curl -X POST \
`chat_messages`.
Если передан `workflowAction=send_delivery_offer`, функция дополнительно переводит заказ в
`Ожидает согласования доставки` и выставляет `delivery_agreement_status = 'Отправлено клиенту'`.
`Ожидает ответа клиента` и выставляет `delivery_agreement_status = 'Отправлено клиенту'`.
Ожидаемые переменные:
@ -35,3 +35,26 @@ curl -X POST \
- `TELEGRAM_BOT_TOKEN`
- `VK_BOT_TOKEN`
- `MESSENGER_MAX_TOKEN`
## `create-delivery-invitation`
Создает или обновляет активное приглашение для публичной клиентской ссылки, сохраняет
`delivery_invitations`, обновляет заказ в статус `Ожидает ответа клиента` и возвращает публичный URL.
## `get-delivery-invitation`
Возвращает публичное состояние приглашения по токену. Используется страницей клиента для показа
актуального статуса заказа.
## `confirm-delivery-choice`
Фиксирует выбор времени доставки клиентом, переводит заказ в `Доставка согласована` и создает
историю события.
## `transfer-to-logistics`
Используется для ручной передачи заказа логисту или перевода в `Платное хранение`.
## `report-delivery-result`
Фиксирует итог доставки, включая успешную доставку и проблемные сценарии.

View File

@ -19,9 +19,16 @@ describe("chatbot workflow mapping", () => {
});
});
it("marks outbound delivery offer as sent to client", () => {
it("marks outbound delivery offer as awaiting client response", () => {
expect(getOrderUpdateForOutboundDispatch("send_delivery_offer")).toEqual({
status: "Ожидает согласования доставки",
status: "Ожидает ответа клиента",
deliveryAgreementStatus: "Отправлено клиенту",
});
});
it("keeps reminder dispatch in the same awaiting response state", () => {
expect(getOrderUpdateForOutboundDispatch("send_delivery_reminder")).toEqual({
status: "Ожидает ответа клиента",
deliveryAgreementStatus: "Отправлено клиенту",
});
});

View File

@ -1,3 +1,5 @@
import { getOrderUpdateForDeliveryInvitationAction } from "./delivery-invitations.ts";
export type InboundWorkflowAction =
| "confirm_delivery"
| "reschedule"
@ -35,10 +37,7 @@ export const getOrderUpdateForOutboundDispatch = (action: OutboundWorkflowAction
switch (action) {
case "send_delivery_offer":
case "send_delivery_reminder":
return {
status: "Ожидает согласования доставки",
deliveryAgreementStatus: "Отправлено клиенту",
};
return getOrderUpdateForDeliveryInvitationAction(action);
default:
return null;
}

View File

@ -81,6 +81,40 @@ create table if not exists public.error_logs (
created_at timestamptz not null default timezone('utc', now())
);
create table if not exists public.delivery_invitations (
id uuid primary key default gen_random_uuid(),
order_id uuid not null references public.orders (id) on delete cascade unique,
token_hash text not null unique,
state text not null default 'awaiting_choice',
order_number text,
customer_name text,
customer_phone text,
customer_messenger text,
available_slots text[] not null default array['Первая половина дня', 'Вторая половина дня'],
delivery_date date,
delivery_time text,
sent_at timestamptz,
opened_at timestamptz,
confirmed_at timestamptz,
logistics_transferred_at timestamptz,
paid_storage_at timestamptz,
delivered_at timestamptz,
created_at timestamptz not null default timezone('utc', now()),
updated_at timestamptz not null default timezone('utc', now())
);
create table if not exists public.integration_events (
id uuid primary key default gen_random_uuid(),
order_id uuid references public.orders (id) on delete set null,
event_type text not null,
direction text not null default 'internal',
source text not null default 'supabase-function',
status text not null default 'success',
payload jsonb not null default '{}'::jsonb,
error_message text,
created_at timestamptz not null default timezone('utc', now())
);
alter table public.orders add column if not exists delivery_agreement_status text not null default 'Не начато';
alter table public.orders add column if not exists assigned_driver_id uuid references public.users (id);
alter table public.chat_messages drop constraint if exists chat_messages_channel_check;
@ -88,6 +122,22 @@ alter table public.chat_messages
add constraint chat_messages_channel_check
check (channel in ('telegram', 'vk', 'messenger_max', 'sms', 'email'));
alter table public.delivery_invitations add column if not exists state text not null default 'awaiting_choice';
alter table public.delivery_invitations add column if not exists delivery_date date;
alter table public.delivery_invitations add column if not exists delivery_time text;
alter table public.delivery_invitations add column if not exists sent_at timestamptz;
alter table public.delivery_invitations add column if not exists opened_at timestamptz;
alter table public.delivery_invitations add column if not exists confirmed_at timestamptz;
alter table public.delivery_invitations add column if not exists logistics_transferred_at timestamptz;
alter table public.delivery_invitations add column if not exists paid_storage_at timestamptz;
alter table public.delivery_invitations add column if not exists delivered_at timestamptz;
alter table public.delivery_invitations add column if not exists updated_at timestamptz not null default timezone('utc', now());
alter table public.integration_events add column if not exists direction text not null default 'internal';
alter table public.integration_events add column if not exists source text not null default 'supabase-function';
alter table public.integration_events add column if not exists status text not null default 'success';
alter table public.integration_events add column if not exists payload jsonb not null default '{}'::jsonb;
alter table public.integration_events add column if not exists error_message text;
insert into public.roles (name, permissions)
values
(
@ -128,6 +178,12 @@ before update on public.orders
for each row
execute function public.set_updated_at();
drop trigger if exists delivery_invitations_set_updated_at on public.delivery_invitations;
create trigger delivery_invitations_set_updated_at
before update on public.delivery_invitations
for each row
execute function public.set_updated_at();
create or replace function public.current_role_name()
returns text
language sql
@ -246,6 +302,11 @@ create index if not exists idx_orders_search on public.orders using gin (
create index if not exists idx_chat_messages_search on public.chat_messages using gin (
to_tsvector('russian', coalesce(text, ''))
);
create index if not exists idx_delivery_invitations_order_id on public.delivery_invitations (order_id);
create index if not exists idx_delivery_invitations_token_hash on public.delivery_invitations (token_hash);
create index if not exists idx_delivery_invitations_state on public.delivery_invitations (state);
create index if not exists idx_integration_events_order_id on public.integration_events (order_id, created_at desc);
create index if not exists idx_integration_events_event_type on public.integration_events (event_type);
alter table public.roles enable row level security;
alter table public.users enable row level security;
@ -255,6 +316,8 @@ alter table public.order_history enable row level security;
alter table public.delivery_slots enable row level security;
alter table public.chat_messages enable row level security;
alter table public.error_logs enable row level security;
alter table public.delivery_invitations enable row level security;
alter table public.integration_events enable row level security;
drop policy if exists "roles admin only" on public.roles;
create policy "roles admin only" on public.roles
@ -443,3 +506,15 @@ create policy "error logs admin only" on public.error_logs
for all
using (public.current_role_name() = 'admin')
with check (public.current_role_name() = 'admin');
drop policy if exists "delivery invitations admin only" on public.delivery_invitations;
create policy "delivery invitations admin only" on public.delivery_invitations
for all
using (public.current_role_name() = 'admin')
with check (public.current_role_name() = 'admin');
drop policy if exists "integration events admin only" on public.integration_events;
create policy "integration events admin only" on public.integration_events
for all
using (public.current_role_name() = 'admin')
with check (public.current_role_name() = 'admin');