refactor: align delivery flow with 1c imports
This commit is contained in:
parent
a534d53e61
commit
1798e3acfd
|
|
@ -8,7 +8,8 @@
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint . --ext js,jsx",
|
"lint": "eslint . --ext js,jsx",
|
||||||
"test": "vitest run"
|
"test": "vitest run",
|
||||||
|
"anonymize:1c-xml": "node scripts/anonymize-1c-xml.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/supabase-js": "^2.52.0",
|
"@supabase/supabase-js": "^2.52.0",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
|
@ -22,11 +22,11 @@ export const Modal = ({ children, isOpen, onClose, className }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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="absolute inset-0" onClick={onClose} />
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { cn } from "../../lib/cn";
|
import { cn } from "../../lib/cn";
|
||||||
|
|
||||||
export const Panel = ({ children, className }) => {
|
export const Panel = ({ children, className, ...props }) => {
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-soft backdrop-blur",
|
"rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-soft backdrop-blur",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,14 @@ export const OtpLoginForm = ({
|
||||||
error,
|
error,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Panel className="w-full max-w-md p-8">
|
<Panel className="w-full max-w-md p-5 sm:p-8">
|
||||||
<div className="mb-8 space-y-2">
|
<div className="mb-6 space-y-2 sm:mb-8">
|
||||||
<p className="text-sm uppercase tracking-[0.28em] text-[var(--color-text-muted)]">
|
<p className="text-sm uppercase tracking-[0.28em] text-[var(--color-text-muted)]">
|
||||||
Платформа доставки
|
Платформа доставки
|
||||||
</p>
|
</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)]">
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
Введите email, и код придет на почту. В рабочем режиме доступ определяется учетной
|
Введите email, и код придет на почту. В рабочем режиме доступ определяется учетной
|
||||||
записью в системе, а не выбором роли.
|
записью в системе, а не выбором роли.
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { ROLE_PERMISSIONS } from "../../constants/roles";
|
import { ROLE_PERMISSIONS } from "../../constants/roles";
|
||||||
import {
|
import {
|
||||||
getAvailableTransitionsByRole,
|
|
||||||
getDeliveryAgreementComment,
|
getDeliveryAgreementComment,
|
||||||
getOrderStatusComment,
|
getOrderStatusComment,
|
||||||
getStatusTone,
|
getStatusTone,
|
||||||
} from "../../constants/deliveryWorkflow";
|
} from "../../constants/deliveryWorkflow";
|
||||||
import { demoUsers } from "../../data/mockAppData";
|
import { demoUsers } from "../../data/mockAppData";
|
||||||
|
import { getAvailableTransitionsForOrder } from "../../services/orderService";
|
||||||
import { formatDateTime } from "../../utils/formatters";
|
import { formatDateTime } from "../../utils/formatters";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
import { Button } from "../UI/Button";
|
import { Button } from "../UI/Button";
|
||||||
|
|
@ -29,11 +29,13 @@ export const OrderDetailPanel = ({
|
||||||
order,
|
order,
|
||||||
currentUser,
|
currentUser,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
|
onAssignDriver,
|
||||||
onClientMessage,
|
onClientMessage,
|
||||||
onInternalMessage,
|
onInternalMessage,
|
||||||
onOrderNote,
|
onOrderNote,
|
||||||
}) => {
|
}) => {
|
||||||
const [nextStatus, setNextStatus] = React.useState(order?.status || "Новый");
|
const [nextStatus, setNextStatus] = React.useState(order?.status || "Новый");
|
||||||
|
const [selectedDriverId, setSelectedDriverId] = React.useState(order?.assignedDriverId || "");
|
||||||
const [clientReply, setClientReply] = React.useState("Подтверждаю доставку");
|
const [clientReply, setClientReply] = React.useState("Подтверждаю доставку");
|
||||||
const [chatQuery, setChatQuery] = React.useState("");
|
const [chatQuery, setChatQuery] = React.useState("");
|
||||||
const [activeTab, setActiveTab] = React.useState("overview");
|
const [activeTab, setActiveTab] = React.useState("overview");
|
||||||
|
|
@ -42,6 +44,7 @@ export const OrderDetailPanel = ({
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setNextStatus(order?.status || "Новый");
|
setNextStatus(order?.status || "Новый");
|
||||||
|
setSelectedDriverId(order?.assignedDriverId || "");
|
||||||
setChatQuery("");
|
setChatQuery("");
|
||||||
setActiveTab("overview");
|
setActiveTab("overview");
|
||||||
setTeamReply("Новый комментарий для команды");
|
setTeamReply("Новый комментарий для команды");
|
||||||
|
|
@ -68,10 +71,12 @@ export const OrderDetailPanel = ({
|
||||||
{ key: "chat", label: "Чат с клиентом" },
|
{ key: "chat", label: "Чат с клиентом" },
|
||||||
{ key: "team", label: "Команда" },
|
{ key: "team", label: "Команда" },
|
||||||
];
|
];
|
||||||
const availableTransitions = getAvailableTransitionsByRole({
|
const availableTransitions = getAvailableTransitionsForOrder({
|
||||||
status: order.status,
|
order,
|
||||||
role: currentUser.role,
|
role: currentUser.role,
|
||||||
});
|
});
|
||||||
|
const canAssignDriver = currentUser.role === "logistician" || currentUser.role === "admin";
|
||||||
|
const drivers = demoUsers.filter((user) => user.role === "driver");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
|
|
@ -116,7 +121,7 @@ export const OrderDetailPanel = ({
|
||||||
|
|
||||||
{activeTab === "overview" ? (
|
{activeTab === "overview" ? (
|
||||||
<div className="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
|
<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">
|
<Panel className="p-5">
|
||||||
<div className="mb-4 flex items-center justify-between gap-3">
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
<strong>Данные клиента</strong>
|
<strong>Данные клиента</strong>
|
||||||
|
|
@ -172,7 +177,7 @@ export const OrderDetailPanel = ({
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="order-1 space-y-4 xl:order-none">
|
||||||
<Panel className="space-y-4 p-5">
|
<Panel className="space-y-4 p-5">
|
||||||
<div>
|
<div>
|
||||||
<strong>Управление заказом</strong>
|
<strong>Управление заказом</strong>
|
||||||
|
|
@ -208,6 +213,32 @@ export const OrderDetailPanel = ({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 className="text-sm text-[var(--color-text-muted)]">
|
||||||
Для вашей роли доступны типовые действия:
|
Для вашей роли доступны типовые действия:
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,15 @@ import { Input } from "../UI/Input";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
import { Select } from "../UI/Select";
|
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 = {
|
const initialForm = {
|
||||||
orderNumber: "",
|
orderNumber: "",
|
||||||
customerName: "",
|
customerName: "",
|
||||||
customerPhone: "",
|
customerPhone: "",
|
||||||
customerAddress: "",
|
customerAddress: "",
|
||||||
messenger: "Телеграм",
|
messenger: "Телеграм",
|
||||||
managerId: managerOptions[0]?.id || "",
|
managerId: "",
|
||||||
deliveryDate: "",
|
deliveryDate: "",
|
||||||
items: "",
|
items: "",
|
||||||
comments: "",
|
comments: "",
|
||||||
|
|
@ -26,10 +27,12 @@ export const OrderEditorPanel = ({
|
||||||
onSaveOrder,
|
onSaveOrder,
|
||||||
createOnly = false,
|
createOnly = false,
|
||||||
onDone,
|
onDone,
|
||||||
|
users,
|
||||||
}) => {
|
}) => {
|
||||||
const [form, setForm] = React.useState(initialForm);
|
const [form, setForm] = React.useState(initialForm);
|
||||||
const [isCreateMode, setIsCreateMode] = React.useState(createOnly);
|
const [isCreateMode, setIsCreateMode] = React.useState(createOnly);
|
||||||
const canManageOrders = currentUser.role === "manager" || currentUser.role === "admin";
|
const canManageOrders = currentUser.role === "manager" || currentUser.role === "admin";
|
||||||
|
const managerOptions = getManagerOptions(users);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!createOnly) {
|
if (!createOnly) {
|
||||||
|
|
@ -56,10 +59,10 @@ export const OrderEditorPanel = ({
|
||||||
customerAddress: selectedOrder.customer.address,
|
customerAddress: selectedOrder.customer.address,
|
||||||
messenger: selectedOrder.customer.messenger,
|
messenger: selectedOrder.customer.messenger,
|
||||||
managerId: selectedOrder.managerId,
|
managerId: selectedOrder.managerId,
|
||||||
deliveryDate: selectedOrder.deliverySlots[0]?.date || "",
|
deliveryDate: selectedOrder.deliverySlots?.[0]?.date || "",
|
||||||
items: (selectedOrder.items || []).join("\n"),
|
items: (selectedOrder.items || []).join("\n"),
|
||||||
comments: selectedOrder.comments.join(", "),
|
comments: (selectedOrder.comments || []).join(", "),
|
||||||
tags: selectedOrder.tags.join(", "),
|
tags: (selectedOrder.tags || []).join(", "),
|
||||||
});
|
});
|
||||||
}, [isCreateMode, selectedOrder]);
|
}, [isCreateMode, selectedOrder]);
|
||||||
|
|
||||||
|
|
@ -112,18 +115,18 @@ export const OrderEditorPanel = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold">Управление заказом</h3>
|
<h3 className="text-lg font-semibold">Управление заказом</h3>
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
Создание и редактирование заказа с полями клиента, канала связи и даты доставки.
|
Редактирование импортированного из 1С заказа с полями клиента, канала связи и даты доставки.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{!createOnly ? (
|
{!createOnly ? (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant="secondary" onClick={resetForCreate} disabled={!canManageOrders}>
|
<Button variant="secondary" onClick={resetForCreate} disabled={!canManageOrders}>
|
||||||
Новый заказ
|
Импортированный заказ
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" onClick={resetForSelected}>
|
<Button variant="ghost" onClick={resetForSelected}>
|
||||||
Сбросить
|
Сбросить
|
||||||
|
|
@ -134,7 +137,7 @@ export const OrderEditorPanel = ({
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Номер заказа"
|
placeholder="Номер заказа из 1С"
|
||||||
value={form.orderNumber}
|
value={form.orderNumber}
|
||||||
onChange={(event) => updateField("orderNumber", event.target.value)}
|
onChange={(event) => updateField("orderNumber", event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -149,17 +152,17 @@ export const OrderEditorPanel = ({
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Имя клиента"
|
placeholder="Имя клиента из 1С"
|
||||||
value={form.customerName}
|
value={form.customerName}
|
||||||
onChange={(event) => updateField("customerName", event.target.value)}
|
onChange={(event) => updateField("customerName", event.target.value)}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Телефон"
|
placeholder="Телефон клиента"
|
||||||
value={form.customerPhone}
|
value={form.customerPhone}
|
||||||
onChange={(event) => updateField("customerPhone", event.target.value)}
|
onChange={(event) => updateField("customerPhone", event.target.value)}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Адрес доставки"
|
placeholder="Адрес доставки из 1С"
|
||||||
value={form.customerAddress}
|
value={form.customerAddress}
|
||||||
onChange={(event) => updateField("customerAddress", event.target.value)}
|
onChange={(event) => updateField("customerAddress", event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -199,9 +202,11 @@ export const OrderEditorPanel = ({
|
||||||
onChange={(event) => updateField("comments", event.target.value)}
|
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}>
|
<Button className="w-full" onClick={handleSubmit} disabled={!canManageOrders}>
|
||||||
{isCreateMode ? "Создать заказ" : "Сохранить изменения"}
|
{isCreateMode ? "Сохранить импорт" : "Сохранить изменения"}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { WORKFLOW_STAGES } from "../../constants/deliveryWorkflow";
|
||||||
import { ORDER_STATUSES } from "../../constants/orderStatuses";
|
import { ORDER_STATUSES } from "../../constants/orderStatuses";
|
||||||
|
import { ROLE_LABELS } from "../../constants/roles";
|
||||||
import { demoUsers } from "../../data/mockAppData";
|
import { demoUsers } from "../../data/mockAppData";
|
||||||
|
import { Badge } from "../UI/Badge";
|
||||||
|
import { Button } from "../UI/Button";
|
||||||
import { Input } from "../UI/Input";
|
import { Input } from "../UI/Input";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
import { Select } from "../UI/Select";
|
import { Select } from "../UI/Select";
|
||||||
|
|
@ -8,74 +12,191 @@ import { Select } from "../UI/Select";
|
||||||
const logisticians = demoUsers.filter((user) => user.role === "logistician");
|
const logisticians = demoUsers.filter((user) => user.role === "logistician");
|
||||||
const managers = demoUsers.filter((user) => user.role === "manager");
|
const managers = demoUsers.filter((user) => user.role === "manager");
|
||||||
const messengers = ["Телеграм", "ВКонтакте", "Макс", "СМС", "Эл. почта"];
|
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 }) => {
|
export const OrderFilters = ({ filters, setFilters }) => {
|
||||||
return (
|
const [isMobileFiltersOpen, setIsMobileFiltersOpen] = React.useState(false);
|
||||||
<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 }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Select
|
const activeChips = [
|
||||||
value={filters.status}
|
filters.status !== "all" ? { key: "status", label: filters.status } : null,
|
||||||
onChange={(event) =>
|
filters.stage !== "all"
|
||||||
setFilters((current) => ({ ...current, status: event.target.value }))
|
? { 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>
|
<option value="all">Все статусы</option>
|
||||||
{ORDER_STATUSES.map((status) => (
|
{ORDER_STATUSES.map((status) => (
|
||||||
<option key={status} value={status}>
|
<option key={status} value={status}>
|
||||||
{status}
|
{status}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>,
|
||||||
|
showLabels,
|
||||||
|
)}
|
||||||
|
|
||||||
<Select
|
{renderFilterField(
|
||||||
value={filters.managerId}
|
"Этап",
|
||||||
onChange={(event) =>
|
<Select value={filters.stage} onChange={(event) => updateFilter("stage", event.target.value)}>
|
||||||
setFilters((current) => ({ ...current, managerId: 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>
|
<option value="all">Все менеджеры</option>
|
||||||
{managers.map((manager) => (
|
{managers.map((manager) => (
|
||||||
<option key={manager.id} value={manager.id}>
|
<option key={manager.id} value={manager.id}>
|
||||||
{manager.name}
|
{manager.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>,
|
||||||
|
showLabels,
|
||||||
|
)}
|
||||||
|
|
||||||
<Select
|
{renderFilterField(
|
||||||
value={filters.logisticianId}
|
"Логист",
|
||||||
onChange={(event) =>
|
<Select value={filters.logisticianId} onChange={(event) => updateFilter("logisticianId", event.target.value)}>
|
||||||
setFilters((current) => ({ ...current, logisticianId: event.target.value }))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="all">Все логисты</option>
|
<option value="all">Все логисты</option>
|
||||||
{logisticians.map((logistician) => (
|
{logisticians.map((logistician) => (
|
||||||
<option key={logistician.id} value={logistician.id}>
|
<option key={logistician.id} value={logistician.id}>
|
||||||
{logistician.name}
|
{logistician.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>,
|
||||||
|
showLabels,
|
||||||
|
)}
|
||||||
|
|
||||||
<Select
|
{renderFilterField(
|
||||||
value={filters.messenger}
|
"Канал",
|
||||||
onChange={(event) =>
|
<Select value={filters.messenger} onChange={(event) => updateFilter("messenger", event.target.value)}>
|
||||||
setFilters((current) => ({ ...current, messenger: event.target.value }))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="all">Все каналы</option>
|
<option value="all">Все каналы</option>
|
||||||
{messengers.map((messenger) => (
|
{messengers.map((messenger) => (
|
||||||
<option key={messenger} value={messenger}>
|
<option key={messenger} value={messenger}>
|
||||||
{messenger}
|
{messenger}
|
||||||
</option>
|
</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>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,13 @@ export const OrdersCalendarView = ({ orders, onOpenOrder }) => {
|
||||||
}, {}),
|
}, {}),
|
||||||
[orders],
|
[orders],
|
||||||
);
|
);
|
||||||
|
const agendaDays = React.useMemo(
|
||||||
|
() =>
|
||||||
|
Object.entries(ordersByDay)
|
||||||
|
.sort(([left], [right]) => new Date(left) - new Date(right))
|
||||||
|
.map(([key, dayOrders]) => ({ key, dayOrders })),
|
||||||
|
[ordersByDay],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel className="space-y-5 p-6">
|
<Panel className="space-y-5 p-6">
|
||||||
|
|
@ -85,6 +92,37 @@ export const OrdersCalendarView = ({ orders, onOpenOrder }) => {
|
||||||
</div>
|
</div>
|
||||||
</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)]">
|
<div className="grid grid-cols-7 gap-3 text-xs uppercase tracking-[0.12em] text-[var(--color-text-muted)]">
|
||||||
{WEEK_DAYS.map((day) => (
|
{WEEK_DAYS.map((day) => (
|
||||||
<div key={day} className="px-2">
|
<div key={day} className="px-2">
|
||||||
|
|
@ -93,13 +131,13 @@ export const OrdersCalendarView = ({ orders, onOpenOrder }) => {
|
||||||
))}
|
))}
|
||||||
</div>
|
</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) => {
|
{calendarDays.map((day, index) => {
|
||||||
if (!day) {
|
if (!day) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`empty-${index}`}
|
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>
|
||||||
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,35 @@ export const OrdersTable = ({ orders, selectedOrderId, onOpenOrder }) => {
|
||||||
<Badge tone="neutral">{orders.length}</Badge>
|
<Badge tone="neutral">{orders.length}</Badge>
|
||||||
</div>
|
</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">
|
<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)]">
|
<thead className="bg-[var(--color-surface-strong)] text-left text-xs uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
|
||||||
<tr>
|
<tr>
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
export const ORDER_STATUS_META = {
|
||||||
"Новый": {
|
"Новый": {
|
||||||
comment: "Заказ создан и ожидает проверки менеджером.",
|
comment: "Заказ создан и ожидает проверки менеджером.",
|
||||||
ownerRole: "manager",
|
ownerRole: "manager",
|
||||||
|
stageKey: "manager",
|
||||||
|
stageLabel: getStageLabel("manager"),
|
||||||
|
warningAfterHours: 24,
|
||||||
|
criticalAfterHours: 48,
|
||||||
tone: "neutral",
|
tone: "neutral",
|
||||||
},
|
},
|
||||||
"Требует уточнения": {
|
"Требует уточнения": {
|
||||||
comment: "В заказе не хватает данных, их должен уточнить менеджер.",
|
comment: "В заказе не хватает данных, их должен уточнить менеджер.",
|
||||||
ownerRole: "manager",
|
ownerRole: "manager",
|
||||||
|
stageKey: "manager",
|
||||||
|
stageLabel: getStageLabel("manager"),
|
||||||
|
warningAfterHours: 12,
|
||||||
|
criticalAfterHours: 24,
|
||||||
tone: "warning",
|
tone: "warning",
|
||||||
},
|
},
|
||||||
"Подтверждён менеджером": {
|
"Подтверждён менеджером": {
|
||||||
comment: "Менеджер проверил заказ и передал его дальше в работу.",
|
comment: "Менеджер проверил заказ и передал его дальше в работу.",
|
||||||
ownerRole: "manager",
|
ownerRole: "manager",
|
||||||
|
stageKey: "manager",
|
||||||
|
stageLabel: getStageLabel("manager"),
|
||||||
|
warningAfterHours: 12,
|
||||||
|
criticalAfterHours: 24,
|
||||||
tone: "accent",
|
tone: "accent",
|
||||||
},
|
},
|
||||||
"В очереди производства": {
|
"В очереди производства": {
|
||||||
comment: "Заказ передан на производство и ожидает запуска.",
|
comment: "Заказ передан на производство и ожидает запуска.",
|
||||||
ownerRole: "production_lead",
|
ownerRole: "production_lead",
|
||||||
|
stageKey: "production",
|
||||||
|
stageLabel: getStageLabel("production"),
|
||||||
|
warningAfterHours: 24,
|
||||||
|
criticalAfterHours: 48,
|
||||||
tone: "neutral",
|
tone: "neutral",
|
||||||
},
|
},
|
||||||
"В производстве": {
|
"В производстве": {
|
||||||
comment: "Заказ находится в изготовлении.",
|
comment: "Заказ находится в изготовлении.",
|
||||||
ownerRole: "production_lead",
|
ownerRole: "production_lead",
|
||||||
|
stageKey: "production",
|
||||||
|
stageLabel: getStageLabel("production"),
|
||||||
|
warningAfterHours: 48,
|
||||||
|
criticalAfterHours: 96,
|
||||||
tone: "accent",
|
tone: "accent",
|
||||||
},
|
},
|
||||||
"Готов к отгрузке": {
|
"Готов к отгрузке": {
|
||||||
comment: "Производство завершено, можно запускать согласование доставки.",
|
comment: "Производство завершено, можно запускать согласование доставки.",
|
||||||
ownerRole: "production_lead",
|
ownerRole: "production_lead",
|
||||||
|
stageKey: "production",
|
||||||
|
stageLabel: getStageLabel("production"),
|
||||||
|
warningAfterHours: 8,
|
||||||
|
criticalAfterHours: 24,
|
||||||
tone: "accent",
|
tone: "accent",
|
||||||
},
|
},
|
||||||
|
"Ожидает ответа клиента": {
|
||||||
|
comment: "Клиенту отправлена ссылка, система ждёт подтверждения времени доставки.",
|
||||||
|
ownerRole: "logistician",
|
||||||
|
stageKey: "logistics",
|
||||||
|
stageLabel: getStageLabel("logistics"),
|
||||||
|
warningAfterHours: 1,
|
||||||
|
criticalAfterHours: 3,
|
||||||
|
tone: "warning",
|
||||||
|
},
|
||||||
"Ожидает согласования доставки": {
|
"Ожидает согласования доставки": {
|
||||||
comment: "Клиенту отправлено предложение выбрать дату и половину дня доставки.",
|
comment: "Клиенту отправлено предложение выбрать дату и половину дня доставки.",
|
||||||
ownerRole: "logistician",
|
ownerRole: "logistician",
|
||||||
|
stageKey: "logistics",
|
||||||
|
stageLabel: getStageLabel("logistics"),
|
||||||
|
warningAfterHours: 24,
|
||||||
|
criticalAfterHours: 96,
|
||||||
tone: "warning",
|
tone: "warning",
|
||||||
},
|
},
|
||||||
"Доставка согласована": {
|
"Доставка согласована": {
|
||||||
comment: "Клиент подтвердил доставку, логист может назначать рейс.",
|
comment: "Клиент подтвердил доставку, логист может назначать рейс.",
|
||||||
ownerRole: "logistician",
|
ownerRole: "logistician",
|
||||||
|
stageKey: "logistics",
|
||||||
|
stageLabel: getStageLabel("logistics"),
|
||||||
|
warningAfterHours: 12,
|
||||||
|
criticalAfterHours: 24,
|
||||||
tone: "accent",
|
tone: "accent",
|
||||||
},
|
},
|
||||||
|
"Передан логисту": {
|
||||||
|
comment: "Согласование не завершилось автоматически, заказ передан логисту для ручной работы.",
|
||||||
|
ownerRole: "logistician",
|
||||||
|
stageKey: "logistics",
|
||||||
|
stageLabel: getStageLabel("logistics"),
|
||||||
|
warningAfterHours: 4,
|
||||||
|
criticalAfterHours: 12,
|
||||||
|
tone: "warning",
|
||||||
|
},
|
||||||
"Назначен водитель": {
|
"Назначен водитель": {
|
||||||
comment: "Логист распределил заказ на конкретного водителя.",
|
comment: "Логист распределил заказ на конкретного водителя.",
|
||||||
ownerRole: "logistician",
|
ownerRole: "logistician",
|
||||||
|
stageKey: "delivery",
|
||||||
|
stageLabel: getStageLabel("delivery"),
|
||||||
|
warningAfterHours: 12,
|
||||||
|
criticalAfterHours: 24,
|
||||||
tone: "accent",
|
tone: "accent",
|
||||||
},
|
},
|
||||||
Загружен: {
|
Загружен: {
|
||||||
comment: "Заказ физически загружен в транспорт.",
|
comment: "Заказ физически загружен в транспорт.",
|
||||||
ownerRole: "driver",
|
ownerRole: "driver",
|
||||||
|
stageKey: "delivery",
|
||||||
|
stageLabel: getStageLabel("delivery"),
|
||||||
|
warningAfterHours: 8,
|
||||||
|
criticalAfterHours: 24,
|
||||||
tone: "neutral",
|
tone: "neutral",
|
||||||
},
|
},
|
||||||
"В пути": {
|
"В пути": {
|
||||||
comment: "Водитель выехал и выполняет доставку.",
|
comment: "Водитель выехал и выполняет доставку.",
|
||||||
ownerRole: "driver",
|
ownerRole: "driver",
|
||||||
|
stageKey: "delivery",
|
||||||
|
stageLabel: getStageLabel("delivery"),
|
||||||
|
warningAfterHours: 12,
|
||||||
|
criticalAfterHours: 24,
|
||||||
tone: "accent",
|
tone: "accent",
|
||||||
},
|
},
|
||||||
Доставлен: {
|
Доставлен: {
|
||||||
comment: "Заказ успешно передан клиенту.",
|
comment: "Заказ успешно передан клиенту.",
|
||||||
ownerRole: "driver",
|
ownerRole: "driver",
|
||||||
|
stageKey: "completed",
|
||||||
|
stageLabel: getStageLabel("completed"),
|
||||||
|
warningAfterHours: null,
|
||||||
|
criticalAfterHours: null,
|
||||||
tone: "accent",
|
tone: "accent",
|
||||||
},
|
},
|
||||||
Закрыт: {
|
Закрыт: {
|
||||||
comment: "Цикл заказа завершён и больше не требует действий.",
|
comment: "Цикл заказа завершён и больше не требует действий.",
|
||||||
ownerRole: "logistician",
|
ownerRole: "logistician",
|
||||||
|
stageKey: "completed",
|
||||||
|
stageLabel: getStageLabel("completed"),
|
||||||
|
warningAfterHours: null,
|
||||||
|
criticalAfterHours: null,
|
||||||
tone: "neutral",
|
tone: "neutral",
|
||||||
},
|
},
|
||||||
Отменён: {
|
Отменён: {
|
||||||
comment: "Заказ отменён и выведен из процесса.",
|
comment: "Заказ отменён и выведен из процесса.",
|
||||||
ownerRole: "manager",
|
ownerRole: "manager",
|
||||||
|
stageKey: "completed",
|
||||||
|
stageLabel: getStageLabel("completed"),
|
||||||
|
warningAfterHours: null,
|
||||||
|
criticalAfterHours: null,
|
||||||
tone: "danger",
|
tone: "danger",
|
||||||
},
|
},
|
||||||
"Проблема доставки": {
|
"Проблема доставки": {
|
||||||
comment: "На этапе доставки возникла проблема и нужен ручной разбор.",
|
comment: "На этапе доставки возникла проблема и нужен ручной разбор.",
|
||||||
ownerRole: "logistician",
|
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",
|
tone: "danger",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -110,26 +208,32 @@ export const ORDER_STATUS_TRANSITIONS = {
|
||||||
"Подтверждён менеджером": ["В очереди производства", "Требует уточнения", "Отменён"],
|
"Подтверждён менеджером": ["В очереди производства", "Требует уточнения", "Отменён"],
|
||||||
"В очереди производства": ["В производстве", "Требует уточнения", "Отменён"],
|
"В очереди производства": ["В производстве", "Требует уточнения", "Отменён"],
|
||||||
"В производстве": ["Готов к отгрузке", "Требует уточнения", "Отменён"],
|
"В производстве": ["Готов к отгрузке", "Требует уточнения", "Отменён"],
|
||||||
"Готов к отгрузке": ["Ожидает согласования доставки", "Проблема доставки", "Отменён"],
|
"Готов к отгрузке": ["Ожидает согласования доставки", "Ожидает ответа клиента", "Проблема доставки", "Отменён"],
|
||||||
|
"Ожидает ответа клиента": ["Доставка согласована", "Передан логисту", "Платное хранение", "Проблема доставки", "Отменён"],
|
||||||
"Ожидает согласования доставки": ["Доставка согласована", "Проблема доставки", "Отменён"],
|
"Ожидает согласования доставки": ["Доставка согласована", "Проблема доставки", "Отменён"],
|
||||||
"Доставка согласована": ["Назначен водитель", "Ожидает согласования доставки", "Проблема доставки"],
|
"Доставка согласована": ["Назначен водитель", "Ожидает согласования доставки", "Проблема доставки"],
|
||||||
|
"Передан логисту": ["Доставка согласована", "Платное хранение", "Проблема доставки", "Отменён"],
|
||||||
"Назначен водитель": ["Загружен", "Проблема доставки"],
|
"Назначен водитель": ["Загружен", "Проблема доставки"],
|
||||||
Загружен: ["В пути", "Проблема доставки"],
|
Загружен: ["В пути", "Проблема доставки"],
|
||||||
"В пути": ["Доставлен", "Проблема доставки"],
|
"В пути": ["Доставлен", "Проблема доставки"],
|
||||||
Доставлен: ["Закрыт"],
|
Доставлен: ["Закрыт"],
|
||||||
"Проблема доставки": ["Ожидает согласования доставки", "Назначен водитель", "Отменён", "Закрыт"],
|
"Проблема доставки": ["Ожидает согласования доставки", "Назначен водитель", "Отменён", "Закрыт"],
|
||||||
|
"Платное хранение": ["Доставка согласована", "Отменён", "Закрыт"],
|
||||||
Закрыт: [],
|
Закрыт: [],
|
||||||
Отменён: [],
|
Отменён: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ROLE_TRANSITION_TARGETS = {
|
export const ROLE_TRANSITION_TARGETS = {
|
||||||
manager: ["Новый", "Требует уточнения", "Подтверждён менеджером", "В очереди производства", "Отменён"],
|
manager: ORDER_STATUSES,
|
||||||
production_lead: ["В очереди производства", "В производстве", "Готов к отгрузке", "Требует уточнения", "Отменён"],
|
production_lead: ["В очереди производства", "В производстве", "Готов к отгрузке", "Требует уточнения", "Отменён"],
|
||||||
logistician: [
|
logistician: [
|
||||||
|
"Ожидает ответа клиента",
|
||||||
"Ожидает согласования доставки",
|
"Ожидает согласования доставки",
|
||||||
"Доставка согласована",
|
"Доставка согласована",
|
||||||
|
"Передан логисту",
|
||||||
"Назначен водитель",
|
"Назначен водитель",
|
||||||
"Проблема доставки",
|
"Проблема доставки",
|
||||||
|
"Платное хранение",
|
||||||
"Закрыт",
|
"Закрыт",
|
||||||
"Отменён",
|
"Отменён",
|
||||||
],
|
],
|
||||||
|
|
@ -160,6 +264,17 @@ export const getDeliveryAgreementComment = (status) =>
|
||||||
|
|
||||||
export const getStatusTone = (status) => ORDER_STATUS_META[status]?.tone || "neutral";
|
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 }) => {
|
export const getAvailableTransitionsByRole = ({ status, role }) => {
|
||||||
const nextStatuses = ORDER_STATUS_TRANSITIONS[status] || [];
|
const nextStatuses = ORDER_STATUS_TRANSITIONS[status] || [];
|
||||||
const allowedTargets = ROLE_TRANSITION_TARGETS[role] || [];
|
const allowedTargets = ROLE_TRANSITION_TARGETS[role] || [];
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ export const demoUsers = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const demoOrders = [
|
const baseDemoOrders = [
|
||||||
{
|
{
|
||||||
id: "o-1001",
|
id: "o-1001",
|
||||||
orderNumber: "CD-240031",
|
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 = [
|
export const demoNotifications = [
|
||||||
{
|
{
|
||||||
id: "n-1",
|
id: "n-1",
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { getOrderStatusComment } from "../constants/deliveryWorkflow";
|
import { getOrderStatusComment } from "../constants/deliveryWorkflow";
|
||||||
import { demoNotifications, demoOrders, demoUsers } from "../data/mockAppData";
|
import { demoNotifications, demoOrders, demoUsers } from "../data/mockAppData";
|
||||||
|
import { fetchUsers } from "../services/supabase/userRepository";
|
||||||
|
import { fetchOrders, enrichOrdersWithUsers } from "../services/supabase/orderRepository";
|
||||||
import {
|
import {
|
||||||
reorderDriverDeliveries,
|
reorderDriverDeliveries,
|
||||||
} from "../services/driverDeliveries";
|
} from "../services/driverDeliveries";
|
||||||
import {
|
import {
|
||||||
|
assignDriverToOrder,
|
||||||
applyDeliveryReschedule,
|
applyDeliveryReschedule,
|
||||||
applyStatusUpdate,
|
applyStatusUpdate,
|
||||||
appendChatMessageToOrder,
|
appendChatMessageToOrder,
|
||||||
|
|
@ -17,37 +20,100 @@ import {
|
||||||
filterOrdersByView,
|
filterOrdersByView,
|
||||||
updateOrderDetails,
|
updateOrderDetails,
|
||||||
} from "../services/orderService";
|
} 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) => {
|
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({
|
const [filters, setFilters] = React.useState({
|
||||||
query: "",
|
query: "",
|
||||||
status: "all",
|
status: "all",
|
||||||
|
stage: "all",
|
||||||
|
ownerRole: "all",
|
||||||
|
agingState: "all",
|
||||||
managerId: "all",
|
managerId: "all",
|
||||||
logisticianId: "all",
|
logisticianId: "all",
|
||||||
messenger: "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 [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(() => {
|
React.useEffect(() => {
|
||||||
return orders.filter((order) => {
|
let cancelled = false;
|
||||||
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]);
|
|
||||||
|
|
||||||
const filteredOrders = React.useMemo(() => {
|
const loadLiveData = async () => {
|
||||||
return filterOrdersByView({ orders, currentUser, filters }).filteredOrders;
|
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]);
|
}, [currentUser, filters, orders]);
|
||||||
|
const visibleOrders = orderView.visibleOrders;
|
||||||
|
const filteredOrders = orderView.filteredOrders;
|
||||||
|
|
||||||
const selectedOrder =
|
const selectedOrder =
|
||||||
filteredOrders.find((order) => order.id === selectedOrderId) ||
|
filteredOrders.find((order) => order.id === selectedOrderId) ||
|
||||||
|
|
@ -55,6 +121,8 @@ export const useOrders = (currentUser) => {
|
||||||
filteredOrders[0] ||
|
filteredOrders[0] ||
|
||||||
null;
|
null;
|
||||||
|
|
||||||
|
const userMap = React.useMemo(() => new Map(users.map((user) => [user.id, user])), [users]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!selectedOrder && filteredOrders[0]) {
|
if (!selectedOrder && filteredOrders[0]) {
|
||||||
setSelectedOrderId(filteredOrders[0].id);
|
setSelectedOrderId(filteredOrders[0].id);
|
||||||
|
|
@ -164,8 +232,25 @@ export const useOrders = (currentUser) => {
|
||||||
[updateOrder],
|
[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 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));
|
setOrders((current) => autoAssignOrders(current, logisticians));
|
||||||
appendNotification({
|
appendNotification({
|
||||||
id: `notification-${Date.now()}`,
|
id: `notification-${Date.now()}`,
|
||||||
|
|
@ -173,7 +258,7 @@ export const useOrders = (currentUser) => {
|
||||||
title: "Автораспределение выполнено",
|
title: "Автораспределение выполнено",
|
||||||
description: `Заказы распределены между ${logisticians.length || 0} логистами`,
|
description: `Заказы распределены между ${logisticians.length || 0} логистами`,
|
||||||
});
|
});
|
||||||
}, [appendNotification]);
|
}, [appendNotification, users]);
|
||||||
|
|
||||||
const saveOrderDetails = React.useCallback(
|
const saveOrderDetails = React.useCallback(
|
||||||
({ orderId, payload, actorName }) => {
|
({ orderId, payload, actorName }) => {
|
||||||
|
|
@ -193,7 +278,8 @@ export const useOrders = (currentUser) => {
|
||||||
|
|
||||||
const createOrder = React.useCallback(
|
const createOrder = React.useCallback(
|
||||||
({ payload, actorName }) => {
|
({ 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({
|
const nextOrder = createOrderRecord({
|
||||||
payload,
|
payload,
|
||||||
actorName,
|
actorName,
|
||||||
|
|
@ -209,7 +295,7 @@ export const useOrders = (currentUser) => {
|
||||||
description: `${nextOrder.orderNumber}: заказ создан и ожидает подтверждения`,
|
description: `${nextOrder.orderNumber}: заказ создан и ожидает подтверждения`,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[appendNotification],
|
[appendNotification, users],
|
||||||
);
|
);
|
||||||
|
|
||||||
const saveDriverRouteOrder = React.useCallback(
|
const saveDriverRouteOrder = React.useCallback(
|
||||||
|
|
@ -229,6 +315,40 @@ export const useOrders = (currentUser) => {
|
||||||
return buildMetrics(visibleOrders);
|
return buildMetrics(visibleOrders);
|
||||||
}, [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 {
|
return {
|
||||||
orders: filteredOrders,
|
orders: filteredOrders,
|
||||||
allOrders: visibleOrders,
|
allOrders: visibleOrders,
|
||||||
|
|
@ -238,15 +358,25 @@ export const useOrders = (currentUser) => {
|
||||||
filters,
|
filters,
|
||||||
setFilters,
|
setFilters,
|
||||||
notifications,
|
notifications,
|
||||||
|
users,
|
||||||
|
userMap,
|
||||||
|
isSupabaseBacked,
|
||||||
|
isLoading,
|
||||||
|
loadError,
|
||||||
|
pushNotification: appendNotification,
|
||||||
updateStatus,
|
updateStatus,
|
||||||
addChatMessage,
|
addChatMessage,
|
||||||
addInternalMessage,
|
addInternalMessage,
|
||||||
addOrderNote,
|
addOrderNote,
|
||||||
|
assignDriver,
|
||||||
reassignDelivery,
|
reassignDelivery,
|
||||||
autoAssignLogisticians,
|
autoAssignLogisticians,
|
||||||
saveOrderDetails,
|
saveOrderDetails,
|
||||||
createOrder,
|
createOrder,
|
||||||
saveDriverRouteOrder,
|
saveDriverRouteOrder,
|
||||||
metrics,
|
metrics,
|
||||||
|
agingAlerts,
|
||||||
|
agingSummary,
|
||||||
|
deliverySetBuckets,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,9 @@ export const AppShell = ({
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen px-4 py-5 md:px-6 md:py-8">
|
<div className="min-h-screen px-3 py-4 sm:px-4 md:px-6 md:py-8">
|
||||||
<div className="mx-auto grid max-w-[1540px] gap-5 xl:grid-cols-[220px_1fr] xl:gap-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="flex h-fit flex-col gap-5 p-4">
|
<Panel className="hidden h-fit flex-col gap-5 p-4 xl:flex">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-[0.24em] text-[var(--color-text-muted)]">
|
<p className="text-xs uppercase tracking-[0.24em] text-[var(--color-text-muted)]">
|
||||||
Панель
|
Панель
|
||||||
|
|
@ -51,8 +51,28 @@ export const AppShell = ({
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<div className="space-y-6 xl:space-y-8">
|
<div className="min-w-0 space-y-5 pb-24 xl:space-y-8 xl:pb-0">
|
||||||
<Panel className="p-4 md:p-5">
|
<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 className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
||||||
|
|
@ -78,6 +98,27 @@ export const AppShell = ({
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,23 +5,26 @@ import { ROLE_LABELS, ROLE_PERMISSIONS } from "../constants/roles";
|
||||||
import {
|
import {
|
||||||
DRIVER_STATUSES,
|
DRIVER_STATUSES,
|
||||||
getOrderStatusComment,
|
getOrderStatusComment,
|
||||||
|
getStatusStageKey,
|
||||||
LOGISTICS_STATUSES,
|
LOGISTICS_STATUSES,
|
||||||
PRODUCTION_STATUSES,
|
PRODUCTION_STATUSES,
|
||||||
} from "../constants/deliveryWorkflow";
|
} from "../constants/deliveryWorkflow";
|
||||||
import { AuditPanel } from "../components/admin/AuditPanel";
|
import { AuditPanel } from "../components/admin/AuditPanel";
|
||||||
import { UserDirectoryPanel } from "../components/admin/UserDirectoryPanel";
|
import { UserDirectoryPanel } from "../components/admin/UserDirectoryPanel";
|
||||||
import { UserOnboardingPanel } from "../components/admin/UserOnboardingPanel";
|
import { UserOnboardingPanel } from "../components/admin/UserOnboardingPanel";
|
||||||
|
import { DeliverySetDetailPanel } from "../components/logistics/DeliverySetDetailPanel";
|
||||||
import { DriverDeliveryDetail } from "../components/driver/DriverDeliveryDetail";
|
import { DriverDeliveryDetail } from "../components/driver/DriverDeliveryDetail";
|
||||||
import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
|
import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
|
||||||
import { KpiCard } from "../components/dashboard/KpiCard";
|
import { KpiCard } from "../components/dashboard/KpiCard";
|
||||||
|
import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard";
|
||||||
import { PwaDemoPanel } from "../components/dashboard/PwaDemoPanel";
|
import { PwaDemoPanel } from "../components/dashboard/PwaDemoPanel";
|
||||||
import { ProductionQueuePanel } from "../components/dashboard/ProductionQueuePanel";
|
import { ProductionQueuePanel } from "../components/dashboard/ProductionQueuePanel";
|
||||||
import { RoleWorkspacePanel } from "../components/dashboard/RoleWorkspacePanel";
|
import { RoleWorkspacePanel } from "../components/dashboard/RoleWorkspacePanel";
|
||||||
import { BotControlPanel } from "../components/logistics/BotControlPanel";
|
import { BotControlPanel } from "../components/logistics/BotControlPanel";
|
||||||
import { OrdersCalendarView } from "../components/orders/OrdersCalendarView";
|
import { OrdersCalendarView } from "../components/orders/OrdersCalendarView";
|
||||||
import { OrderDetailPanel } from "../components/orders/OrderDetailPanel";
|
import { OrderDetailPanel } from "../components/orders/OrderDetailPanel";
|
||||||
import { OrderEditorPanel } from "../components/orders/OrderEditorPanel";
|
|
||||||
import { OrderFilters } from "../components/orders/OrderFilters";
|
import { OrderFilters } from "../components/orders/OrderFilters";
|
||||||
|
import { OrdersKanbanBoard } from "../components/orders/OrdersKanbanBoard";
|
||||||
import { OrdersTable } from "../components/orders/OrdersTable";
|
import { OrdersTable } from "../components/orders/OrdersTable";
|
||||||
import { Badge } from "../components/UI/Badge";
|
import { Badge } from "../components/UI/Badge";
|
||||||
import { Button } from "../components/UI/Button";
|
import { Button } from "../components/UI/Button";
|
||||||
|
|
@ -37,8 +40,15 @@ import {
|
||||||
filterDriverDeliveries,
|
filterDriverDeliveries,
|
||||||
getDeliveryDay,
|
getDeliveryDay,
|
||||||
} from "../services/driverDeliveries";
|
} 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 { formatDateTime } from "../utils/formatters";
|
||||||
|
import { resolveDraggedOrderId } from "../components/orders/ordersKanbanDrag";
|
||||||
|
|
||||||
export const DashboardPage = () => {
|
export const DashboardPage = () => {
|
||||||
const { user, signOut } = useAuth();
|
const { user, signOut } = useAuth();
|
||||||
|
|
@ -51,10 +61,13 @@ export const DashboardPage = () => {
|
||||||
const [isOrderWorkspaceExpanded, setIsOrderWorkspaceExpanded] = React.useState(false);
|
const [isOrderWorkspaceExpanded, setIsOrderWorkspaceExpanded] = React.useState(false);
|
||||||
const [dragOrderId, setDragOrderId] = React.useState(null);
|
const [dragOrderId, setDragOrderId] = React.useState(null);
|
||||||
const [dropColumnKey, setDropColumnKey] = 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 [kanbanSort, setKanbanSort] = React.useState("updated_desc");
|
||||||
const [showCompletedInRegistry, setShowCompletedInRegistry] = React.useState(false);
|
const [showCompletedInRegistry, setShowCompletedInRegistry] = React.useState(false);
|
||||||
const [showCompletedInKanban, setShowCompletedInKanban] = 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({
|
const [driverFilters, setDriverFilters] = React.useState({
|
||||||
dateFrom: "",
|
dateFrom: "",
|
||||||
dateTo: "",
|
dateTo: "",
|
||||||
|
|
@ -63,7 +76,7 @@ export const DashboardPage = () => {
|
||||||
viewMode: "active",
|
viewMode: "active",
|
||||||
showCompleted: false,
|
showCompleted: false,
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
orders,
|
orders,
|
||||||
allOrders,
|
allOrders,
|
||||||
selectedOrder,
|
selectedOrder,
|
||||||
|
|
@ -72,16 +85,23 @@ export const DashboardPage = () => {
|
||||||
filters,
|
filters,
|
||||||
setFilters,
|
setFilters,
|
||||||
notifications,
|
notifications,
|
||||||
|
pushNotification,
|
||||||
updateStatus,
|
updateStatus,
|
||||||
addChatMessage,
|
addChatMessage,
|
||||||
addInternalMessage,
|
addInternalMessage,
|
||||||
addOrderNote,
|
addOrderNote,
|
||||||
|
assignDriver,
|
||||||
reassignDelivery,
|
reassignDelivery,
|
||||||
autoAssignLogisticians,
|
autoAssignLogisticians,
|
||||||
saveOrderDetails,
|
|
||||||
createOrder,
|
|
||||||
saveDriverRouteOrder,
|
saveDriverRouteOrder,
|
||||||
metrics,
|
metrics,
|
||||||
|
agingAlerts,
|
||||||
|
agingSummary,
|
||||||
|
deliverySetBuckets,
|
||||||
|
users,
|
||||||
|
isSupabaseBacked,
|
||||||
|
isLoading,
|
||||||
|
loadError,
|
||||||
} = useOrders(user);
|
} = useOrders(user);
|
||||||
|
|
||||||
const canManageLogistics = userRole === "logistician" || userRole === "admin";
|
const canManageLogistics = userRole === "logistician" || userRole === "admin";
|
||||||
|
|
@ -219,11 +239,56 @@ export const DashboardPage = () => {
|
||||||
setIsOrderModalOpen(true);
|
setIsOrderModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKanbanDrop = (column) => {
|
const handleKanbanDrop = (event, column) => {
|
||||||
if (!dragOrderId) {
|
const droppedOrderId = resolveDraggedOrderId(event, dragOrderId);
|
||||||
|
|
||||||
|
if (!droppedOrderId) {
|
||||||
return;
|
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);
|
setDragOrderId(null);
|
||||||
setDropColumnKey(null);
|
setDropColumnKey(null);
|
||||||
};
|
};
|
||||||
|
|
@ -267,9 +332,29 @@ export const DashboardPage = () => {
|
||||||
return sortableOrders.sort(sorters[kanbanSort] || sorters.updated_desc);
|
return sortableOrders.sort(sorters[kanbanSort] || sorters.updated_desc);
|
||||||
}, [kanbanSort, orders]);
|
}, [kanbanSort, orders]);
|
||||||
const kanbanColumns = React.useMemo(
|
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) {
|
if (!user) {
|
||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
|
|
@ -310,6 +395,7 @@ export const DashboardPage = () => {
|
||||||
order={order}
|
order={order}
|
||||||
currentUser={user}
|
currentUser={user}
|
||||||
onStatusChange={(nextStatus) => updateStatus(order.id, nextStatus, user.name)}
|
onStatusChange={(nextStatus) => updateStatus(order.id, nextStatus, user.name)}
|
||||||
|
onAssignDriver={(driverId) => assignDriver({ orderId: order.id, driverId, actorName: user.name })}
|
||||||
onClientMessage={(text) =>
|
onClientMessage={(text) =>
|
||||||
addChatMessage(order.id, {
|
addChatMessage(order.id, {
|
||||||
sender: "client",
|
sender: "client",
|
||||||
|
|
@ -319,6 +405,7 @@ export const DashboardPage = () => {
|
||||||
}
|
}
|
||||||
onInternalMessage={(message) => addInternalMessage(order.id, message)}
|
onInternalMessage={(message) => addInternalMessage(order.id, message)}
|
||||||
onOrderNote={(note) => addOrderNote(order.id, note)}
|
onOrderNote={(note) => addOrderNote(order.id, note)}
|
||||||
|
users={users}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -390,8 +477,27 @@ export const DashboardPage = () => {
|
||||||
<KpiCard label="Проблемные" value={metrics.exceptions} hint="Нужна ручная реакция" />
|
<KpiCard label="Проблемные" value={metrics.exceptions} hint="Нужна ручная реакция" />
|
||||||
</section>
|
</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]">
|
<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">
|
<Panel className="space-y-5 p-6">
|
||||||
<h3 className="text-lg font-semibold">Оперативные действия</h3>
|
<h3 className="text-lg font-semibold">Оперативные действия</h3>
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
|
@ -428,7 +534,7 @@ export const DashboardPage = () => {
|
||||||
<Panel className="p-6">
|
<Panel className="p-6">
|
||||||
<h3 className="text-lg font-semibold">Последние события</h3>
|
<h3 className="text-lg font-semibold">Последние события</h3>
|
||||||
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{notifications.map((notification) => (
|
{eventFeed.map((notification) => (
|
||||||
<div
|
<div
|
||||||
key={notification.id}
|
key={notification.id}
|
||||||
className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-5"
|
className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-5"
|
||||||
|
|
@ -483,7 +589,7 @@ export const DashboardPage = () => {
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold">Реестр заказов</h3>
|
<h3 className="text-lg font-semibold">Реестр заказов</h3>
|
||||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
Основная таблица для ежедневной работы. Создание заказа вынесено в отдельное действие.
|
Основная таблица для ежедневной работы. Данные сюда поступают только из 1С.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
|
@ -494,12 +600,10 @@ export const DashboardPage = () => {
|
||||||
>
|
>
|
||||||
{showCompletedInRegistry ? "Скрыть завершённые" : "Показать завершённые"}
|
{showCompletedInRegistry ? "Скрыть завершённые" : "Показать завершённые"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={() => setIsCreateOrderModalOpen(true)}>
|
<Badge tone="neutral">Импорт из 1С</Badge>
|
||||||
Добавить заказ
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
<OrderFilters filters={filters} setFilters={setFilters} />
|
<OrderFilters filters={filters} setFilters={setFilters} users={users} />
|
||||||
|
|
||||||
{isOrderWorkspaceExpanded ? (
|
{isOrderWorkspaceExpanded ? (
|
||||||
renderOrderWorkspace(selectedOrder, true)
|
renderOrderWorkspace(selectedOrder, true)
|
||||||
|
|
@ -508,6 +612,7 @@ export const DashboardPage = () => {
|
||||||
orders={registryOrders}
|
orders={registryOrders}
|
||||||
selectedOrderId={selectedOrderId}
|
selectedOrderId={selectedOrderId}
|
||||||
onOpenOrder={openOrderModal}
|
onOpenOrder={openOrderModal}
|
||||||
|
users={users}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -519,13 +624,13 @@ export const DashboardPage = () => {
|
||||||
|
|
||||||
{ordersViewTab === "kanban" ? (
|
{ordersViewTab === "kanban" ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<OrderFilters filters={filters} setFilters={setFilters} />
|
<OrderFilters filters={filters} setFilters={setFilters} users={users} />
|
||||||
|
|
||||||
<Panel className="p-4">
|
<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 className="text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
Канбан показывает только отфильтрованные заказы. Карточки можно перетаскивать
|
Канбан показывает только отфильтрованные заказы. Можно переключать вид по этапам
|
||||||
между столбцами, исключения вынесены отдельно, а завершённые скрыты по умолчанию.
|
и по статусам, а цвет карточки показывает, чья сейчас зона ответственности.
|
||||||
</p>
|
</p>
|
||||||
<Select value={kanbanSort} onChange={(event) => setKanbanSort(event.target.value)}>
|
<Select value={kanbanSort} onChange={(event) => setKanbanSort(event.target.value)}>
|
||||||
<option value="updated_desc">Сначала недавно обновлённые</option>
|
<option value="updated_desc">Сначала недавно обновлённые</option>
|
||||||
|
|
@ -541,65 +646,62 @@ export const DashboardPage = () => {
|
||||||
>
|
>
|
||||||
{showCompletedInKanban ? "Скрыть завершённые" : "Показать завершённые"}
|
{showCompletedInKanban ? "Скрыть завершённые" : "Показать завершённые"}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<div
|
<OrdersKanbanBoard
|
||||||
className={[
|
columns={visibleKanbanColumns}
|
||||||
"grid gap-3",
|
currentMode={kanbanMode}
|
||||||
showCompletedInKanban ? "xl:grid-cols-5" : "xl:grid-cols-4",
|
departmentFilter={kanbanDepartmentFilter}
|
||||||
].join(" ")}
|
notice={kanbanNotice}
|
||||||
>
|
onDepartmentFilterChange={setKanbanDepartmentFilter}
|
||||||
{kanbanColumns.map((column) => (
|
onModeChange={setKanbanMode}
|
||||||
<Panel key={column.key} className="rounded-[20px] p-3">
|
onOpenOrder={openOrderModal}
|
||||||
<div className="mb-3 flex items-center justify-between gap-3 px-1">
|
onDragStart={(orderId) => {
|
||||||
<h3 className="text-sm font-semibold text-[var(--color-text)]">{column.title}</h3>
|
setKanbanNotice(null);
|
||||||
<span className="text-sm text-[var(--color-text-muted)]">{column.items.length}</span>
|
setDragOrderId(orderId);
|
||||||
</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);
|
|
||||||
}}
|
}}
|
||||||
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={() => {
|
onDragEnd={() => {
|
||||||
setDragOrderId(null);
|
setDragOrderId(null);
|
||||||
setDropColumnKey(null);
|
setDropColumnKey(null);
|
||||||
}}
|
}}
|
||||||
draggable
|
onDragOverColumn={setDropColumnKey}
|
||||||
>
|
onDragLeaveColumn={(columnKey) =>
|
||||||
<div className="font-medium">{order.orderNumber}</div>
|
setDropColumnKey((current) => (current === columnKey ? null : current))
|
||||||
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
|
}
|
||||||
{order.customer.name}
|
onDropColumn={handleKanbanDrop}
|
||||||
</div>
|
dropColumnKey={dropColumnKey}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -652,6 +754,7 @@ export const DashboardPage = () => {
|
||||||
orders={archiveOrders}
|
orders={archiveOrders}
|
||||||
selectedOrderId={selectedOrderId}
|
selectedOrderId={selectedOrderId}
|
||||||
onOpenOrder={openOrderModal}
|
onOpenOrder={openOrderModal}
|
||||||
|
users={users}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -668,6 +771,7 @@ export const DashboardPage = () => {
|
||||||
orders={productionOrders}
|
orders={productionOrders}
|
||||||
selectedOrderId={selectedOrderId}
|
selectedOrderId={selectedOrderId}
|
||||||
onOpenOrder={openOrderModal}
|
onOpenOrder={openOrderModal}
|
||||||
|
users={users}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -676,6 +780,18 @@ export const DashboardPage = () => {
|
||||||
if (activeSection === "logistics") {
|
if (activeSection === "logistics") {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 xl:space-y-8">
|
<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]">
|
<section className="grid gap-6 xl:grid-cols-[1.08fr_0.92fr]">
|
||||||
<BotControlPanel
|
<BotControlPanel
|
||||||
selectedOrder={selectedLogisticsOrder}
|
selectedOrder={selectedLogisticsOrder}
|
||||||
|
|
@ -706,10 +822,10 @@ export const DashboardPage = () => {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Panel className="p-6">
|
<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 className="mt-3 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
Для логистики здесь остаются только заказы, где нужно согласование, перенос или
|
Отдельные заказы из наборов доставки, где нужно согласование, перенос или ручная
|
||||||
ручная реакция на исключения.
|
реакция на исключения.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{logisticsOrders.map((order) => (
|
{logisticsOrders.map((order) => (
|
||||||
|
|
@ -808,7 +924,7 @@ export const DashboardPage = () => {
|
||||||
<div className="space-y-6 xl:space-y-8">
|
<div className="space-y-6 xl:space-y-8">
|
||||||
<div className="grid gap-6 xl:grid-cols-[1fr_1fr]">
|
<div className="grid gap-6 xl:grid-cols-[1fr_1fr]">
|
||||||
<AuditPanel order={selectedOrder} />
|
<AuditPanel order={selectedOrder} />
|
||||||
<UserDirectoryPanel currentUser={user} />
|
<UserDirectoryPanel currentUser={user} users={users} />
|
||||||
</div>
|
</div>
|
||||||
<UserOnboardingPanel />
|
<UserOnboardingPanel />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -824,6 +940,21 @@ export const DashboardPage = () => {
|
||||||
onSectionChange={setActiveSection}
|
onSectionChange={setActiveSection}
|
||||||
sectionMeta={sectionMeta}
|
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()}
|
{renderActiveTab()}
|
||||||
<Modal isOpen={isOrderModalOpen} onClose={() => setIsOrderModalOpen(false)}>
|
<Modal isOpen={isOrderModalOpen} onClose={() => setIsOrderModalOpen(false)}>
|
||||||
{user.role === "driver" ? (
|
{user.role === "driver" ? (
|
||||||
|
|
@ -844,6 +975,7 @@ export const DashboardPage = () => {
|
||||||
onStatusChange={(nextStatus) =>
|
onStatusChange={(nextStatus) =>
|
||||||
selectedOrder && updateStatus(selectedOrder.id, nextStatus, user.name)
|
selectedOrder && updateStatus(selectedOrder.id, nextStatus, user.name)
|
||||||
}
|
}
|
||||||
|
users={users}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -875,33 +1007,6 @@ export const DashboardPage = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</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>
|
</AppShell>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ export const LoginPage = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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
|
<OtpLoginForm
|
||||||
email={email}
|
email={email}
|
||||||
setEmail={setEmail}
|
setEmail={setEmail}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Navigate, createBrowserRouter } from "react-router-dom";
|
import { Navigate, createBrowserRouter } from "react-router-dom";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import { ClientDeliveryPage } from "./pages/ClientDeliveryPage";
|
||||||
import { DashboardPage } from "./pages/DashboardPage";
|
import { DashboardPage } from "./pages/DashboardPage";
|
||||||
import { LoginPage } from "./pages/LoginPage";
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||||
|
|
@ -18,6 +19,10 @@ export const router = createBrowserRouter([
|
||||||
path: "login",
|
path: "login",
|
||||||
element: <LoginPage />,
|
element: <LoginPage />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "delivery/:token",
|
||||||
|
element: <ClientDeliveryPage />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "dashboard",
|
path: "dashboard",
|
||||||
element: <DashboardPage />,
|
element: <DashboardPage />,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,18 @@
|
||||||
import { demoOrders } from "../data/mockAppData";
|
import { demoOrders } from "../data/mockAppData";
|
||||||
import {
|
import {
|
||||||
getAvailableTransitionsByRole,
|
getAvailableTransitionsByRole,
|
||||||
|
getStatusOwnerRole,
|
||||||
|
getStatusStageKey,
|
||||||
LOGISTICS_STATUSES,
|
LOGISTICS_STATUSES,
|
||||||
PRODUCTION_STATUSES,
|
PRODUCTION_STATUSES,
|
||||||
} from "../constants/deliveryWorkflow";
|
} 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) =>
|
export const cloneOrders = (orders = demoOrders) =>
|
||||||
orders.map((order) => ({
|
orders.map((order) => ({
|
||||||
|
|
@ -40,32 +49,61 @@ export const buildSearchBlob = (order) => {
|
||||||
.toLowerCase();
|
.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) => {
|
const visibleOrders = orders.filter((order) => {
|
||||||
if (currentUser?.role === "manager" && order.managerId !== currentUser.id) {
|
if (!currentUser) {
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (currentUser?.role === "logistician" && !order.logisticianIds.includes(currentUser.id)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (currentUser?.role === "driver" && order.assignedDriverId !== currentUser.id) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentUser.role === "manager" || currentUser.role === "admin") {
|
||||||
return true;
|
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) => {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
if (filters.managerId !== "all" && order.managerId !== filters.managerId) {
|
if (normalizedFilters.stage !== "all" && stageKey !== normalizedFilters.stage) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (filters.logisticianId !== "all" && !order.logisticianIds.includes(filters.logisticianId)) {
|
if (normalizedFilters.ownerRole !== "all" && ownerRole !== normalizedFilters.ownerRole) {
|
||||||
return false;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
if (normalizedQuery && !buildSearchBlob(order).includes(normalizedQuery)) {
|
if (normalizedQuery && !buildSearchBlob(order).includes(normalizedQuery)) {
|
||||||
|
|
@ -92,7 +130,68 @@ export const appendHistoryEntry = (order, entry) => ({
|
||||||
export const getAvailableTransitions = ({ status, role }) =>
|
export const getAvailableTransitions = ({ status, role }) =>
|
||||||
getAvailableTransitionsByRole({ 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) => {
|
const deriveAgreementStatus = (currentOrder, nextStatus) => {
|
||||||
|
if (nextStatus === "Ожидает ответа клиента") {
|
||||||
|
return currentOrder.deliveryAgreementStatus === "Подтверждено клиентом"
|
||||||
|
? "Отправлено клиенту"
|
||||||
|
: "Ожидание ответа";
|
||||||
|
}
|
||||||
|
|
||||||
if (nextStatus === "Ожидает согласования доставки") {
|
if (nextStatus === "Ожидает согласования доставки") {
|
||||||
return currentOrder.deliveryAgreementStatus === "Подтверждено клиентом"
|
return currentOrder.deliveryAgreementStatus === "Подтверждено клиентом"
|
||||||
? "Отправлено клиенту"
|
? "Отправлено клиенту"
|
||||||
|
|
@ -103,6 +202,16 @@ const deriveAgreementStatus = (currentOrder, nextStatus) => {
|
||||||
return "Подтверждено клиентом";
|
return "Подтверждено клиентом";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (nextStatus === "Передан логисту") {
|
||||||
|
return currentOrder.deliveryAgreementStatus === "Подтверждено клиентом"
|
||||||
|
? "Перенос запрошен"
|
||||||
|
: "Нет ответа";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextStatus === "Платное хранение") {
|
||||||
|
return "Нет ответа";
|
||||||
|
}
|
||||||
|
|
||||||
if (nextStatus === "Проблема доставки") {
|
if (nextStatus === "Проблема доставки") {
|
||||||
return currentOrder.deliveryAgreementStatus === "Не начато"
|
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) => ({
|
export const appendChatMessageToOrder = (order, message) => ({
|
||||||
...order,
|
...order,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,40 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { getStatusOwnerRole } from "../constants/deliveryWorkflow";
|
||||||
import { demoOrders, demoUsers } from "../data/mockAppData";
|
import { demoOrders, demoUsers } from "../data/mockAppData";
|
||||||
import {
|
import {
|
||||||
|
assignDriverToOrder,
|
||||||
applyStatusUpdate,
|
applyStatusUpdate,
|
||||||
autoAssignOrders,
|
autoAssignOrders,
|
||||||
buildMetrics,
|
buildMetrics,
|
||||||
cloneOrders,
|
cloneOrders,
|
||||||
createOrderRecord,
|
createOrderRecord,
|
||||||
filterOrdersByView,
|
filterOrdersByView,
|
||||||
|
getKanbanDropResolution,
|
||||||
getAvailableTransitions,
|
getAvailableTransitions,
|
||||||
} from "./orderService";
|
} from "./orderService";
|
||||||
|
|
||||||
describe("orderService", () => {
|
describe("orderService", () => {
|
||||||
it("filters manager orders to owned items only", () => {
|
it("lets manager see the whole pipeline", () => {
|
||||||
const result = filterOrdersByView({
|
const result = filterOrdersByView({
|
||||||
orders: cloneOrders(demoOrders),
|
orders: cloneOrders(demoOrders),
|
||||||
currentUser: demoUsers[0],
|
currentUser: demoUsers[0],
|
||||||
filters: {
|
filters: {
|
||||||
query: "",
|
query: "",
|
||||||
status: "all",
|
status: "all",
|
||||||
|
stage: "all",
|
||||||
|
ownerRole: "all",
|
||||||
|
agingState: "all",
|
||||||
managerId: "all",
|
managerId: "all",
|
||||||
logisticianId: "all",
|
logisticianId: "all",
|
||||||
messenger: "all",
|
messenger: "all",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.visibleOrders).toHaveLength(7);
|
expect(result.visibleOrders).toHaveLength(demoOrders.length);
|
||||||
expect(result.filteredOrders.every((order) => order.managerId === demoUsers[0].id)).toBe(true);
|
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 driver = demoUsers.find((user) => user.role === "driver");
|
||||||
const result = filterOrdersByView({
|
const result = filterOrdersByView({
|
||||||
orders: cloneOrders(demoOrders),
|
orders: cloneOrders(demoOrders),
|
||||||
|
|
@ -36,14 +42,40 @@ describe("orderService", () => {
|
||||||
filters: {
|
filters: {
|
||||||
query: "",
|
query: "",
|
||||||
status: "all",
|
status: "all",
|
||||||
|
stage: "all",
|
||||||
|
ownerRole: "all",
|
||||||
|
agingState: "all",
|
||||||
managerId: "all",
|
managerId: "all",
|
||||||
logisticianId: "all",
|
logisticianId: "all",
|
||||||
messenger: "all",
|
messenger: "all",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.visibleOrders).toHaveLength(4);
|
const expectedDriverOrders = demoOrders.filter((order) => getStatusOwnerRole(order.status) === "driver");
|
||||||
expect(result.visibleOrders.every((order) => order.assignedDriverId === driver.id)).toBe(true);
|
|
||||||
|
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", () => {
|
it("updates status and prepends history record", () => {
|
||||||
|
|
@ -54,6 +86,14 @@ describe("orderService", () => {
|
||||||
expect(nextOrder.history[0].newStatus).toBe("Доставка согласована");
|
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", () => {
|
it("returns role-scoped transitions for logistics stage", () => {
|
||||||
const transitions = getAvailableTransitions({
|
const transitions = getAvailableTransitions({
|
||||||
status: "Ожидает согласования доставки",
|
status: "Ожидает согласования доставки",
|
||||||
|
|
@ -63,6 +103,50 @@ describe("orderService", () => {
|
||||||
expect(transitions).toEqual(["Доставка согласована", "Проблема доставки", "Отменён"]);
|
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", () => {
|
it("creates a new order draft with assigned logistician", () => {
|
||||||
const logisticians = demoUsers.filter((user) => user.role === "logistician");
|
const logisticians = demoUsers.filter((user) => user.role === "logistician");
|
||||||
const order = createOrderRecord({
|
const order = createOrderRecord({
|
||||||
|
|
@ -99,9 +183,25 @@ describe("orderService", () => {
|
||||||
it("builds dashboard metrics", () => {
|
it("builds dashboard metrics", () => {
|
||||||
const metrics = buildMetrics(demoOrders);
|
const metrics = buildMetrics(demoOrders);
|
||||||
|
|
||||||
expect(metrics.total).toBe(7);
|
expect(metrics.total).toBe(demoOrders.length);
|
||||||
expect(metrics.readyToShip).toBe(1);
|
expect(metrics.readyToShip).toBe(demoOrders.filter((order) => order.status === "Готов к отгрузке").length);
|
||||||
expect(metrics.awaitingDeliveryCoordination).toBe(1);
|
expect(metrics.awaitingDeliveryCoordination).toBe(
|
||||||
expect(metrics.exceptions).toBe(1);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,41 @@
|
||||||
|
import {
|
||||||
|
ORDER_STATUSES,
|
||||||
|
WORKFLOW_STAGES,
|
||||||
|
getStatusOwnerRole,
|
||||||
|
getStatusSla,
|
||||||
|
getStatusStageKey,
|
||||||
|
getStatusStageLabel,
|
||||||
|
} from "../constants/deliveryWorkflow";
|
||||||
|
|
||||||
const COMPLETED_ORDER_STATUSES = new Set(["Доставлен", "Закрыт", "Отменён"]);
|
const COMPLETED_ORDER_STATUSES = new Set(["Доставлен", "Закрыт", "Отменён"]);
|
||||||
|
const ARCHIVE_ONLY_ORDER_STATUSES = new Set(["Закрыт", "Отменён"]);
|
||||||
const EXCEPTION_ORDER_STATUSES = new Set(["Проблема доставки"]);
|
const EXCEPTION_ORDER_STATUSES = new Set(["Проблема доставки"]);
|
||||||
|
|
||||||
const KANBAN_BASE_COLUMNS = [
|
const formatAgeLabel = (hours) => {
|
||||||
{
|
if (!Number.isFinite(hours) || hours < 1) {
|
||||||
key: "new",
|
return "меньше часа";
|
||||||
title: "В работе",
|
}
|
||||||
statuses: [
|
|
||||||
"Новый",
|
|
||||||
"Требует уточнения",
|
|
||||||
"Подтверждён менеджером",
|
|
||||||
"В очереди производства",
|
|
||||||
"В производстве",
|
|
||||||
"Готов к отгрузке",
|
|
||||||
],
|
|
||||||
dropStatus: "В производстве",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "coordination",
|
|
||||||
title: "Согласование доставки",
|
|
||||||
statuses: ["Ожидает согласования доставки", "Доставка согласована", "Назначен водитель"],
|
|
||||||
dropStatus: "Ожидает согласования доставки",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "execution",
|
|
||||||
title: "Исполнение",
|
|
||||||
statuses: ["Загружен", "В пути"],
|
|
||||||
dropStatus: "В пути",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "exceptions",
|
|
||||||
title: "Исключения",
|
|
||||||
statuses: ["Проблема доставки"],
|
|
||||||
dropStatus: "Проблема доставки",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const COMPLETED_COLUMN = {
|
if (hours < 24) {
|
||||||
key: "completed",
|
return `${Math.floor(hours)}ч в статусе`;
|
||||||
title: "Завершённые",
|
}
|
||||||
statuses: [...COMPLETED_ORDER_STATUSES],
|
|
||||||
dropStatus: "Закрыт",
|
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);
|
export const isCompletedOrderStatus = (status) => COMPLETED_ORDER_STATUSES.has(status);
|
||||||
|
|
@ -52,11 +48,120 @@ export const filterRegistryOrders = (orders, { includeCompleted = false } = {})
|
||||||
export const filterArchiveOrders = (orders) =>
|
export const filterArchiveOrders = (orders) =>
|
||||||
orders.filter((order) => isCompletedOrderStatus(order.status));
|
orders.filter((order) => isCompletedOrderStatus(order.status));
|
||||||
|
|
||||||
export const buildKanbanColumns = (orders, { includeCompleted = false } = {}) => {
|
export const getOrderAgingState = (order, { now = new Date().toISOString() } = {}) => {
|
||||||
const columns = includeCompleted ? [...KANBAN_BASE_COLUMNS, COMPLETED_COLUMN] : KANBAN_BASE_COLUMNS;
|
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) => ({
|
return columns.map((column) => ({
|
||||||
...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);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,61 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
buildKanbanColumns,
|
buildKanbanColumns,
|
||||||
|
filterKanbanColumnsByStage,
|
||||||
|
getOrderAgingState,
|
||||||
filterArchiveOrders,
|
filterArchiveOrders,
|
||||||
filterRegistryOrders,
|
filterRegistryOrders,
|
||||||
isCompletedOrderStatus,
|
isCompletedOrderStatus,
|
||||||
} from "./orderViews";
|
} from "./orderViews";
|
||||||
|
|
||||||
const baseOrders = [
|
const baseOrders = [
|
||||||
{ id: "1", status: "Новый" },
|
{
|
||||||
{ id: "2", status: "Ожидает согласования доставки" },
|
id: "1",
|
||||||
{ id: "3", status: "Проблема доставки" },
|
orderNumber: "CD-1",
|
||||||
{ id: "4", status: "Закрыт" },
|
customer: { name: "Иван Петров" },
|
||||||
{ id: "5", status: "Доставлен" },
|
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", () => {
|
describe("orderViews", () => {
|
||||||
|
|
@ -33,17 +77,92 @@ describe("orderViews", () => {
|
||||||
expect(archive.map((order) => order.id)).toEqual(["4", "5"]);
|
expect(archive.map((order) => order.id)).toEqual(["4", "5"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds kanban with separate exceptions and optional completed column", () => {
|
it("keeps completed drop-zone visible in stage mode and fills it only on demand", () => {
|
||||||
const withoutCompleted = buildKanbanColumns(baseOrders, { includeCompleted: false });
|
const withoutCompleted = buildKanbanColumns(baseOrders, {
|
||||||
const withCompleted = buildKanbanColumns(baseOrders, { includeCompleted: true });
|
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([
|
expect(withoutCompleted.map((column) => column.key)).toEqual([
|
||||||
"new",
|
"manager",
|
||||||
"coordination",
|
"production",
|
||||||
"execution",
|
"logistics",
|
||||||
"exceptions",
|
"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.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([
|
||||||
|
"Назначен водитель",
|
||||||
|
"Загружен",
|
||||||
|
"В пути",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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) => {
|
export const formatDateTime = (value) => {
|
||||||
if (!value) {
|
const date = toValidDate(value);
|
||||||
|
|
||||||
|
if (!date) {
|
||||||
return "Не указано";
|
return "Не указано";
|
||||||
}
|
}
|
||||||
return format(new Date(value), "dd.MM.yyyy HH:mm");
|
return format(date, "dd.MM.yyyy HH:mm");
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatDate = (value) => {
|
export const formatDate = (value) => {
|
||||||
if (!value) {
|
const date = toValidDate(value);
|
||||||
|
|
||||||
|
if (!date) {
|
||||||
return "Не указано";
|
return "Не указано";
|
||||||
}
|
}
|
||||||
return format(new Date(value), "dd.MM.yyyy");
|
return format(date, "dd.MM.yyyy");
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ curl -X POST \
|
||||||
`chat_messages`.
|
`chat_messages`.
|
||||||
|
|
||||||
Если передан `workflowAction=send_delivery_offer`, функция дополнительно переводит заказ в
|
Если передан `workflowAction=send_delivery_offer`, функция дополнительно переводит заказ в
|
||||||
`Ожидает согласования доставки` и выставляет `delivery_agreement_status = 'Отправлено клиенту'`.
|
`Ожидает ответа клиента` и выставляет `delivery_agreement_status = 'Отправлено клиенту'`.
|
||||||
|
|
||||||
Ожидаемые переменные:
|
Ожидаемые переменные:
|
||||||
|
|
||||||
|
|
@ -35,3 +35,26 @@ curl -X POST \
|
||||||
- `TELEGRAM_BOT_TOKEN`
|
- `TELEGRAM_BOT_TOKEN`
|
||||||
- `VK_BOT_TOKEN`
|
- `VK_BOT_TOKEN`
|
||||||
- `MESSENGER_MAX_TOKEN`
|
- `MESSENGER_MAX_TOKEN`
|
||||||
|
|
||||||
|
## `create-delivery-invitation`
|
||||||
|
|
||||||
|
Создает или обновляет активное приглашение для публичной клиентской ссылки, сохраняет
|
||||||
|
`delivery_invitations`, обновляет заказ в статус `Ожидает ответа клиента` и возвращает публичный URL.
|
||||||
|
|
||||||
|
## `get-delivery-invitation`
|
||||||
|
|
||||||
|
Возвращает публичное состояние приглашения по токену. Используется страницей клиента для показа
|
||||||
|
актуального статуса заказа.
|
||||||
|
|
||||||
|
## `confirm-delivery-choice`
|
||||||
|
|
||||||
|
Фиксирует выбор времени доставки клиентом, переводит заказ в `Доставка согласована` и создает
|
||||||
|
историю события.
|
||||||
|
|
||||||
|
## `transfer-to-logistics`
|
||||||
|
|
||||||
|
Используется для ручной передачи заказа логисту или перевода в `Платное хранение`.
|
||||||
|
|
||||||
|
## `report-delivery-result`
|
||||||
|
|
||||||
|
Фиксирует итог доставки, включая успешную доставку и проблемные сценарии.
|
||||||
|
|
|
||||||
|
|
@ -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({
|
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: "Отправлено клиенту",
|
deliveryAgreementStatus: "Отправлено клиенту",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { getOrderUpdateForDeliveryInvitationAction } from "./delivery-invitations.ts";
|
||||||
|
|
||||||
export type InboundWorkflowAction =
|
export type InboundWorkflowAction =
|
||||||
| "confirm_delivery"
|
| "confirm_delivery"
|
||||||
| "reschedule"
|
| "reschedule"
|
||||||
|
|
@ -35,10 +37,7 @@ export const getOrderUpdateForOutboundDispatch = (action: OutboundWorkflowAction
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "send_delivery_offer":
|
case "send_delivery_offer":
|
||||||
case "send_delivery_reminder":
|
case "send_delivery_reminder":
|
||||||
return {
|
return getOrderUpdateForDeliveryInvitationAction(action);
|
||||||
status: "Ожидает согласования доставки",
|
|
||||||
deliveryAgreementStatus: "Отправлено клиенту",
|
|
||||||
};
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,40 @@ create table if not exists public.error_logs (
|
||||||
created_at timestamptz not null default timezone('utc', now())
|
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 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.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;
|
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
|
add constraint chat_messages_channel_check
|
||||||
check (channel in ('telegram', 'vk', 'messenger_max', 'sms', 'email'));
|
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)
|
insert into public.roles (name, permissions)
|
||||||
values
|
values
|
||||||
(
|
(
|
||||||
|
|
@ -128,6 +178,12 @@ before update on public.orders
|
||||||
for each row
|
for each row
|
||||||
execute function public.set_updated_at();
|
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()
|
create or replace function public.current_role_name()
|
||||||
returns text
|
returns text
|
||||||
language sql
|
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 (
|
create index if not exists idx_chat_messages_search on public.chat_messages using gin (
|
||||||
to_tsvector('russian', coalesce(text, ''))
|
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.roles enable row level security;
|
||||||
alter table public.users 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.delivery_slots enable row level security;
|
||||||
alter table public.chat_messages 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.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;
|
drop policy if exists "roles admin only" on public.roles;
|
||||||
create policy "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
|
for all
|
||||||
using (public.current_role_name() = 'admin')
|
using (public.current_role_name() = 'admin')
|
||||||
with check (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');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue