refactor: align delivery flow with 1c imports
This commit is contained in:
parent
a534d53e61
commit
1798e3acfd
|
|
@ -8,7 +8,8 @@
|
|||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext js,jsx",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"anonymize:1c-xml": "node scripts/anonymize-1c-xml.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.52.0",
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[rgba(9,17,15,0.45)] p-4">
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-[rgba(9,17,15,0.45)] p-0 md:items-center md:p-4">
|
||||
<div className="absolute inset-0" onClick={onClose} />
|
||||
<div
|
||||
className={cn(
|
||||
"relative max-h-[92vh] w-full max-w-[1220px] overflow-y-auto rounded-[32px] border border-[var(--color-border)] bg-[var(--color-base)] p-4 shadow-soft md:p-6",
|
||||
"relative h-[100dvh] w-full overflow-y-auto rounded-none border border-[var(--color-border)] bg-[var(--color-base)] p-4 shadow-soft md:h-auto md:max-h-[92vh] md:max-w-[1220px] md:rounded-[32px] md:p-6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import React from "react";
|
||||
import { cn } from "../../lib/cn";
|
||||
|
||||
export const Panel = ({ children, className }) => {
|
||||
export const Panel = ({ children, className, ...props }) => {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-soft backdrop-blur",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -20,12 +20,14 @@ export const OtpLoginForm = ({
|
|||
error,
|
||||
}) => {
|
||||
return (
|
||||
<Panel className="w-full max-w-md p-8">
|
||||
<div className="mb-8 space-y-2">
|
||||
<Panel className="w-full max-w-md p-5 sm:p-8">
|
||||
<div className="mb-6 space-y-2 sm:mb-8">
|
||||
<p className="text-sm uppercase tracking-[0.28em] text-[var(--color-text-muted)]">
|
||||
Платформа доставки
|
||||
</p>
|
||||
<h1 className="text-3xl font-semibold">Вход по email и коду</h1>
|
||||
<h1 className="text-2xl font-semibold leading-tight sm:text-3xl">
|
||||
Вход по email и коду
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
Введите email, и код придет на почту. В рабочем режиме доступ определяется учетной
|
||||
записью в системе, а не выбором роли.
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import React from "react";
|
||||
import { ROLE_PERMISSIONS } from "../../constants/roles";
|
||||
import {
|
||||
getAvailableTransitionsByRole,
|
||||
getDeliveryAgreementComment,
|
||||
getOrderStatusComment,
|
||||
getStatusTone,
|
||||
} from "../../constants/deliveryWorkflow";
|
||||
import { demoUsers } from "../../data/mockAppData";
|
||||
import { getAvailableTransitionsForOrder } from "../../services/orderService";
|
||||
import { formatDateTime } from "../../utils/formatters";
|
||||
import { Badge } from "../UI/Badge";
|
||||
import { Button } from "../UI/Button";
|
||||
|
|
@ -29,11 +29,13 @@ export const OrderDetailPanel = ({
|
|||
order,
|
||||
currentUser,
|
||||
onStatusChange,
|
||||
onAssignDriver,
|
||||
onClientMessage,
|
||||
onInternalMessage,
|
||||
onOrderNote,
|
||||
}) => {
|
||||
const [nextStatus, setNextStatus] = React.useState(order?.status || "Новый");
|
||||
const [selectedDriverId, setSelectedDriverId] = React.useState(order?.assignedDriverId || "");
|
||||
const [clientReply, setClientReply] = React.useState("Подтверждаю доставку");
|
||||
const [chatQuery, setChatQuery] = React.useState("");
|
||||
const [activeTab, setActiveTab] = React.useState("overview");
|
||||
|
|
@ -42,6 +44,7 @@ export const OrderDetailPanel = ({
|
|||
|
||||
React.useEffect(() => {
|
||||
setNextStatus(order?.status || "Новый");
|
||||
setSelectedDriverId(order?.assignedDriverId || "");
|
||||
setChatQuery("");
|
||||
setActiveTab("overview");
|
||||
setTeamReply("Новый комментарий для команды");
|
||||
|
|
@ -68,10 +71,12 @@ export const OrderDetailPanel = ({
|
|||
{ key: "chat", label: "Чат с клиентом" },
|
||||
{ key: "team", label: "Команда" },
|
||||
];
|
||||
const availableTransitions = getAvailableTransitionsByRole({
|
||||
status: order.status,
|
||||
const availableTransitions = getAvailableTransitionsForOrder({
|
||||
order,
|
||||
role: currentUser.role,
|
||||
});
|
||||
const canAssignDriver = currentUser.role === "logistician" || currentUser.role === "admin";
|
||||
const drivers = demoUsers.filter((user) => user.role === "driver");
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
|
|
@ -116,7 +121,7 @@ export const OrderDetailPanel = ({
|
|||
|
||||
{activeTab === "overview" ? (
|
||||
<div className="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
|
||||
<div className="space-y-4">
|
||||
<div className="order-2 space-y-4 xl:order-none">
|
||||
<Panel className="p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<strong>Данные клиента</strong>
|
||||
|
|
@ -172,7 +177,7 @@ export const OrderDetailPanel = ({
|
|||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="order-1 space-y-4 xl:order-none">
|
||||
<Panel className="space-y-4 p-5">
|
||||
<div>
|
||||
<strong>Управление заказом</strong>
|
||||
|
|
@ -208,6 +213,32 @@ export const OrderDetailPanel = ({
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{canAssignDriver ? (
|
||||
<div className="space-y-3 rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface)] p-4">
|
||||
<div>
|
||||
<div className="font-medium">Назначение водителя</div>
|
||||
<p className="mt-2 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
Выберите водителя для передачи заказа в этап доставки.
|
||||
</p>
|
||||
</div>
|
||||
<Select value={selectedDriverId} onChange={(event) => setSelectedDriverId(event.target.value)}>
|
||||
<option value="">Не назначен</option>
|
||||
{drivers.map((driver) => (
|
||||
<option key={driver.id} value={driver.id}>
|
||||
{driver.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={(selectedDriverId || "") === (order.assignedDriverId || "")}
|
||||
onClick={() => onAssignDriver?.(selectedDriverId || null)}
|
||||
>
|
||||
Сохранить водителя
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="text-sm text-[var(--color-text-muted)]">
|
||||
Для вашей роли доступны типовые действия:
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,14 +5,15 @@ import { Input } from "../UI/Input";
|
|||
import { Panel } from "../UI/Panel";
|
||||
import { Select } from "../UI/Select";
|
||||
|
||||
const managerOptions = demoUsers.filter((user) => user.role === "manager" || user.role === "admin");
|
||||
const getUsers = (users) => (Array.isArray(users) && users.length ? users : demoUsers);
|
||||
const getManagerOptions = (users) => getUsers(users).filter((user) => user.role === "manager" || user.role === "admin");
|
||||
const initialForm = {
|
||||
orderNumber: "",
|
||||
customerName: "",
|
||||
customerPhone: "",
|
||||
customerAddress: "",
|
||||
messenger: "Телеграм",
|
||||
managerId: managerOptions[0]?.id || "",
|
||||
managerId: "",
|
||||
deliveryDate: "",
|
||||
items: "",
|
||||
comments: "",
|
||||
|
|
@ -26,10 +27,12 @@ export const OrderEditorPanel = ({
|
|||
onSaveOrder,
|
||||
createOnly = false,
|
||||
onDone,
|
||||
users,
|
||||
}) => {
|
||||
const [form, setForm] = React.useState(initialForm);
|
||||
const [isCreateMode, setIsCreateMode] = React.useState(createOnly);
|
||||
const canManageOrders = currentUser.role === "manager" || currentUser.role === "admin";
|
||||
const managerOptions = getManagerOptions(users);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!createOnly) {
|
||||
|
|
@ -56,10 +59,10 @@ export const OrderEditorPanel = ({
|
|||
customerAddress: selectedOrder.customer.address,
|
||||
messenger: selectedOrder.customer.messenger,
|
||||
managerId: selectedOrder.managerId,
|
||||
deliveryDate: selectedOrder.deliverySlots[0]?.date || "",
|
||||
deliveryDate: selectedOrder.deliverySlots?.[0]?.date || "",
|
||||
items: (selectedOrder.items || []).join("\n"),
|
||||
comments: selectedOrder.comments.join(", "),
|
||||
tags: selectedOrder.tags.join(", "),
|
||||
comments: (selectedOrder.comments || []).join(", "),
|
||||
tags: (selectedOrder.tags || []).join(", "),
|
||||
});
|
||||
}, [isCreateMode, selectedOrder]);
|
||||
|
||||
|
|
@ -112,18 +115,18 @@ export const OrderEditorPanel = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<Panel className="space-y-4 p-5">
|
||||
<Panel className="space-y-4 p-5 pb-24">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Управление заказом</h3>
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
Создание и редактирование заказа с полями клиента, канала связи и даты доставки.
|
||||
Редактирование импортированного из 1С заказа с полями клиента, канала связи и даты доставки.
|
||||
</p>
|
||||
</div>
|
||||
{!createOnly ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" onClick={resetForCreate} disabled={!canManageOrders}>
|
||||
Новый заказ
|
||||
Импортированный заказ
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={resetForSelected}>
|
||||
Сбросить
|
||||
|
|
@ -134,7 +137,7 @@ export const OrderEditorPanel = ({
|
|||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input
|
||||
placeholder="Номер заказа"
|
||||
placeholder="Номер заказа из 1С"
|
||||
value={form.orderNumber}
|
||||
onChange={(event) => updateField("orderNumber", event.target.value)}
|
||||
/>
|
||||
|
|
@ -149,17 +152,17 @@ export const OrderEditorPanel = ({
|
|||
))}
|
||||
</Select>
|
||||
<Input
|
||||
placeholder="Имя клиента"
|
||||
placeholder="Имя клиента из 1С"
|
||||
value={form.customerName}
|
||||
onChange={(event) => updateField("customerName", event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Телефон"
|
||||
placeholder="Телефон клиента"
|
||||
value={form.customerPhone}
|
||||
onChange={(event) => updateField("customerPhone", event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Адрес доставки"
|
||||
placeholder="Адрес доставки из 1С"
|
||||
value={form.customerAddress}
|
||||
onChange={(event) => updateField("customerAddress", event.target.value)}
|
||||
/>
|
||||
|
|
@ -199,9 +202,11 @@ export const OrderEditorPanel = ({
|
|||
onChange={(event) => updateField("comments", event.target.value)}
|
||||
/>
|
||||
|
||||
<div className="sticky bottom-0 -mx-5 border-t border-[var(--color-border)] bg-[var(--color-base)]/96 px-5 pb-1 pt-4 backdrop-blur">
|
||||
<Button className="w-full" onClick={handleSubmit} disabled={!canManageOrders}>
|
||||
{isCreateMode ? "Создать заказ" : "Сохранить изменения"}
|
||||
{isCreateMode ? "Сохранить импорт" : "Сохранить изменения"}
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import React from "react";
|
||||
import { WORKFLOW_STAGES } from "../../constants/deliveryWorkflow";
|
||||
import { ORDER_STATUSES } from "../../constants/orderStatuses";
|
||||
import { ROLE_LABELS } from "../../constants/roles";
|
||||
import { demoUsers } from "../../data/mockAppData";
|
||||
import { Badge } from "../UI/Badge";
|
||||
import { Button } from "../UI/Button";
|
||||
import { Input } from "../UI/Input";
|
||||
import { Panel } from "../UI/Panel";
|
||||
import { Select } from "../UI/Select";
|
||||
|
|
@ -8,74 +12,191 @@ import { Select } from "../UI/Select";
|
|||
const logisticians = demoUsers.filter((user) => user.role === "logistician");
|
||||
const managers = demoUsers.filter((user) => user.role === "manager");
|
||||
const messengers = ["Телеграм", "ВКонтакте", "Макс", "СМС", "Эл. почта"];
|
||||
const responsibilityRoles = Object.entries(ROLE_LABELS).filter(([role]) => role !== "admin");
|
||||
const agingOptions = [
|
||||
{ key: "warning", label: "Требуют внимания" },
|
||||
{ key: "critical", label: "Просрочены" },
|
||||
];
|
||||
|
||||
export const OrderFilters = ({ filters, setFilters }) => {
|
||||
return (
|
||||
<Panel className="p-4">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||
<Input
|
||||
placeholder="Поиск по заказу, клиенту, тегу"
|
||||
value={filters.query}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({ ...current, query: event.target.value }))
|
||||
}
|
||||
/>
|
||||
const [isMobileFiltersOpen, setIsMobileFiltersOpen] = React.useState(false);
|
||||
|
||||
<Select
|
||||
value={filters.status}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({ ...current, status: event.target.value }))
|
||||
const activeChips = [
|
||||
filters.status !== "all" ? { key: "status", label: filters.status } : null,
|
||||
filters.stage !== "all"
|
||||
? { key: "stage", label: WORKFLOW_STAGES.find((stage) => stage.key === filters.stage)?.label }
|
||||
: null,
|
||||
filters.ownerRole !== "all" ? { key: "ownerRole", label: ROLE_LABELS[filters.ownerRole] } : null,
|
||||
filters.agingState !== "all"
|
||||
? { key: "agingState", label: agingOptions.find((option) => option.key === filters.agingState)?.label }
|
||||
: null,
|
||||
filters.managerId !== "all"
|
||||
? { key: "managerId", label: managers.find((manager) => manager.id === filters.managerId)?.name }
|
||||
: null,
|
||||
filters.logisticianId !== "all"
|
||||
? {
|
||||
key: "logisticianId",
|
||||
label: logisticians.find((logistician) => logistician.id === filters.logisticianId)?.name,
|
||||
}
|
||||
>
|
||||
: null,
|
||||
filters.messenger !== "all" ? { key: "messenger", label: filters.messenger } : null,
|
||||
].filter(Boolean);
|
||||
|
||||
const updateFilter = (key, value) => {
|
||||
setFilters((current) => ({ ...current, [key]: value }));
|
||||
};
|
||||
|
||||
const renderFilterField = (label, control, showLabel = false) => (
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
{showLabel ? (
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||
{label}
|
||||
</span>
|
||||
) : null}
|
||||
{control}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderAdvancedFilters = ({ className = "", showLabels = false } = {}) => (
|
||||
<div className={className}>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-7">
|
||||
{renderFilterField(
|
||||
"Статус",
|
||||
<Select value={filters.status} onChange={(event) => updateFilter("status", event.target.value)}>
|
||||
<option value="all">Все статусы</option>
|
||||
{ORDER_STATUSES.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{status}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Select>,
|
||||
showLabels,
|
||||
)}
|
||||
|
||||
<Select
|
||||
value={filters.managerId}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({ ...current, managerId: event.target.value }))
|
||||
}
|
||||
>
|
||||
{renderFilterField(
|
||||
"Этап",
|
||||
<Select value={filters.stage} onChange={(event) => updateFilter("stage", event.target.value)}>
|
||||
<option value="all">Все этапы</option>
|
||||
{WORKFLOW_STAGES.map((stage) => (
|
||||
<option key={stage.key} value={stage.key}>
|
||||
{stage.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>,
|
||||
showLabels,
|
||||
)}
|
||||
|
||||
{renderFilterField(
|
||||
"Ответственный отдел",
|
||||
<Select value={filters.ownerRole} onChange={(event) => updateFilter("ownerRole", event.target.value)}>
|
||||
<option value="all">Все зоны ответственности</option>
|
||||
{responsibilityRoles.map(([role, label]) => (
|
||||
<option key={role} value={role}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</Select>,
|
||||
showLabels,
|
||||
)}
|
||||
|
||||
{renderFilterField(
|
||||
"SLA",
|
||||
<Select value={filters.agingState} onChange={(event) => updateFilter("agingState", event.target.value)}>
|
||||
<option value="all">Без фильтра по SLA</option>
|
||||
{agingOptions.map((option) => (
|
||||
<option key={option.key} value={option.key}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>,
|
||||
showLabels,
|
||||
)}
|
||||
|
||||
{renderFilterField(
|
||||
"Менеджер",
|
||||
<Select value={filters.managerId} onChange={(event) => updateFilter("managerId", event.target.value)}>
|
||||
<option value="all">Все менеджеры</option>
|
||||
{managers.map((manager) => (
|
||||
<option key={manager.id} value={manager.id}>
|
||||
{manager.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Select>,
|
||||
showLabels,
|
||||
)}
|
||||
|
||||
<Select
|
||||
value={filters.logisticianId}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({ ...current, logisticianId: event.target.value }))
|
||||
}
|
||||
>
|
||||
{renderFilterField(
|
||||
"Логист",
|
||||
<Select value={filters.logisticianId} onChange={(event) => updateFilter("logisticianId", event.target.value)}>
|
||||
<option value="all">Все логисты</option>
|
||||
{logisticians.map((logistician) => (
|
||||
<option key={logistician.id} value={logistician.id}>
|
||||
{logistician.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Select>,
|
||||
showLabels,
|
||||
)}
|
||||
|
||||
<Select
|
||||
value={filters.messenger}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({ ...current, messenger: event.target.value }))
|
||||
}
|
||||
>
|
||||
{renderFilterField(
|
||||
"Канал",
|
||||
<Select value={filters.messenger} onChange={(event) => updateFilter("messenger", event.target.value)}>
|
||||
<option value="all">Все каналы</option>
|
||||
{messengers.map((messenger) => (
|
||||
<option key={messenger} value={messenger}>
|
||||
{messenger}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Select>,
|
||||
showLabels,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Panel className="p-4">
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
placeholder="Поиск по заявке, клиенту, телефону"
|
||||
value={filters.query}
|
||||
onChange={(event) => updateFilter("query", event.target.value)}
|
||||
/>
|
||||
<Button size="sm" variant="secondary" onClick={() => setIsMobileFiltersOpen((current) => !current)}>
|
||||
Фильтры
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||
Активные фильтры
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{activeChips.length ? activeChips.map((chip) => <Badge key={chip.key}>{chip.label}</Badge>) : <Badge>Нет</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
{isMobileFiltersOpen
|
||||
? renderAdvancedFilters({
|
||||
className: "rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-3",
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex md:flex-col md:gap-4">
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(22rem,1.35fr)_minmax(0,1fr)] xl:items-start">
|
||||
<Input
|
||||
placeholder="Поиск по заявке, клиенту, телефону"
|
||||
value={filters.query}
|
||||
onChange={(event) => updateFilter("query", event.target.value)}
|
||||
/>
|
||||
<div className="min-h-[44px] rounded-[20px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] px-4 py-3">
|
||||
<div className="text-xs uppercase tracking-[0.14em] text-[var(--color-text-muted)]">Активные фильтры</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{activeChips.length ? activeChips.map((chip) => <Badge key={chip.key}>{chip.label}</Badge>) : <Badge>Нет</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderAdvancedFilters({ showLabels: true })}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -62,6 +62,13 @@ export const OrdersCalendarView = ({ orders, onOpenOrder }) => {
|
|||
}, {}),
|
||||
[orders],
|
||||
);
|
||||
const agendaDays = React.useMemo(
|
||||
() =>
|
||||
Object.entries(ordersByDay)
|
||||
.sort(([left], [right]) => new Date(left) - new Date(right))
|
||||
.map(([key, dayOrders]) => ({ key, dayOrders })),
|
||||
[ordersByDay],
|
||||
);
|
||||
|
||||
return (
|
||||
<Panel className="space-y-5 p-6">
|
||||
|
|
@ -85,6 +92,37 @@ export const OrdersCalendarView = ({ orders, onOpenOrder }) => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 md:hidden">
|
||||
<div className="text-xs uppercase tracking-[0.14em] text-[var(--color-text-muted)]">Заказы по дням</div>
|
||||
{agendaDays.map(({ key, dayOrders }) => (
|
||||
<div key={key} className="rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold">
|
||||
{new Date(`${key}T00:00:00`).toLocaleDateString("ru-RU", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--color-text-muted)]">{dayOrders.length}</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{dayOrders.map((order) => (
|
||||
<button
|
||||
key={order.id}
|
||||
type="button"
|
||||
onClick={() => onOpenOrder(order.id)}
|
||||
className="w-full rounded-[16px] bg-[var(--color-surface)] px-3 py-3 text-left text-sm hover:bg-[var(--color-accent-soft)]"
|
||||
>
|
||||
<div className="font-medium">{order.orderNumber}</div>
|
||||
<div className="mt-1 text-xs text-[var(--color-text-muted)]">{order.customer.name}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block">
|
||||
<div className="grid grid-cols-7 gap-3 text-xs uppercase tracking-[0.12em] text-[var(--color-text-muted)]">
|
||||
{WEEK_DAYS.map((day) => (
|
||||
<div key={day} className="px-2">
|
||||
|
|
@ -93,13 +131,13 @@ export const OrdersCalendarView = ({ orders, onOpenOrder }) => {
|
|||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-7">
|
||||
<div className="mt-3 grid grid-cols-7 gap-3">
|
||||
{calendarDays.map((day, index) => {
|
||||
if (!day) {
|
||||
return (
|
||||
<div
|
||||
key={`empty-${index}`}
|
||||
className="hidden min-h-[132px] rounded-[24px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface)]/50 md:block"
|
||||
className="min-h-[132px] rounded-[24px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface)]/50"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -141,6 +179,7 @@ export const OrdersCalendarView = ({ orders, onOpenOrder }) => {
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,7 +25,35 @@ export const OrdersTable = ({ orders, selectedOrderId, onOpenOrder }) => {
|
|||
<Badge tone="neutral">{orders.length}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<div className="space-y-3 p-4 md:hidden">
|
||||
{orders.map((order) => (
|
||||
<button
|
||||
key={order.id}
|
||||
type="button"
|
||||
onClick={() => onOpenOrder(order.id)}
|
||||
className={[
|
||||
"w-full rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-left transition",
|
||||
selectedOrderId === order.id ? "bg-[var(--color-accent-soft)]" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium">{order.orderNumber}</div>
|
||||
<div className="mt-1 text-sm text-[var(--color-text-muted)]">{order.customer.name}</div>
|
||||
</div>
|
||||
<Badge tone={getStatusTone(order.status)}>{order.status}</Badge>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-[var(--color-text-muted)]">{buildOrderSummary(order)}</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 text-xs text-[var(--color-text-muted)]">
|
||||
<span>{order.customer.phone}</span>
|
||||
<span>{resolveUserName(order.managerId)}</span>
|
||||
<span>{formatDateTime(order.updatedAt)}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden overflow-x-auto md:block">
|
||||
<table className="min-w-full border-collapse">
|
||||
<thead className="bg-[var(--color-surface-strong)] text-left text-xs uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
|
||||
<tr>
|
||||
|
|
|
|||
|
|
@ -1,77 +1,175 @@
|
|||
export const WORKFLOW_STAGES = [
|
||||
{ key: "manager", label: "Менеджер" },
|
||||
{ key: "production", label: "Производство" },
|
||||
{ key: "logistics", label: "Логистика" },
|
||||
{ key: "delivery", label: "Доставка" },
|
||||
{ key: "completed", label: "Завершено" },
|
||||
];
|
||||
|
||||
const getStageLabel = (stageKey) =>
|
||||
WORKFLOW_STAGES.find((stage) => stage.key === stageKey)?.label || "Без этапа";
|
||||
|
||||
export const ORDER_STATUS_META = {
|
||||
"Новый": {
|
||||
comment: "Заказ создан и ожидает проверки менеджером.",
|
||||
ownerRole: "manager",
|
||||
stageKey: "manager",
|
||||
stageLabel: getStageLabel("manager"),
|
||||
warningAfterHours: 24,
|
||||
criticalAfterHours: 48,
|
||||
tone: "neutral",
|
||||
},
|
||||
"Требует уточнения": {
|
||||
comment: "В заказе не хватает данных, их должен уточнить менеджер.",
|
||||
ownerRole: "manager",
|
||||
stageKey: "manager",
|
||||
stageLabel: getStageLabel("manager"),
|
||||
warningAfterHours: 12,
|
||||
criticalAfterHours: 24,
|
||||
tone: "warning",
|
||||
},
|
||||
"Подтверждён менеджером": {
|
||||
comment: "Менеджер проверил заказ и передал его дальше в работу.",
|
||||
ownerRole: "manager",
|
||||
stageKey: "manager",
|
||||
stageLabel: getStageLabel("manager"),
|
||||
warningAfterHours: 12,
|
||||
criticalAfterHours: 24,
|
||||
tone: "accent",
|
||||
},
|
||||
"В очереди производства": {
|
||||
comment: "Заказ передан на производство и ожидает запуска.",
|
||||
ownerRole: "production_lead",
|
||||
stageKey: "production",
|
||||
stageLabel: getStageLabel("production"),
|
||||
warningAfterHours: 24,
|
||||
criticalAfterHours: 48,
|
||||
tone: "neutral",
|
||||
},
|
||||
"В производстве": {
|
||||
comment: "Заказ находится в изготовлении.",
|
||||
ownerRole: "production_lead",
|
||||
stageKey: "production",
|
||||
stageLabel: getStageLabel("production"),
|
||||
warningAfterHours: 48,
|
||||
criticalAfterHours: 96,
|
||||
tone: "accent",
|
||||
},
|
||||
"Готов к отгрузке": {
|
||||
comment: "Производство завершено, можно запускать согласование доставки.",
|
||||
ownerRole: "production_lead",
|
||||
stageKey: "production",
|
||||
stageLabel: getStageLabel("production"),
|
||||
warningAfterHours: 8,
|
||||
criticalAfterHours: 24,
|
||||
tone: "accent",
|
||||
},
|
||||
"Ожидает ответа клиента": {
|
||||
comment: "Клиенту отправлена ссылка, система ждёт подтверждения времени доставки.",
|
||||
ownerRole: "logistician",
|
||||
stageKey: "logistics",
|
||||
stageLabel: getStageLabel("logistics"),
|
||||
warningAfterHours: 1,
|
||||
criticalAfterHours: 3,
|
||||
tone: "warning",
|
||||
},
|
||||
"Ожидает согласования доставки": {
|
||||
comment: "Клиенту отправлено предложение выбрать дату и половину дня доставки.",
|
||||
ownerRole: "logistician",
|
||||
stageKey: "logistics",
|
||||
stageLabel: getStageLabel("logistics"),
|
||||
warningAfterHours: 24,
|
||||
criticalAfterHours: 96,
|
||||
tone: "warning",
|
||||
},
|
||||
"Доставка согласована": {
|
||||
comment: "Клиент подтвердил доставку, логист может назначать рейс.",
|
||||
ownerRole: "logistician",
|
||||
stageKey: "logistics",
|
||||
stageLabel: getStageLabel("logistics"),
|
||||
warningAfterHours: 12,
|
||||
criticalAfterHours: 24,
|
||||
tone: "accent",
|
||||
},
|
||||
"Передан логисту": {
|
||||
comment: "Согласование не завершилось автоматически, заказ передан логисту для ручной работы.",
|
||||
ownerRole: "logistician",
|
||||
stageKey: "logistics",
|
||||
stageLabel: getStageLabel("logistics"),
|
||||
warningAfterHours: 4,
|
||||
criticalAfterHours: 12,
|
||||
tone: "warning",
|
||||
},
|
||||
"Назначен водитель": {
|
||||
comment: "Логист распределил заказ на конкретного водителя.",
|
||||
ownerRole: "logistician",
|
||||
stageKey: "delivery",
|
||||
stageLabel: getStageLabel("delivery"),
|
||||
warningAfterHours: 12,
|
||||
criticalAfterHours: 24,
|
||||
tone: "accent",
|
||||
},
|
||||
Загружен: {
|
||||
comment: "Заказ физически загружен в транспорт.",
|
||||
ownerRole: "driver",
|
||||
stageKey: "delivery",
|
||||
stageLabel: getStageLabel("delivery"),
|
||||
warningAfterHours: 8,
|
||||
criticalAfterHours: 24,
|
||||
tone: "neutral",
|
||||
},
|
||||
"В пути": {
|
||||
comment: "Водитель выехал и выполняет доставку.",
|
||||
ownerRole: "driver",
|
||||
stageKey: "delivery",
|
||||
stageLabel: getStageLabel("delivery"),
|
||||
warningAfterHours: 12,
|
||||
criticalAfterHours: 24,
|
||||
tone: "accent",
|
||||
},
|
||||
Доставлен: {
|
||||
comment: "Заказ успешно передан клиенту.",
|
||||
ownerRole: "driver",
|
||||
stageKey: "completed",
|
||||
stageLabel: getStageLabel("completed"),
|
||||
warningAfterHours: null,
|
||||
criticalAfterHours: null,
|
||||
tone: "accent",
|
||||
},
|
||||
Закрыт: {
|
||||
comment: "Цикл заказа завершён и больше не требует действий.",
|
||||
ownerRole: "logistician",
|
||||
stageKey: "completed",
|
||||
stageLabel: getStageLabel("completed"),
|
||||
warningAfterHours: null,
|
||||
criticalAfterHours: null,
|
||||
tone: "neutral",
|
||||
},
|
||||
Отменён: {
|
||||
comment: "Заказ отменён и выведен из процесса.",
|
||||
ownerRole: "manager",
|
||||
stageKey: "completed",
|
||||
stageLabel: getStageLabel("completed"),
|
||||
warningAfterHours: null,
|
||||
criticalAfterHours: null,
|
||||
tone: "danger",
|
||||
},
|
||||
"Проблема доставки": {
|
||||
comment: "На этапе доставки возникла проблема и нужен ручной разбор.",
|
||||
ownerRole: "logistician",
|
||||
stageKey: "logistics",
|
||||
stageLabel: getStageLabel("logistics"),
|
||||
warningAfterHours: 12,
|
||||
criticalAfterHours: 48,
|
||||
tone: "danger",
|
||||
},
|
||||
"Платное хранение": {
|
||||
comment: "Согласование доставки не достигнуто, заказ переведен на платное хранение.",
|
||||
ownerRole: "logistician",
|
||||
stageKey: "logistics",
|
||||
stageLabel: getStageLabel("logistics"),
|
||||
warningAfterHours: 24,
|
||||
criticalAfterHours: 72,
|
||||
tone: "danger",
|
||||
},
|
||||
};
|
||||
|
|
@ -110,26 +208,32 @@ export const ORDER_STATUS_TRANSITIONS = {
|
|||
"Подтверждён менеджером": ["В очереди производства", "Требует уточнения", "Отменён"],
|
||||
"В очереди производства": ["В производстве", "Требует уточнения", "Отменён"],
|
||||
"В производстве": ["Готов к отгрузке", "Требует уточнения", "Отменён"],
|
||||
"Готов к отгрузке": ["Ожидает согласования доставки", "Проблема доставки", "Отменён"],
|
||||
"Готов к отгрузке": ["Ожидает согласования доставки", "Ожидает ответа клиента", "Проблема доставки", "Отменён"],
|
||||
"Ожидает ответа клиента": ["Доставка согласована", "Передан логисту", "Платное хранение", "Проблема доставки", "Отменён"],
|
||||
"Ожидает согласования доставки": ["Доставка согласована", "Проблема доставки", "Отменён"],
|
||||
"Доставка согласована": ["Назначен водитель", "Ожидает согласования доставки", "Проблема доставки"],
|
||||
"Передан логисту": ["Доставка согласована", "Платное хранение", "Проблема доставки", "Отменён"],
|
||||
"Назначен водитель": ["Загружен", "Проблема доставки"],
|
||||
Загружен: ["В пути", "Проблема доставки"],
|
||||
"В пути": ["Доставлен", "Проблема доставки"],
|
||||
Доставлен: ["Закрыт"],
|
||||
"Проблема доставки": ["Ожидает согласования доставки", "Назначен водитель", "Отменён", "Закрыт"],
|
||||
"Платное хранение": ["Доставка согласована", "Отменён", "Закрыт"],
|
||||
Закрыт: [],
|
||||
Отменён: [],
|
||||
};
|
||||
|
||||
export const ROLE_TRANSITION_TARGETS = {
|
||||
manager: ["Новый", "Требует уточнения", "Подтверждён менеджером", "В очереди производства", "Отменён"],
|
||||
manager: ORDER_STATUSES,
|
||||
production_lead: ["В очереди производства", "В производстве", "Готов к отгрузке", "Требует уточнения", "Отменён"],
|
||||
logistician: [
|
||||
"Ожидает ответа клиента",
|
||||
"Ожидает согласования доставки",
|
||||
"Доставка согласована",
|
||||
"Передан логисту",
|
||||
"Назначен водитель",
|
||||
"Проблема доставки",
|
||||
"Платное хранение",
|
||||
"Закрыт",
|
||||
"Отменён",
|
||||
],
|
||||
|
|
@ -160,6 +264,17 @@ export const getDeliveryAgreementComment = (status) =>
|
|||
|
||||
export const getStatusTone = (status) => ORDER_STATUS_META[status]?.tone || "neutral";
|
||||
|
||||
export const getStatusOwnerRole = (status) => ORDER_STATUS_META[status]?.ownerRole || null;
|
||||
|
||||
export const getStatusStageKey = (status) => ORDER_STATUS_META[status]?.stageKey || null;
|
||||
|
||||
export const getStatusStageLabel = (status) => ORDER_STATUS_META[status]?.stageLabel || "Без этапа";
|
||||
|
||||
export const getStatusSla = (status) => ({
|
||||
warningAfterHours: ORDER_STATUS_META[status]?.warningAfterHours ?? null,
|
||||
criticalAfterHours: ORDER_STATUS_META[status]?.criticalAfterHours ?? null,
|
||||
});
|
||||
|
||||
export const getAvailableTransitionsByRole = ({ status, role }) => {
|
||||
const nextStatuses = ORDER_STATUS_TRANSITIONS[status] || [];
|
||||
const allowedTargets = ROLE_TRANSITION_TARGETS[role] || [];
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export const demoUsers = [
|
|||
},
|
||||
];
|
||||
|
||||
export const demoOrders = [
|
||||
const baseDemoOrders = [
|
||||
{
|
||||
id: "o-1001",
|
||||
orderNumber: "CD-240031",
|
||||
|
|
@ -622,6 +622,158 @@ export const demoOrders = [
|
|||
},
|
||||
];
|
||||
|
||||
const extraOrderSeeds = [
|
||||
{ suffix: 101, customerName: "Людмила Артемьева", status: "Новый", city: "Симферополь", item: "Шкаф распашной", messenger: "Телеграм", updatedAt: "2026-03-14T08:20:00Z" },
|
||||
{ suffix: 102, customerName: "Павел Карпов", status: "Новый", city: "Ялта", item: "Стол обеденный", messenger: "ВКонтакте", updatedAt: "2026-03-14T10:10:00Z" },
|
||||
{ suffix: 103, customerName: "Алёна Беспалова", status: "Требует уточнения", city: "Евпатория", item: "Тумба ТВ", messenger: "СМС", updatedAt: "2026-03-14T06:40:00Z" },
|
||||
{ suffix: 104, customerName: "Георгий Храмов", status: "Требует уточнения", city: "Севастополь", item: "Комод", messenger: "Макс", updatedAt: "2026-03-13T17:50:00Z" },
|
||||
{ suffix: 105, customerName: "Валерия Фролова", status: "Подтверждён менеджером", city: "Симферополь", item: "Кухонный фасад", messenger: "Телеграм", updatedAt: "2026-03-14T11:35:00Z" },
|
||||
{ suffix: 106, customerName: "Иван Мирошниченко", status: "Подтверждён менеджером", city: "Алушта", item: "Шкаф-купе", messenger: "Эл. почта", updatedAt: "2026-03-14T09:25:00Z" },
|
||||
{ suffix: 107, customerName: "Марина Ермакова", status: "В очереди производства", city: "Симферополь", item: "Столешница", messenger: "Телеграм", updatedAt: "2026-03-13T12:10:00Z" },
|
||||
{ suffix: 108, customerName: "Руслан Гладков", status: "В очереди производства", city: "Ялта", item: "Гардеробная секция", messenger: "ВКонтакте", updatedAt: "2026-03-13T08:00:00Z" },
|
||||
{ suffix: 109, customerName: "Светлана Коваль", status: "В очереди производства", city: "Севастополь", item: "Дверь межкомнатная", messenger: "СМС", updatedAt: "2026-03-12T13:45:00Z" },
|
||||
{ suffix: 110, customerName: "Михаил Орлов", status: "В производстве", city: "Симферополь", item: "Кухня линейная", messenger: "Телеграм", updatedAt: "2026-03-13T09:30:00Z" },
|
||||
{ suffix: 111, customerName: "Татьяна Шубина", status: "В производстве", city: "Ялта", item: "Стеллаж", messenger: "Макс", updatedAt: "2026-03-12T15:20:00Z" },
|
||||
{ suffix: 112, customerName: "Андрей Беляев", status: "В производстве", city: "Евпатория", item: "Фасады МДФ", messenger: "Эл. почта", updatedAt: "2026-03-14T07:55:00Z" },
|
||||
{ suffix: 113, customerName: "Елена Бондарь", status: "Готов к отгрузке", city: "Симферополь", item: "Пенал для кухни", messenger: "Телеграм", updatedAt: "2026-03-14T05:15:00Z" },
|
||||
{ suffix: 114, customerName: "Кирилл Нестеров", status: "Готов к отгрузке", city: "Ялта", item: "Стол письменный", messenger: "СМС", updatedAt: "2026-03-14T09:45:00Z" },
|
||||
{ suffix: 115, customerName: "Наталья Зотова", status: "Готов к отгрузке", city: "Севастополь", item: "Шкаф угловой", messenger: "ВКонтакте", updatedAt: "2026-03-13T18:05:00Z" },
|
||||
{ suffix: 116, customerName: "Константин Матвеев", status: "Ожидает согласования доставки", city: "Симферополь", item: "Комод высокий", messenger: "Телеграм", updatedAt: "2026-03-14T08:05:00Z" },
|
||||
{ suffix: 117, customerName: "Лариса Шевцова", status: "Ожидает согласования доставки", city: "Ялта", item: "Стеллаж модульный", messenger: "Макс", updatedAt: "2026-03-13T06:50:00Z" },
|
||||
{ suffix: 118, customerName: "Евгений Филимонов", status: "Ожидает согласования доставки", city: "Севастополь", item: "Тумба под мойку", messenger: "СМС", updatedAt: "2026-03-12T08:15:00Z" },
|
||||
{ suffix: 119, customerName: "Диана Рябова", status: "Доставка согласована", city: "Симферополь", item: "Навесной шкаф", messenger: "Телеграм", updatedAt: "2026-03-14T11:00:00Z" },
|
||||
{ suffix: 120, customerName: "Олег Вишневский", status: "Доставка согласована", city: "Алушта", item: "Стол раскладной", messenger: "Эл. почта", updatedAt: "2026-03-14T04:20:00Z" },
|
||||
{ suffix: 121, customerName: "Полина Исаева", status: "Назначен водитель", city: "Симферополь", item: "Кровать", messenger: "Телеграм", updatedAt: "2026-03-14T10:40:00Z" },
|
||||
{ suffix: 122, customerName: "Роман Щукин", status: "Назначен водитель", city: "Ялта", item: "Прихожая", messenger: "ВКонтакте", updatedAt: "2026-03-14T09:05:00Z" },
|
||||
{ suffix: 123, customerName: "Юлия Баранова", status: "Загружен", city: "Севастополь", item: "Шкаф-пенал", messenger: "СМС", updatedAt: "2026-03-14T07:30:00Z" },
|
||||
{ suffix: 124, customerName: "Виктор Громыко", status: "В пути", city: "Симферополь", item: "Гарнитур в прихожую", messenger: "Телеграм", updatedAt: "2026-03-14T11:20:00Z" },
|
||||
{ suffix: 125, customerName: "Инна Самойлова", status: "Доставлен", city: "Евпатория", item: "Шкаф в ванную", messenger: "Эл. почта", updatedAt: "2026-03-14T12:05:00Z" },
|
||||
];
|
||||
|
||||
const DELIVERY_STATUSES = new Set(["Назначен водитель", "Загружен", "В пути", "Доставлен", "Закрыт"]);
|
||||
const LOGISTICS_OR_DELIVERY_STATUSES = new Set([
|
||||
"Ожидает согласования доставки",
|
||||
"Доставка согласована",
|
||||
"Назначен водитель",
|
||||
"Загружен",
|
||||
"В пути",
|
||||
"Доставлен",
|
||||
"Закрыт",
|
||||
]);
|
||||
|
||||
const getAgreementStatusForDemo = (status) => {
|
||||
if (status === "Ожидает согласования доставки") {
|
||||
return "Ожидание ответа";
|
||||
}
|
||||
if (status === "Доставка согласована" || DELIVERY_STATUSES.has(status)) {
|
||||
return "Подтверждено клиентом";
|
||||
}
|
||||
return "Не начато";
|
||||
};
|
||||
|
||||
const getDeliverySlotStatusForDemo = (status) => {
|
||||
if (status === "Ожидает согласования доставки") {
|
||||
return "Ожидает подтверждения";
|
||||
}
|
||||
if (status === "Доставка согласована" || status === "Назначен водитель") {
|
||||
return "Подтверждён";
|
||||
}
|
||||
if (status === "Загружен" || status === "В пути") {
|
||||
return "В рейсе";
|
||||
}
|
||||
if (status === "Доставлен" || status === "Закрыт") {
|
||||
return "Завершён";
|
||||
}
|
||||
return "Черновик";
|
||||
};
|
||||
|
||||
const buildExtraDemoOrder = (seed, index) => {
|
||||
const logisticianId = index % 2 === 0 ? "u-logistics" : "u-logistics-2";
|
||||
const assignedDriverId =
|
||||
DELIVERY_STATUSES.has(seed.status) || seed.status === "Доставка согласована"
|
||||
? "u-driver"
|
||||
: null;
|
||||
const scheduledDelivery = `2026-03-${String(16 + (index % 6)).padStart(2, "0")}T${index % 2 === 0 ? "09:00:00Z" : "13:00:00Z"}`;
|
||||
|
||||
return {
|
||||
id: `o-extra-${seed.suffix}`,
|
||||
orderNumber: `CD-24${seed.suffix}`,
|
||||
customer: {
|
||||
name: seed.customerName,
|
||||
phone: `+7 978 100-${String(seed.suffix).padStart(3, "0")}`,
|
||||
messenger: seed.messenger,
|
||||
address: `${seed.city}, ул. Демо, ${10 + index}`,
|
||||
},
|
||||
status: seed.status,
|
||||
deliveryAgreementStatus: getAgreementStatusForDemo(seed.status),
|
||||
managerId: "u-manager",
|
||||
logisticianIds: LOGISTICS_OR_DELIVERY_STATUSES.has(seed.status) ? [logisticianId] : [],
|
||||
assignedDriverId,
|
||||
driverRouteOrder: assignedDriverId ? (index % 6) + 1 : null,
|
||||
createdAt: `2026-03-${String(9 + (index % 5)).padStart(2, "0")}T${String((index % 5) + 7).padStart(2, "0")}:00:00Z`,
|
||||
updatedAt: seed.updatedAt,
|
||||
scheduledDelivery,
|
||||
items: [`${seed.item} | 1 шт`, "Комплект фурнитуры | 1 набор"],
|
||||
tags: [seed.city.toLowerCase(), seed.status.toLowerCase()],
|
||||
comments: [`Демо-заказ для проверки нагрузки на статусе «${seed.status}».`],
|
||||
orderNotes: [
|
||||
{
|
||||
id: `note-extra-${seed.suffix}`,
|
||||
authorName: "Система",
|
||||
text: `Контрольная демо-запись для этапа «${seed.status}».`,
|
||||
createdAt: seed.updatedAt,
|
||||
},
|
||||
],
|
||||
history: [
|
||||
{
|
||||
id: `history-extra-${seed.suffix}`,
|
||||
action: "Демо-переход",
|
||||
oldStatus: null,
|
||||
newStatus: seed.status,
|
||||
userName: "Система",
|
||||
at: seed.updatedAt,
|
||||
},
|
||||
],
|
||||
chatMessages:
|
||||
seed.status === "Ожидает согласования доставки"
|
||||
? [
|
||||
{
|
||||
id: `chat-extra-${seed.suffix}`,
|
||||
sender: "bot",
|
||||
channel: seed.messenger,
|
||||
text: `Клиенту отправлено согласование по заказу CD-24${seed.suffix}.`,
|
||||
sentAt: seed.updatedAt,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
internalMessages: [
|
||||
{
|
||||
id: `internal-extra-${seed.suffix}`,
|
||||
senderId: logisticianId,
|
||||
senderName: logisticianId === "u-logistics" ? "Ольга Синицына" : "Павел Миронов",
|
||||
text: `Демо-комментарий для статуса «${seed.status}».`,
|
||||
sentAt: seed.updatedAt,
|
||||
},
|
||||
],
|
||||
deliverySlots: LOGISTICS_OR_DELIVERY_STATUSES.has(seed.status)
|
||||
? [
|
||||
{
|
||||
id: `slot-extra-${seed.suffix}`,
|
||||
date: scheduledDelivery.slice(0, 10),
|
||||
time: index % 2 === 0 ? "Первая половина дня" : "Вторая половина дня",
|
||||
logisticianId,
|
||||
status: getDeliverySlotStatusForDemo(seed.status),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
exception: seed.status === "Проблема доставки" ? "Требуется ручной разбор логистом" : null,
|
||||
};
|
||||
};
|
||||
|
||||
const extraDemoOrders = extraOrderSeeds.map(buildExtraDemoOrder);
|
||||
|
||||
export const demoOrders = [...baseDemoOrders, ...extraDemoOrders];
|
||||
|
||||
export const demoNotifications = [
|
||||
{
|
||||
id: "n-1",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import React from "react";
|
||||
import { getOrderStatusComment } from "../constants/deliveryWorkflow";
|
||||
import { demoNotifications, demoOrders, demoUsers } from "../data/mockAppData";
|
||||
import { fetchUsers } from "../services/supabase/userRepository";
|
||||
import { fetchOrders, enrichOrdersWithUsers } from "../services/supabase/orderRepository";
|
||||
import {
|
||||
reorderDriverDeliveries,
|
||||
} from "../services/driverDeliveries";
|
||||
import {
|
||||
assignDriverToOrder,
|
||||
applyDeliveryReschedule,
|
||||
applyStatusUpdate,
|
||||
appendChatMessageToOrder,
|
||||
|
|
@ -17,37 +20,100 @@ import {
|
|||
filterOrdersByView,
|
||||
updateOrderDetails,
|
||||
} from "../services/orderService";
|
||||
import { getOrderAgingState } from "../services/orderViews";
|
||||
import { groupOrdersIntoDeliverySets, DELIVERY_SET_BUCKET_LABELS } from "../services/deliverySetViews";
|
||||
import { hasSupabaseConfig } from "../supabaseClient";
|
||||
|
||||
const cloneLiveUsers = (users) => (Array.isArray(users) ? users.map((user) => ({ ...user })) : []);
|
||||
|
||||
export const useOrders = (currentUser) => {
|
||||
const [orders, setOrders] = React.useState(() => cloneOrders(demoOrders));
|
||||
const [orders, setOrders] = React.useState(() =>
|
||||
hasSupabaseConfig ? [] : cloneOrders(demoOrders),
|
||||
);
|
||||
const [users, setUsers] = React.useState(() =>
|
||||
hasSupabaseConfig ? [] : cloneLiveUsers(demoUsers),
|
||||
);
|
||||
const [filters, setFilters] = React.useState({
|
||||
query: "",
|
||||
status: "all",
|
||||
stage: "all",
|
||||
ownerRole: "all",
|
||||
agingState: "all",
|
||||
managerId: "all",
|
||||
logisticianId: "all",
|
||||
messenger: "all",
|
||||
});
|
||||
const [selectedOrderId, setSelectedOrderId] = React.useState(demoOrders[0]?.id ?? null);
|
||||
const [selectedOrderId, setSelectedOrderId] = React.useState(() =>
|
||||
hasSupabaseConfig ? null : demoOrders[0]?.id ?? null,
|
||||
);
|
||||
const [notifications, setNotifications] = React.useState(demoNotifications);
|
||||
const [isSupabaseBacked, setIsSupabaseBacked] = React.useState(false);
|
||||
const [isLoading, setIsLoading] = React.useState(hasSupabaseConfig);
|
||||
const [loadError, setLoadError] = React.useState("");
|
||||
|
||||
const visibleOrders = React.useMemo(() => {
|
||||
return orders.filter((order) => {
|
||||
if (currentUser?.role === "manager" && order.managerId !== currentUser.id) {
|
||||
return false;
|
||||
}
|
||||
if (currentUser?.role === "logistician" && !order.logisticianIds.includes(currentUser.id)) {
|
||||
return false;
|
||||
}
|
||||
if (currentUser?.role === "driver" && order.assignedDriverId !== currentUser.id) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [currentUser, orders]);
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const filteredOrders = React.useMemo(() => {
|
||||
return filterOrdersByView({ orders, currentUser, filters }).filteredOrders;
|
||||
const loadLiveData = async () => {
|
||||
if (!hasSupabaseConfig) {
|
||||
setOrders([]);
|
||||
setUsers([]);
|
||||
setIsSupabaseBacked(false);
|
||||
setIsLoading(false);
|
||||
setLoadError("");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setLoadError("");
|
||||
|
||||
const [usersResult, ordersResult] = await Promise.all([fetchUsers(), fetchOrders()]);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (usersResult.error || ordersResult.error) {
|
||||
const error = usersResult.error || ordersResult.error;
|
||||
setLoadError(error?.message || "Не удалось загрузить данные Supabase");
|
||||
setOrders([]);
|
||||
setUsers([]);
|
||||
setIsSupabaseBacked(false);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const liveUsers = usersResult.data || [];
|
||||
const liveOrders = enrichOrdersWithUsers(ordersResult.data || [], liveUsers);
|
||||
|
||||
setUsers(liveUsers);
|
||||
setOrders(liveOrders);
|
||||
setIsSupabaseBacked(true);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
loadLiveData();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!orders.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedOrderId || !orders.some((order) => order.id === selectedOrderId)) {
|
||||
setSelectedOrderId(orders[0].id);
|
||||
}
|
||||
}, [orders, selectedOrderId]);
|
||||
|
||||
const orderView = React.useMemo(() => {
|
||||
return filterOrdersByView({ orders, currentUser, filters });
|
||||
}, [currentUser, filters, orders]);
|
||||
const visibleOrders = orderView.visibleOrders;
|
||||
const filteredOrders = orderView.filteredOrders;
|
||||
|
||||
const selectedOrder =
|
||||
filteredOrders.find((order) => order.id === selectedOrderId) ||
|
||||
|
|
@ -55,6 +121,8 @@ export const useOrders = (currentUser) => {
|
|||
filteredOrders[0] ||
|
||||
null;
|
||||
|
||||
const userMap = React.useMemo(() => new Map(users.map((user) => [user.id, user])), [users]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedOrder && filteredOrders[0]) {
|
||||
setSelectedOrderId(filteredOrders[0].id);
|
||||
|
|
@ -164,8 +232,25 @@ export const useOrders = (currentUser) => {
|
|||
[updateOrder],
|
||||
);
|
||||
|
||||
const assignDriver = React.useCallback(
|
||||
({ orderId, driverId, actorName }) => {
|
||||
updateOrder(
|
||||
orderId,
|
||||
(order) => assignDriverToOrder(order, driverId, actorName),
|
||||
(order) => ({
|
||||
id: `notification-${Date.now()}`,
|
||||
type: "success",
|
||||
title: driverId ? "Водитель назначен" : "Водитель снят",
|
||||
description: `${order.orderNumber}: карточка доставки обновлена`,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[updateOrder],
|
||||
);
|
||||
|
||||
const autoAssignLogisticians = React.useCallback(() => {
|
||||
const logisticians = demoUsers.filter((user) => user.role === "logistician");
|
||||
const sourceUsers = users.length ? users : demoUsers;
|
||||
const logisticians = sourceUsers.filter((user) => user.role === "logistician");
|
||||
setOrders((current) => autoAssignOrders(current, logisticians));
|
||||
appendNotification({
|
||||
id: `notification-${Date.now()}`,
|
||||
|
|
@ -173,7 +258,7 @@ export const useOrders = (currentUser) => {
|
|||
title: "Автораспределение выполнено",
|
||||
description: `Заказы распределены между ${logisticians.length || 0} логистами`,
|
||||
});
|
||||
}, [appendNotification]);
|
||||
}, [appendNotification, users]);
|
||||
|
||||
const saveOrderDetails = React.useCallback(
|
||||
({ orderId, payload, actorName }) => {
|
||||
|
|
@ -193,7 +278,8 @@ export const useOrders = (currentUser) => {
|
|||
|
||||
const createOrder = React.useCallback(
|
||||
({ payload, actorName }) => {
|
||||
const logisticians = demoUsers.filter((user) => user.role === "logistician");
|
||||
const sourceUsers = users.length ? users : demoUsers;
|
||||
const logisticians = sourceUsers.filter((user) => user.role === "logistician");
|
||||
const nextOrder = createOrderRecord({
|
||||
payload,
|
||||
actorName,
|
||||
|
|
@ -209,7 +295,7 @@ export const useOrders = (currentUser) => {
|
|||
description: `${nextOrder.orderNumber}: заказ создан и ожидает подтверждения`,
|
||||
});
|
||||
},
|
||||
[appendNotification],
|
||||
[appendNotification, users],
|
||||
);
|
||||
|
||||
const saveDriverRouteOrder = React.useCallback(
|
||||
|
|
@ -229,6 +315,40 @@ export const useOrders = (currentUser) => {
|
|||
return buildMetrics(visibleOrders);
|
||||
}, [visibleOrders]);
|
||||
|
||||
const agingAlerts = React.useMemo(() => {
|
||||
return visibleOrders
|
||||
.map((order) => ({
|
||||
order,
|
||||
...getOrderAgingState(order),
|
||||
}))
|
||||
.filter(({ agingState }) => agingState === "warning" || agingState === "critical")
|
||||
.sort((left, right) => right.statusAgeHours - left.statusAgeHours);
|
||||
}, [visibleOrders]);
|
||||
|
||||
const agingSummary = React.useMemo(() => {
|
||||
return {
|
||||
warning: agingAlerts.filter((item) => item.agingState === "warning").length,
|
||||
critical: agingAlerts.filter((item) => item.agingState === "critical").length,
|
||||
};
|
||||
}, [agingAlerts]);
|
||||
|
||||
const deliverySetBuckets = React.useMemo(() => {
|
||||
const sets = groupOrdersIntoDeliverySets(visibleOrders);
|
||||
const buckets = {};
|
||||
for (const bucketKey of Object.keys(DELIVERY_SET_BUCKET_LABELS)) {
|
||||
buckets[bucketKey] = [];
|
||||
}
|
||||
for (const set of sets) {
|
||||
const bucketKey = set.status || "approaching";
|
||||
if (buckets[bucketKey]) {
|
||||
buckets[bucketKey].push(set);
|
||||
} else {
|
||||
buckets.approaching.push(set);
|
||||
}
|
||||
}
|
||||
return buckets;
|
||||
}, [visibleOrders]);
|
||||
|
||||
return {
|
||||
orders: filteredOrders,
|
||||
allOrders: visibleOrders,
|
||||
|
|
@ -238,15 +358,25 @@ export const useOrders = (currentUser) => {
|
|||
filters,
|
||||
setFilters,
|
||||
notifications,
|
||||
users,
|
||||
userMap,
|
||||
isSupabaseBacked,
|
||||
isLoading,
|
||||
loadError,
|
||||
pushNotification: appendNotification,
|
||||
updateStatus,
|
||||
addChatMessage,
|
||||
addInternalMessage,
|
||||
addOrderNote,
|
||||
assignDriver,
|
||||
reassignDelivery,
|
||||
autoAssignLogisticians,
|
||||
saveOrderDetails,
|
||||
createOrder,
|
||||
saveDriverRouteOrder,
|
||||
metrics,
|
||||
agingAlerts,
|
||||
agingSummary,
|
||||
deliverySetBuckets,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,9 +15,9 @@ export const AppShell = ({
|
|||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div className="min-h-screen px-4 py-5 md:px-6 md:py-8">
|
||||
<div className="mx-auto grid max-w-[1540px] gap-5 xl:grid-cols-[220px_1fr] xl:gap-8">
|
||||
<Panel className="flex h-fit flex-col gap-5 p-4">
|
||||
<div className="min-h-screen px-3 py-4 sm:px-4 md:px-6 md:py-8">
|
||||
<div className="mx-auto max-w-[1540px] space-y-4 xl:grid xl:grid-cols-[220px_1fr] xl:gap-8 xl:space-y-0">
|
||||
<Panel className="hidden h-fit flex-col gap-5 p-4 xl:flex">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-[var(--color-text-muted)]">
|
||||
Панель
|
||||
|
|
@ -51,8 +51,28 @@ export const AppShell = ({
|
|||
</div>
|
||||
</Panel>
|
||||
|
||||
<div className="space-y-6 xl:space-y-8">
|
||||
<Panel className="p-4 md:p-5">
|
||||
<div className="min-w-0 space-y-5 pb-24 xl:space-y-8 xl:pb-0">
|
||||
<Panel className="p-4 xl:hidden">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
||||
Рабочая область
|
||||
</p>
|
||||
<h2 className="mt-2 truncate text-xl font-semibold">{sectionMeta?.label || "Панель"}</h2>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
{user.name} · {ROLE_LABELS[user.role]}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<Button size="sm" variant="ghost" onClick={onSignOut}>
|
||||
Выйти
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel className="hidden p-4 md:p-5 xl:block">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
||||
|
|
@ -78,6 +98,27 @@ export const AppShell = ({
|
|||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-[var(--color-border)] bg-[rgba(244,247,245,0.94)] px-3 py-3 backdrop-blur xl:hidden">
|
||||
<div className="mx-auto flex max-w-[1540px] gap-2 overflow-x-auto">
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
className={[
|
||||
"flex min-w-[120px] flex-1 items-center justify-center gap-2 rounded-[18px] px-3 py-3 text-sm transition",
|
||||
activeSection === item.key
|
||||
? "bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
|
||||
: "bg-[var(--color-surface)] text-[var(--color-text-muted)]",
|
||||
].join(" ")}
|
||||
onClick={() => onSectionChange(item.key)}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate font-medium">{item.label}</span>
|
||||
{item.badge ? <Badge tone={activeSection === item.key ? "neutral" : "accent"}>{item.badge}</Badge> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,23 +5,26 @@ import { ROLE_LABELS, ROLE_PERMISSIONS } from "../constants/roles";
|
|||
import {
|
||||
DRIVER_STATUSES,
|
||||
getOrderStatusComment,
|
||||
getStatusStageKey,
|
||||
LOGISTICS_STATUSES,
|
||||
PRODUCTION_STATUSES,
|
||||
} from "../constants/deliveryWorkflow";
|
||||
import { AuditPanel } from "../components/admin/AuditPanel";
|
||||
import { UserDirectoryPanel } from "../components/admin/UserDirectoryPanel";
|
||||
import { UserOnboardingPanel } from "../components/admin/UserOnboardingPanel";
|
||||
import { DeliverySetDetailPanel } from "../components/logistics/DeliverySetDetailPanel";
|
||||
import { DriverDeliveryDetail } from "../components/driver/DriverDeliveryDetail";
|
||||
import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
|
||||
import { KpiCard } from "../components/dashboard/KpiCard";
|
||||
import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard";
|
||||
import { PwaDemoPanel } from "../components/dashboard/PwaDemoPanel";
|
||||
import { ProductionQueuePanel } from "../components/dashboard/ProductionQueuePanel";
|
||||
import { RoleWorkspacePanel } from "../components/dashboard/RoleWorkspacePanel";
|
||||
import { BotControlPanel } from "../components/logistics/BotControlPanel";
|
||||
import { OrdersCalendarView } from "../components/orders/OrdersCalendarView";
|
||||
import { OrderDetailPanel } from "../components/orders/OrderDetailPanel";
|
||||
import { OrderEditorPanel } from "../components/orders/OrderEditorPanel";
|
||||
import { OrderFilters } from "../components/orders/OrderFilters";
|
||||
import { OrdersKanbanBoard } from "../components/orders/OrdersKanbanBoard";
|
||||
import { OrdersTable } from "../components/orders/OrdersTable";
|
||||
import { Badge } from "../components/UI/Badge";
|
||||
import { Button } from "../components/UI/Button";
|
||||
|
|
@ -37,8 +40,15 @@ import {
|
|||
filterDriverDeliveries,
|
||||
getDeliveryDay,
|
||||
} from "../services/driverDeliveries";
|
||||
import { buildKanbanColumns, filterArchiveOrders, filterRegistryOrders } from "../services/orderViews";
|
||||
import {
|
||||
buildKanbanColumns,
|
||||
filterArchiveOrders,
|
||||
filterKanbanColumnsByStage,
|
||||
filterRegistryOrders,
|
||||
} from "../services/orderViews";
|
||||
import { getKanbanDropResolution } from "../services/orderService";
|
||||
import { formatDateTime } from "../utils/formatters";
|
||||
import { resolveDraggedOrderId } from "../components/orders/ordersKanbanDrag";
|
||||
|
||||
export const DashboardPage = () => {
|
||||
const { user, signOut } = useAuth();
|
||||
|
|
@ -51,10 +61,13 @@ export const DashboardPage = () => {
|
|||
const [isOrderWorkspaceExpanded, setIsOrderWorkspaceExpanded] = React.useState(false);
|
||||
const [dragOrderId, setDragOrderId] = React.useState(null);
|
||||
const [dropColumnKey, setDropColumnKey] = React.useState(null);
|
||||
const [kanbanNotice, setKanbanNotice] = React.useState(null);
|
||||
const [kanbanMode, setKanbanMode] = React.useState("by_stage");
|
||||
const [kanbanDepartmentFilter, setKanbanDepartmentFilter] = React.useState("all");
|
||||
const [kanbanSort, setKanbanSort] = React.useState("updated_desc");
|
||||
const [showCompletedInRegistry, setShowCompletedInRegistry] = React.useState(false);
|
||||
const [showCompletedInKanban, setShowCompletedInKanban] = React.useState(false);
|
||||
const [isCreateOrderModalOpen, setIsCreateOrderModalOpen] = React.useState(false);
|
||||
const [selectedDeliverySet, setSelectedDeliverySet] = React.useState(null);
|
||||
const [driverFilters, setDriverFilters] = React.useState({
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
|
|
@ -72,16 +85,23 @@ export const DashboardPage = () => {
|
|||
filters,
|
||||
setFilters,
|
||||
notifications,
|
||||
pushNotification,
|
||||
updateStatus,
|
||||
addChatMessage,
|
||||
addInternalMessage,
|
||||
addOrderNote,
|
||||
assignDriver,
|
||||
reassignDelivery,
|
||||
autoAssignLogisticians,
|
||||
saveOrderDetails,
|
||||
createOrder,
|
||||
saveDriverRouteOrder,
|
||||
metrics,
|
||||
agingAlerts,
|
||||
agingSummary,
|
||||
deliverySetBuckets,
|
||||
users,
|
||||
isSupabaseBacked,
|
||||
isLoading,
|
||||
loadError,
|
||||
} = useOrders(user);
|
||||
|
||||
const canManageLogistics = userRole === "logistician" || userRole === "admin";
|
||||
|
|
@ -219,11 +239,56 @@ export const DashboardPage = () => {
|
|||
setIsOrderModalOpen(true);
|
||||
};
|
||||
|
||||
const handleKanbanDrop = (column) => {
|
||||
if (!dragOrderId) {
|
||||
const handleKanbanDrop = (event, column) => {
|
||||
const droppedOrderId = resolveDraggedOrderId(event, dragOrderId);
|
||||
|
||||
if (!droppedOrderId) {
|
||||
return;
|
||||
}
|
||||
updateStatus(dragOrderId, column.dropStatus, user.name);
|
||||
|
||||
const draggedOrder = allOrders.find((order) => order.id === droppedOrderId);
|
||||
|
||||
if (!draggedOrder) {
|
||||
setDragOrderId(null);
|
||||
setDropColumnKey(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const isSameColumn =
|
||||
(kanbanMode === "by_stage" && getStatusStageKey(draggedOrder.status) === column.key) ||
|
||||
(kanbanMode === "by_status" && draggedOrder.status === column.key);
|
||||
|
||||
if (isSameColumn) {
|
||||
setDragOrderId(null);
|
||||
setDropColumnKey(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const { nextStatus, reason } = getKanbanDropResolution({
|
||||
order: draggedOrder,
|
||||
column,
|
||||
role: user.role,
|
||||
});
|
||||
|
||||
if (!nextStatus || nextStatus === draggedOrder.status) {
|
||||
setKanbanNotice({
|
||||
tone: "warning",
|
||||
title: "Перенос недоступен",
|
||||
description: `${draggedOrder.orderNumber}: ${reason || `для роли ${ROLE_LABELS[user.role]} этот переход сейчас недоступен`}`,
|
||||
});
|
||||
pushNotification({
|
||||
id: `notification-${Date.now()}`,
|
||||
type: "warning",
|
||||
title: "Перенос недоступен",
|
||||
description: `${draggedOrder.orderNumber}: ${reason || `для роли ${ROLE_LABELS[user.role]} этот переход сейчас недоступен`}`,
|
||||
});
|
||||
setDragOrderId(null);
|
||||
setDropColumnKey(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setKanbanNotice(null);
|
||||
updateStatus(droppedOrderId, nextStatus, user.name);
|
||||
setDragOrderId(null);
|
||||
setDropColumnKey(null);
|
||||
};
|
||||
|
|
@ -267,9 +332,29 @@ export const DashboardPage = () => {
|
|||
return sortableOrders.sort(sorters[kanbanSort] || sorters.updated_desc);
|
||||
}, [kanbanSort, orders]);
|
||||
const kanbanColumns = React.useMemo(
|
||||
() => buildKanbanColumns(sortedKanbanOrders, { includeCompleted: showCompletedInKanban }),
|
||||
[showCompletedInKanban, sortedKanbanOrders],
|
||||
() =>
|
||||
buildKanbanColumns(sortedKanbanOrders, {
|
||||
includeCompleted: showCompletedInKanban,
|
||||
mode: kanbanMode,
|
||||
}),
|
||||
[kanbanMode, showCompletedInKanban, sortedKanbanOrders],
|
||||
);
|
||||
const visibleKanbanColumns = React.useMemo(
|
||||
() => filterKanbanColumnsByStage(kanbanColumns, kanbanDepartmentFilter),
|
||||
[kanbanColumns, kanbanDepartmentFilter],
|
||||
);
|
||||
const eventFeed = React.useMemo(() => {
|
||||
const agingEvents = agingAlerts.slice(0, 4).map((alert) => ({
|
||||
id: `aging-${alert.order.id}`,
|
||||
title:
|
||||
alert.agingState === "critical"
|
||||
? `Просрочен ${alert.order.orderNumber}`
|
||||
: `Требует внимания ${alert.order.orderNumber}`,
|
||||
description: `${alert.order.customer.name}: ${alert.order.status} · ${alert.statusAgeLabel}`,
|
||||
}));
|
||||
|
||||
return [...agingEvents, ...notifications].slice(0, 8);
|
||||
}, [agingAlerts, notifications]);
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
|
|
@ -310,6 +395,7 @@ export const DashboardPage = () => {
|
|||
order={order}
|
||||
currentUser={user}
|
||||
onStatusChange={(nextStatus) => updateStatus(order.id, nextStatus, user.name)}
|
||||
onAssignDriver={(driverId) => assignDriver({ orderId: order.id, driverId, actorName: user.name })}
|
||||
onClientMessage={(text) =>
|
||||
addChatMessage(order.id, {
|
||||
sender: "client",
|
||||
|
|
@ -319,6 +405,7 @@ export const DashboardPage = () => {
|
|||
}
|
||||
onInternalMessage={(message) => addInternalMessage(order.id, message)}
|
||||
onOrderNote={(note) => addOrderNote(order.id, note)}
|
||||
users={users}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -390,8 +477,27 @@ export const DashboardPage = () => {
|
|||
<KpiCard label="Проблемные" value={metrics.exceptions} hint="Нужна ручная реакция" />
|
||||
</section>
|
||||
|
||||
{agingAlerts.length ? (
|
||||
<Panel className="flex flex-wrap items-center justify-between gap-4 p-5">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Контроль зависших заказов</h3>
|
||||
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
|
||||
Смотрите на заказы, которые слишком долго находятся в одном статусе.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{agingSummary.warning ? (
|
||||
<Badge tone="warning">Требуют внимания: {agingSummary.warning}</Badge>
|
||||
) : null}
|
||||
{agingSummary.critical ? (
|
||||
<Badge tone="danger">Просрочены: {agingSummary.critical}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
|
||||
<RoleWorkspacePanel role={user.role} />
|
||||
<RoleWorkspacePanel role={user.role} deliverySetBuckets={deliverySetBuckets} />
|
||||
<Panel className="space-y-5 p-6">
|
||||
<h3 className="text-lg font-semibold">Оперативные действия</h3>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
|
|
@ -428,7 +534,7 @@ export const DashboardPage = () => {
|
|||
<Panel className="p-6">
|
||||
<h3 className="text-lg font-semibold">Последние события</h3>
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{notifications.map((notification) => (
|
||||
{eventFeed.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-5"
|
||||
|
|
@ -483,7 +589,7 @@ export const DashboardPage = () => {
|
|||
<div>
|
||||
<h3 className="text-lg font-semibold">Реестр заказов</h3>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
Основная таблица для ежедневной работы. Создание заказа вынесено в отдельное действие.
|
||||
Основная таблица для ежедневной работы. Данные сюда поступают только из 1С.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
|
@ -494,12 +600,10 @@ export const DashboardPage = () => {
|
|||
>
|
||||
{showCompletedInRegistry ? "Скрыть завершённые" : "Показать завершённые"}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setIsCreateOrderModalOpen(true)}>
|
||||
Добавить заказ
|
||||
</Button>
|
||||
<Badge tone="neutral">Импорт из 1С</Badge>
|
||||
</div>
|
||||
</Panel>
|
||||
<OrderFilters filters={filters} setFilters={setFilters} />
|
||||
<OrderFilters filters={filters} setFilters={setFilters} users={users} />
|
||||
|
||||
{isOrderWorkspaceExpanded ? (
|
||||
renderOrderWorkspace(selectedOrder, true)
|
||||
|
|
@ -508,6 +612,7 @@ export const DashboardPage = () => {
|
|||
orders={registryOrders}
|
||||
selectedOrderId={selectedOrderId}
|
||||
onOpenOrder={openOrderModal}
|
||||
users={users}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -519,13 +624,13 @@ export const DashboardPage = () => {
|
|||
|
||||
{ordersViewTab === "kanban" ? (
|
||||
<div className="space-y-4">
|
||||
<OrderFilters filters={filters} setFilters={setFilters} />
|
||||
<OrderFilters filters={filters} setFilters={setFilters} users={users} />
|
||||
|
||||
<Panel className="p-4">
|
||||
<div className="grid gap-3 md:grid-cols-[1fr_260px_auto] md:items-center">
|
||||
<div className="grid gap-3 xl:grid-cols-[1.2fr_220px_220px_auto] xl:items-center">
|
||||
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
Канбан показывает только отфильтрованные заказы. Карточки можно перетаскивать
|
||||
между столбцами, исключения вынесены отдельно, а завершённые скрыты по умолчанию.
|
||||
Канбан показывает только отфильтрованные заказы. Можно переключать вид по этапам
|
||||
и по статусам, а цвет карточки показывает, чья сейчас зона ответственности.
|
||||
</p>
|
||||
<Select value={kanbanSort} onChange={(event) => setKanbanSort(event.target.value)}>
|
||||
<option value="updated_desc">Сначала недавно обновлённые</option>
|
||||
|
|
@ -541,65 +646,62 @@ export const DashboardPage = () => {
|
|||
>
|
||||
{showCompletedInKanban ? "Скрыть завершённые" : "Показать завершённые"}
|
||||
</Button>
|
||||
<div className="flex flex-wrap justify-start gap-2 xl:justify-end">
|
||||
{agingSummary.warning ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={filters.agingState === "warning" ? "secondary" : "ghost"}
|
||||
onClick={() =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
agingState: current.agingState === "warning" ? "all" : "warning",
|
||||
}))
|
||||
}
|
||||
>
|
||||
Требуют внимания: {agingSummary.warning}
|
||||
</Button>
|
||||
) : null}
|
||||
{agingSummary.critical ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={filters.agingState === "critical" ? "secondary" : "ghost"}
|
||||
onClick={() =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
agingState: current.agingState === "critical" ? "all" : "critical",
|
||||
}))
|
||||
}
|
||||
>
|
||||
Просрочены: {agingSummary.critical}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<div
|
||||
className={[
|
||||
"grid gap-3",
|
||||
showCompletedInKanban ? "xl:grid-cols-5" : "xl:grid-cols-4",
|
||||
].join(" ")}
|
||||
>
|
||||
{kanbanColumns.map((column) => (
|
||||
<Panel key={column.key} className="rounded-[20px] p-3">
|
||||
<div className="mb-3 flex items-center justify-between gap-3 px-1">
|
||||
<h3 className="text-sm font-semibold text-[var(--color-text)]">{column.title}</h3>
|
||||
<span className="text-sm text-[var(--color-text-muted)]">{column.items.length}</span>
|
||||
</div>
|
||||
<div
|
||||
className={[
|
||||
"min-h-[280px] space-y-2 rounded-[16px] border border-dashed p-2 transition",
|
||||
dropColumnKey === column.key
|
||||
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
|
||||
: "border-[var(--color-border)] bg-[var(--color-surface-strong)]",
|
||||
].join(" ")}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setDropColumnKey(column.key);
|
||||
<OrdersKanbanBoard
|
||||
columns={visibleKanbanColumns}
|
||||
currentMode={kanbanMode}
|
||||
departmentFilter={kanbanDepartmentFilter}
|
||||
notice={kanbanNotice}
|
||||
onDepartmentFilterChange={setKanbanDepartmentFilter}
|
||||
onModeChange={setKanbanMode}
|
||||
onOpenOrder={openOrderModal}
|
||||
onDragStart={(orderId) => {
|
||||
setKanbanNotice(null);
|
||||
setDragOrderId(orderId);
|
||||
}}
|
||||
onDragLeave={() =>
|
||||
setDropColumnKey((current) => (current === column.key ? null : current))
|
||||
}
|
||||
onDrop={() => handleKanbanDrop(column)}
|
||||
>
|
||||
{column.items.map((order) => (
|
||||
<div
|
||||
key={order.id}
|
||||
className="w-full cursor-grab rounded-[14px] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 text-left text-[var(--color-text)] shadow-sm transition hover:border-[var(--color-accent)] hover:bg-[var(--color-accent-soft)] active:cursor-grabbing"
|
||||
onClick={() => openOrderModal(order.id)}
|
||||
onDragStart={() => setDragOrderId(order.id)}
|
||||
onDragEnd={() => {
|
||||
setDragOrderId(null);
|
||||
setDropColumnKey(null);
|
||||
}}
|
||||
draggable
|
||||
>
|
||||
<div className="font-medium">{order.orderNumber}</div>
|
||||
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
{order.customer.name}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-[var(--color-text-muted)]">
|
||||
{(order.items?.[0] || "Состав не указан").split("|")[0].trim()}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-[var(--color-text-muted)]">
|
||||
{order.status}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
))}
|
||||
</div>
|
||||
onDragOverColumn={setDropColumnKey}
|
||||
onDragLeaveColumn={(columnKey) =>
|
||||
setDropColumnKey((current) => (current === columnKey ? null : current))
|
||||
}
|
||||
onDropColumn={handleKanbanDrop}
|
||||
dropColumnKey={dropColumnKey}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
@ -652,6 +754,7 @@ export const DashboardPage = () => {
|
|||
orders={archiveOrders}
|
||||
selectedOrderId={selectedOrderId}
|
||||
onOpenOrder={openOrderModal}
|
||||
users={users}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -668,6 +771,7 @@ export const DashboardPage = () => {
|
|||
orders={productionOrders}
|
||||
selectedOrderId={selectedOrderId}
|
||||
onOpenOrder={openOrderModal}
|
||||
users={users}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -676,6 +780,18 @@ export const DashboardPage = () => {
|
|||
if (activeSection === "logistics") {
|
||||
return (
|
||||
<div className="space-y-6 xl:space-y-8">
|
||||
<LogisticsReadinessBoard
|
||||
deliverySetBuckets={deliverySetBuckets}
|
||||
onSelectSet={(set) => setSelectedDeliverySet(set)}
|
||||
/>
|
||||
|
||||
{selectedDeliverySet ? (
|
||||
<DeliverySetDetailPanel
|
||||
deliverySet={selectedDeliverySet}
|
||||
onClose={() => setSelectedDeliverySet(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-[1.08fr_0.92fr]">
|
||||
<BotControlPanel
|
||||
selectedOrder={selectedLogisticsOrder}
|
||||
|
|
@ -706,10 +822,10 @@ export const DashboardPage = () => {
|
|||
</section>
|
||||
|
||||
<Panel className="p-6">
|
||||
<h3 className="text-lg font-semibold">Готовые заказы и слоты</h3>
|
||||
<h3 className="text-lg font-semibold">Заказы в логистике</h3>
|
||||
<p className="mt-3 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
Для логистики здесь остаются только заказы, где нужно согласование, перенос или
|
||||
ручная реакция на исключения.
|
||||
Отдельные заказы из наборов доставки, где нужно согласование, перенос или ручная
|
||||
реакция на исключения.
|
||||
</p>
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{logisticsOrders.map((order) => (
|
||||
|
|
@ -808,7 +924,7 @@ export const DashboardPage = () => {
|
|||
<div className="space-y-6 xl:space-y-8">
|
||||
<div className="grid gap-6 xl:grid-cols-[1fr_1fr]">
|
||||
<AuditPanel order={selectedOrder} />
|
||||
<UserDirectoryPanel currentUser={user} />
|
||||
<UserDirectoryPanel currentUser={user} users={users} />
|
||||
</div>
|
||||
<UserOnboardingPanel />
|
||||
</div>
|
||||
|
|
@ -824,6 +940,21 @@ export const DashboardPage = () => {
|
|||
onSectionChange={setActiveSection}
|
||||
sectionMeta={sectionMeta}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Panel className="border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
|
||||
Загружаем данные из Supabase...
|
||||
</Panel>
|
||||
) : null}
|
||||
{isSupabaseBacked ? (
|
||||
<Panel className="border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
|
||||
Данные загружены из Supabase, живой контур активен.
|
||||
</Panel>
|
||||
) : null}
|
||||
{loadError ? (
|
||||
<Panel className="border border-dashed border-[var(--color-danger)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-danger)]">
|
||||
{loadError}
|
||||
</Panel>
|
||||
) : null}
|
||||
{renderActiveTab()}
|
||||
<Modal isOpen={isOrderModalOpen} onClose={() => setIsOrderModalOpen(false)}>
|
||||
{user.role === "driver" ? (
|
||||
|
|
@ -844,6 +975,7 @@ export const DashboardPage = () => {
|
|||
onStatusChange={(nextStatus) =>
|
||||
selectedOrder && updateStatus(selectedOrder.id, nextStatus, user.name)
|
||||
}
|
||||
users={users}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -875,33 +1007,6 @@ export const DashboardPage = () => {
|
|||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
<Modal
|
||||
isOpen={isCreateOrderModalOpen}
|
||||
onClose={() => setIsCreateOrderModalOpen(false)}
|
||||
className="max-w-[920px]"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">Создать заказ</h3>
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
Короткая форма создания без перегруза основного раздела заказов.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={() => setIsCreateOrderModalOpen(false)}>
|
||||
Закрыть
|
||||
</Button>
|
||||
</div>
|
||||
<OrderEditorPanel
|
||||
currentUser={user}
|
||||
selectedOrder={null}
|
||||
onCreateOrder={createOrder}
|
||||
onSaveOrder={saveOrderDetails}
|
||||
createOnly
|
||||
onDone={() => setIsCreateOrderModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</AppShell>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export const LoginPage = () => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-4 py-10">
|
||||
<div className="flex min-h-screen items-center justify-center px-3 py-6 sm:px-4 sm:py-10">
|
||||
<OtpLoginForm
|
||||
email={email}
|
||||
setEmail={setEmail}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from "react";
|
||||
import { Navigate, createBrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import { ClientDeliveryPage } from "./pages/ClientDeliveryPage";
|
||||
import { DashboardPage } from "./pages/DashboardPage";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||
|
|
@ -18,6 +19,10 @@ export const router = createBrowserRouter([
|
|||
path: "login",
|
||||
element: <LoginPage />,
|
||||
},
|
||||
{
|
||||
path: "delivery/:token",
|
||||
element: <ClientDeliveryPage />,
|
||||
},
|
||||
{
|
||||
path: "dashboard",
|
||||
element: <DashboardPage />,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,18 @@
|
|||
import { demoOrders } from "../data/mockAppData";
|
||||
import {
|
||||
getAvailableTransitionsByRole,
|
||||
getStatusOwnerRole,
|
||||
getStatusStageKey,
|
||||
LOGISTICS_STATUSES,
|
||||
PRODUCTION_STATUSES,
|
||||
} from "../constants/deliveryWorkflow";
|
||||
import { getOrderAgingState } from "./orderViews";
|
||||
|
||||
const DELIVERY_HANDOFF_STATUS = "Назначен водитель";
|
||||
const DELIVERY_HANDOFF_REQUIRES_DRIVER_MESSAGE =
|
||||
"Сначала назначьте водителя, потом заказ можно передать в доставку.";
|
||||
const DELIVERY_HANDOFF_ROLE_MESSAGE =
|
||||
"Логист может передать заказ в доставку только через статус «Назначен водитель».";
|
||||
|
||||
export const cloneOrders = (orders = demoOrders) =>
|
||||
orders.map((order) => ({
|
||||
|
|
@ -40,32 +49,61 @@ export const buildSearchBlob = (order) => {
|
|||
.toLowerCase();
|
||||
};
|
||||
|
||||
export const filterOrdersByView = ({ orders, currentUser, filters }) => {
|
||||
export const filterOrdersByView = ({ orders, currentUser, filters, now }) => {
|
||||
const normalizedFilters = {
|
||||
query: "",
|
||||
status: "all",
|
||||
stage: "all",
|
||||
ownerRole: "all",
|
||||
agingState: "all",
|
||||
managerId: "all",
|
||||
logisticianId: "all",
|
||||
messenger: "all",
|
||||
...filters,
|
||||
};
|
||||
const visibleOrders = orders.filter((order) => {
|
||||
if (currentUser?.role === "manager" && order.managerId !== currentUser.id) {
|
||||
return false;
|
||||
}
|
||||
if (currentUser?.role === "logistician" && !order.logisticianIds.includes(currentUser.id)) {
|
||||
return false;
|
||||
}
|
||||
if (currentUser?.role === "driver" && order.assignedDriverId !== currentUser.id) {
|
||||
if (!currentUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentUser.role === "manager" || currentUser.role === "admin") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return getStatusOwnerRole(order.status) === currentUser.role;
|
||||
});
|
||||
|
||||
const normalizedQuery = filters.query.trim().toLowerCase();
|
||||
const normalizedQuery = normalizedFilters.query.trim().toLowerCase();
|
||||
const filteredOrders = visibleOrders.filter((order) => {
|
||||
if (filters.status !== "all" && order.status !== filters.status) {
|
||||
const stageKey = getStatusStageKey(order.status);
|
||||
const ownerRole = getStatusOwnerRole(order.status);
|
||||
const { agingState } = getOrderAgingState(order, { now });
|
||||
|
||||
if (normalizedFilters.status !== "all" && order.status !== normalizedFilters.status) {
|
||||
return false;
|
||||
}
|
||||
if (filters.managerId !== "all" && order.managerId !== filters.managerId) {
|
||||
if (normalizedFilters.stage !== "all" && stageKey !== normalizedFilters.stage) {
|
||||
return false;
|
||||
}
|
||||
if (filters.logisticianId !== "all" && !order.logisticianIds.includes(filters.logisticianId)) {
|
||||
if (normalizedFilters.ownerRole !== "all" && ownerRole !== normalizedFilters.ownerRole) {
|
||||
return false;
|
||||
}
|
||||
if (filters.messenger !== "all" && order.customer.messenger !== filters.messenger) {
|
||||
if (normalizedFilters.agingState !== "all" && agingState !== normalizedFilters.agingState) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedFilters.managerId !== "all" && order.managerId !== normalizedFilters.managerId) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
normalizedFilters.logisticianId !== "all" &&
|
||||
!order.logisticianIds.includes(normalizedFilters.logisticianId)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
normalizedFilters.messenger !== "all" &&
|
||||
order.customer.messenger !== normalizedFilters.messenger
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedQuery && !buildSearchBlob(order).includes(normalizedQuery)) {
|
||||
|
|
@ -92,7 +130,68 @@ export const appendHistoryEntry = (order, entry) => ({
|
|||
export const getAvailableTransitions = ({ status, role }) =>
|
||||
getAvailableTransitionsByRole({ status, role });
|
||||
|
||||
export const getAvailableTransitionsForOrder = ({ order, role }) =>
|
||||
getAvailableTransitionsByRole({ status: order.status, role }).filter((nextStatus) => {
|
||||
if (nextStatus === DELIVERY_HANDOFF_STATUS && !order.assignedDriverId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
export const getTransitionBlockedReason = ({ order, nextStatus, role }) => {
|
||||
if (nextStatus === DELIVERY_HANDOFF_STATUS && !order.assignedDriverId) {
|
||||
return DELIVERY_HANDOFF_REQUIRES_DRIVER_MESSAGE;
|
||||
}
|
||||
|
||||
const roleTransitions = getAvailableTransitionsByRole({ status: order.status, role });
|
||||
|
||||
if (!roleTransitions.includes(nextStatus) && role === "logistician" && ["Загружен", "В пути", "Доставлен"].includes(nextStatus)) {
|
||||
return DELIVERY_HANDOFF_ROLE_MESSAGE;
|
||||
}
|
||||
|
||||
return "Этот переход сейчас недоступен для вашей роли.";
|
||||
};
|
||||
|
||||
export const getKanbanDropResolution = ({ order, column, role }) => {
|
||||
const allowedStatuses = getAvailableTransitionsForOrder({ order, role });
|
||||
const nextStatus = column.statuses.find((status) => allowedStatuses.includes(status));
|
||||
|
||||
if (nextStatus) {
|
||||
return {
|
||||
nextStatus,
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (column.statuses.includes(DELIVERY_HANDOFF_STATUS)) {
|
||||
return {
|
||||
nextStatus: null,
|
||||
reason: getTransitionBlockedReason({
|
||||
order,
|
||||
nextStatus: DELIVERY_HANDOFF_STATUS,
|
||||
role,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
nextStatus: null,
|
||||
reason: getTransitionBlockedReason({
|
||||
order,
|
||||
nextStatus: column.statuses[0],
|
||||
role,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const deriveAgreementStatus = (currentOrder, nextStatus) => {
|
||||
if (nextStatus === "Ожидает ответа клиента") {
|
||||
return currentOrder.deliveryAgreementStatus === "Подтверждено клиентом"
|
||||
? "Отправлено клиенту"
|
||||
: "Ожидание ответа";
|
||||
}
|
||||
|
||||
if (nextStatus === "Ожидает согласования доставки") {
|
||||
return currentOrder.deliveryAgreementStatus === "Подтверждено клиентом"
|
||||
? "Отправлено клиенту"
|
||||
|
|
@ -103,6 +202,16 @@ const deriveAgreementStatus = (currentOrder, nextStatus) => {
|
|||
return "Подтверждено клиентом";
|
||||
}
|
||||
|
||||
if (nextStatus === "Передан логисту") {
|
||||
return currentOrder.deliveryAgreementStatus === "Подтверждено клиентом"
|
||||
? "Перенос запрошен"
|
||||
: "Нет ответа";
|
||||
}
|
||||
|
||||
if (nextStatus === "Платное хранение") {
|
||||
return "Нет ответа";
|
||||
}
|
||||
|
||||
if (nextStatus === "Проблема доставки") {
|
||||
return currentOrder.deliveryAgreementStatus === "Не начато"
|
||||
? "Ошибка отправки"
|
||||
|
|
@ -129,6 +238,25 @@ export const applyStatusUpdate = (order, nextStatus, actorName) => {
|
|||
);
|
||||
};
|
||||
|
||||
export const assignDriverToOrder = (order, driverId, actorName) => {
|
||||
const normalizedDriverId = driverId || null;
|
||||
|
||||
return appendHistoryEntry(
|
||||
{
|
||||
...order,
|
||||
assignedDriverId: normalizedDriverId,
|
||||
driverRouteOrder: normalizedDriverId ? order.driverRouteOrder : null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
action: "Назначение водителя",
|
||||
oldStatus: order.status,
|
||||
newStatus: order.status,
|
||||
userName: actorName,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const appendChatMessageToOrder = (order, message) => ({
|
||||
...order,
|
||||
updatedAt: new Date().toISOString(),
|
||||
|
|
|
|||
|
|
@ -1,34 +1,40 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { getStatusOwnerRole } from "../constants/deliveryWorkflow";
|
||||
import { demoOrders, demoUsers } from "../data/mockAppData";
|
||||
import {
|
||||
assignDriverToOrder,
|
||||
applyStatusUpdate,
|
||||
autoAssignOrders,
|
||||
buildMetrics,
|
||||
cloneOrders,
|
||||
createOrderRecord,
|
||||
filterOrdersByView,
|
||||
getKanbanDropResolution,
|
||||
getAvailableTransitions,
|
||||
} from "./orderService";
|
||||
|
||||
describe("orderService", () => {
|
||||
it("filters manager orders to owned items only", () => {
|
||||
it("lets manager see the whole pipeline", () => {
|
||||
const result = filterOrdersByView({
|
||||
orders: cloneOrders(demoOrders),
|
||||
currentUser: demoUsers[0],
|
||||
filters: {
|
||||
query: "",
|
||||
status: "all",
|
||||
stage: "all",
|
||||
ownerRole: "all",
|
||||
agingState: "all",
|
||||
managerId: "all",
|
||||
logisticianId: "all",
|
||||
messenger: "all",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.visibleOrders).toHaveLength(7);
|
||||
expect(result.filteredOrders.every((order) => order.managerId === demoUsers[0].id)).toBe(true);
|
||||
expect(result.visibleOrders).toHaveLength(demoOrders.length);
|
||||
expect(result.filteredOrders).toHaveLength(demoOrders.length);
|
||||
});
|
||||
|
||||
it("filters driver orders to assigned deliveries only", () => {
|
||||
it("shows employees all orders in their responsibility zone", () => {
|
||||
const driver = demoUsers.find((user) => user.role === "driver");
|
||||
const result = filterOrdersByView({
|
||||
orders: cloneOrders(demoOrders),
|
||||
|
|
@ -36,14 +42,40 @@ describe("orderService", () => {
|
|||
filters: {
|
||||
query: "",
|
||||
status: "all",
|
||||
stage: "all",
|
||||
ownerRole: "all",
|
||||
agingState: "all",
|
||||
managerId: "all",
|
||||
logisticianId: "all",
|
||||
messenger: "all",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.visibleOrders).toHaveLength(4);
|
||||
expect(result.visibleOrders.every((order) => order.assignedDriverId === driver.id)).toBe(true);
|
||||
const expectedDriverOrders = demoOrders.filter((order) => getStatusOwnerRole(order.status) === "driver");
|
||||
|
||||
expect(result.visibleOrders).toHaveLength(expectedDriverOrders.length);
|
||||
expect(result.visibleOrders.every((order) => getStatusOwnerRole(order.status) === "driver")).toBe(true);
|
||||
});
|
||||
|
||||
it("finds orders by customer phone, stage, owner role and aging state", () => {
|
||||
const result = filterOrdersByView({
|
||||
orders: cloneOrders(demoOrders),
|
||||
currentUser: demoUsers[0],
|
||||
filters: {
|
||||
query: "+7 978 000-12-31",
|
||||
status: "all",
|
||||
stage: "logistics",
|
||||
ownerRole: "logistician",
|
||||
agingState: "warning",
|
||||
managerId: "all",
|
||||
logisticianId: "all",
|
||||
messenger: "all",
|
||||
},
|
||||
now: "2026-03-15T12:00:00Z",
|
||||
});
|
||||
|
||||
expect(result.filteredOrders).toHaveLength(1);
|
||||
expect(result.filteredOrders[0].orderNumber).toBe("CD-240031");
|
||||
});
|
||||
|
||||
it("updates status and prepends history record", () => {
|
||||
|
|
@ -54,6 +86,14 @@ describe("orderService", () => {
|
|||
expect(nextOrder.history[0].newStatus).toBe("Доставка согласована");
|
||||
});
|
||||
|
||||
it("assigns a driver and records the action in history", () => {
|
||||
const nextOrder = assignDriverToOrder(demoOrders[0], "u-driver", "Ольга Синицына");
|
||||
|
||||
expect(nextOrder.assignedDriverId).toBe("u-driver");
|
||||
expect(nextOrder.history[0].action).toBe("Назначение водителя");
|
||||
expect(nextOrder.history[0].userName).toBe("Ольга Синицына");
|
||||
});
|
||||
|
||||
it("returns role-scoped transitions for logistics stage", () => {
|
||||
const transitions = getAvailableTransitions({
|
||||
status: "Ожидает согласования доставки",
|
||||
|
|
@ -63,6 +103,50 @@ describe("orderService", () => {
|
|||
expect(transitions).toEqual(["Доставка согласована", "Проблема доставки", "Отменён"]);
|
||||
});
|
||||
|
||||
it("lets manager move orders across the full workflow from the shared board", () => {
|
||||
const transitions = getAvailableTransitions({
|
||||
status: "Ожидает согласования доставки",
|
||||
role: "manager",
|
||||
});
|
||||
|
||||
expect(transitions).toEqual(["Доставка согласована", "Проблема доставки", "Отменён"]);
|
||||
});
|
||||
|
||||
it("lets logisticians hand orders off into delivery only when a driver is assigned", () => {
|
||||
const assignedOrder = {
|
||||
...demoOrders.find((order) => order.status === "Доставка согласована"),
|
||||
assignedDriverId: "u-driver",
|
||||
};
|
||||
const blockedOrder = {
|
||||
...assignedOrder,
|
||||
assignedDriverId: null,
|
||||
};
|
||||
const deliveryColumn = {
|
||||
key: "delivery",
|
||||
title: "Доставка",
|
||||
statuses: ["Назначен водитель", "Загружен", "В пути"],
|
||||
};
|
||||
|
||||
expect(
|
||||
getKanbanDropResolution({
|
||||
order: assignedOrder,
|
||||
column: deliveryColumn,
|
||||
role: "logistician",
|
||||
}),
|
||||
).toMatchObject({ nextStatus: "Назначен водитель" });
|
||||
|
||||
expect(
|
||||
getKanbanDropResolution({
|
||||
order: blockedOrder,
|
||||
column: deliveryColumn,
|
||||
role: "logistician",
|
||||
}),
|
||||
).toMatchObject({
|
||||
nextStatus: null,
|
||||
reason: "Сначала назначьте водителя, потом заказ можно передать в доставку.",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a new order draft with assigned logistician", () => {
|
||||
const logisticians = demoUsers.filter((user) => user.role === "logistician");
|
||||
const order = createOrderRecord({
|
||||
|
|
@ -99,9 +183,25 @@ describe("orderService", () => {
|
|||
it("builds dashboard metrics", () => {
|
||||
const metrics = buildMetrics(demoOrders);
|
||||
|
||||
expect(metrics.total).toBe(7);
|
||||
expect(metrics.readyToShip).toBe(1);
|
||||
expect(metrics.awaitingDeliveryCoordination).toBe(1);
|
||||
expect(metrics.exceptions).toBe(1);
|
||||
expect(metrics.total).toBe(demoOrders.length);
|
||||
expect(metrics.readyToShip).toBe(demoOrders.filter((order) => order.status === "Готов к отгрузке").length);
|
||||
expect(metrics.awaitingDeliveryCoordination).toBe(
|
||||
demoOrders.filter((order) => order.status === "Ожидает согласования доставки").length,
|
||||
);
|
||||
expect(metrics.exceptions).toBe(demoOrders.filter((order) => order.status === "Проблема доставки").length);
|
||||
});
|
||||
|
||||
it("ships with an expanded demo dataset across the workflow", () => {
|
||||
expect(demoOrders.length).toBeGreaterThanOrEqual(32);
|
||||
expect(new Set(demoOrders.map((order) => order.status)).size).toBeGreaterThanOrEqual(10);
|
||||
expect(
|
||||
demoOrders.some((order) => order.status === "Доставка согласована" && order.assignedDriverId),
|
||||
).toBe(true);
|
||||
expect(
|
||||
demoOrders.every((order) => Number.isFinite(new Date(order.createdAt).getTime())),
|
||||
).toBe(true);
|
||||
expect(
|
||||
demoOrders.every((order) => Number.isFinite(new Date(order.scheduledDelivery).getTime())),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,45 +1,41 @@
|
|||
import {
|
||||
ORDER_STATUSES,
|
||||
WORKFLOW_STAGES,
|
||||
getStatusOwnerRole,
|
||||
getStatusSla,
|
||||
getStatusStageKey,
|
||||
getStatusStageLabel,
|
||||
} from "../constants/deliveryWorkflow";
|
||||
|
||||
const COMPLETED_ORDER_STATUSES = new Set(["Доставлен", "Закрыт", "Отменён"]);
|
||||
const ARCHIVE_ONLY_ORDER_STATUSES = new Set(["Закрыт", "Отменён"]);
|
||||
const EXCEPTION_ORDER_STATUSES = new Set(["Проблема доставки"]);
|
||||
|
||||
const KANBAN_BASE_COLUMNS = [
|
||||
{
|
||||
key: "new",
|
||||
title: "В работе",
|
||||
statuses: [
|
||||
"Новый",
|
||||
"Требует уточнения",
|
||||
"Подтверждён менеджером",
|
||||
"В очереди производства",
|
||||
"В производстве",
|
||||
"Готов к отгрузке",
|
||||
],
|
||||
dropStatus: "В производстве",
|
||||
},
|
||||
{
|
||||
key: "coordination",
|
||||
title: "Согласование доставки",
|
||||
statuses: ["Ожидает согласования доставки", "Доставка согласована", "Назначен водитель"],
|
||||
dropStatus: "Ожидает согласования доставки",
|
||||
},
|
||||
{
|
||||
key: "execution",
|
||||
title: "Исполнение",
|
||||
statuses: ["Загружен", "В пути"],
|
||||
dropStatus: "В пути",
|
||||
},
|
||||
{
|
||||
key: "exceptions",
|
||||
title: "Исключения",
|
||||
statuses: ["Проблема доставки"],
|
||||
dropStatus: "Проблема доставки",
|
||||
},
|
||||
];
|
||||
const formatAgeLabel = (hours) => {
|
||||
if (!Number.isFinite(hours) || hours < 1) {
|
||||
return "меньше часа";
|
||||
}
|
||||
|
||||
const COMPLETED_COLUMN = {
|
||||
key: "completed",
|
||||
title: "Завершённые",
|
||||
statuses: [...COMPLETED_ORDER_STATUSES],
|
||||
dropStatus: "Закрыт",
|
||||
if (hours < 24) {
|
||||
return `${Math.floor(hours)}ч в статусе`;
|
||||
}
|
||||
|
||||
const days = Math.floor(hours / 24);
|
||||
const remainingHours = Math.floor(hours % 24);
|
||||
|
||||
if (remainingHours === 0) {
|
||||
return `${days}д в статусе`;
|
||||
}
|
||||
|
||||
return `${days}д ${remainingHours}ч в статусе`;
|
||||
};
|
||||
|
||||
const getStatusEnteredAt = (order) => {
|
||||
const matchingEntries = (order.history || [])
|
||||
.filter((entry) => entry.newStatus === order.status)
|
||||
.sort((left, right) => new Date(right.at) - new Date(left.at));
|
||||
|
||||
return matchingEntries[0]?.at || order.updatedAt || order.createdAt || new Date().toISOString();
|
||||
};
|
||||
|
||||
export const isCompletedOrderStatus = (status) => COMPLETED_ORDER_STATUSES.has(status);
|
||||
|
|
@ -52,11 +48,120 @@ export const filterRegistryOrders = (orders, { includeCompleted = false } = {})
|
|||
export const filterArchiveOrders = (orders) =>
|
||||
orders.filter((order) => isCompletedOrderStatus(order.status));
|
||||
|
||||
export const buildKanbanColumns = (orders, { includeCompleted = false } = {}) => {
|
||||
const columns = includeCompleted ? [...KANBAN_BASE_COLUMNS, COMPLETED_COLUMN] : KANBAN_BASE_COLUMNS;
|
||||
export const getOrderAgingState = (order, { now = new Date().toISOString() } = {}) => {
|
||||
const enteredAt = getStatusEnteredAt(order);
|
||||
const statusAgeHours = Math.max(
|
||||
0,
|
||||
(new Date(now).getTime() - new Date(enteredAt).getTime()) / (1000 * 60 * 60),
|
||||
);
|
||||
const { warningAfterHours, criticalAfterHours } = getStatusSla(order.status);
|
||||
|
||||
if (criticalAfterHours !== null && statusAgeHours >= criticalAfterHours) {
|
||||
return {
|
||||
enteredAt,
|
||||
statusAgeHours,
|
||||
statusAgeLabel: formatAgeLabel(statusAgeHours),
|
||||
agingState: "critical",
|
||||
};
|
||||
}
|
||||
|
||||
if (warningAfterHours !== null && statusAgeHours >= warningAfterHours) {
|
||||
return {
|
||||
enteredAt,
|
||||
statusAgeHours,
|
||||
statusAgeLabel: formatAgeLabel(statusAgeHours),
|
||||
agingState: "warning",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enteredAt,
|
||||
statusAgeHours,
|
||||
statusAgeLabel: formatAgeLabel(statusAgeHours),
|
||||
agingState: "normal",
|
||||
};
|
||||
};
|
||||
|
||||
const enrichOrderForKanban = (order, options) => {
|
||||
const aging = getOrderAgingState(order, options);
|
||||
|
||||
return {
|
||||
...order,
|
||||
ownerRole: getStatusOwnerRole(order.status),
|
||||
stageKey: getStatusStageKey(order.status),
|
||||
stageLabel: getStatusStageLabel(order.status),
|
||||
...aging,
|
||||
};
|
||||
};
|
||||
|
||||
const buildStageColumns = (orders, { includeCompleted }) => {
|
||||
const mainStages = WORKFLOW_STAGES.filter((stage) => stage.key !== "completed");
|
||||
const columns = mainStages.map((stage) => ({
|
||||
key: stage.key,
|
||||
title: stage.label,
|
||||
stageKey: stage.key,
|
||||
statuses: ORDER_STATUSES.filter((status) => getStatusStageKey(status) === stage.key),
|
||||
items: orders.filter((order) => order.stageKey === stage.key),
|
||||
}));
|
||||
|
||||
columns.push({
|
||||
key: "delivered",
|
||||
title: "Доставлен",
|
||||
stageKey: "completed",
|
||||
statuses: ["Доставлен"],
|
||||
dropStatus: "Доставлен",
|
||||
items: orders.filter((order) => order.status === "Доставлен"),
|
||||
});
|
||||
|
||||
columns.push({
|
||||
key: "completed",
|
||||
title: "Завершено",
|
||||
stageKey: "completed",
|
||||
statuses: ["Закрыт", "Отменён"],
|
||||
items: includeCompleted
|
||||
? orders.filter((order) => ARCHIVE_ONLY_ORDER_STATUSES.has(order.status))
|
||||
: [],
|
||||
});
|
||||
|
||||
return columns;
|
||||
};
|
||||
|
||||
const buildStatusColumns = (orders, { includeCompleted }) => {
|
||||
const statuses = includeCompleted
|
||||
? ORDER_STATUSES
|
||||
: ORDER_STATUSES.filter((status) => !ARCHIVE_ONLY_ORDER_STATUSES.has(status));
|
||||
|
||||
return statuses.map((status) => ({
|
||||
key: status,
|
||||
title: status,
|
||||
stageKey: getStatusStageKey(status),
|
||||
statuses: [status],
|
||||
dropStatus: status,
|
||||
items: orders.filter((order) => order.status === status),
|
||||
}));
|
||||
};
|
||||
|
||||
export const buildKanbanColumns = (
|
||||
orders,
|
||||
{ includeCompleted = false, mode = "by_stage", now = new Date().toISOString() } = {},
|
||||
) => {
|
||||
const enrichedOrders = orders.map((order) => enrichOrderForKanban(order, { now }));
|
||||
const columns =
|
||||
mode === "by_status"
|
||||
? buildStatusColumns(enrichedOrders, { includeCompleted })
|
||||
: buildStageColumns(enrichedOrders, { includeCompleted });
|
||||
|
||||
return columns.map((column) => ({
|
||||
...column,
|
||||
items: orders.filter((order) => column.statuses.includes(order.status)),
|
||||
warningCount: column.items.filter((item) => item.agingState === "warning").length,
|
||||
criticalCount: column.items.filter((item) => item.agingState === "critical").length,
|
||||
}));
|
||||
};
|
||||
|
||||
export const filterKanbanColumnsByStage = (columns, stageKey = "all") => {
|
||||
if (stageKey === "all") {
|
||||
return columns;
|
||||
}
|
||||
|
||||
return columns.filter((column) => column.stageKey === stageKey);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,17 +1,61 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildKanbanColumns,
|
||||
filterKanbanColumnsByStage,
|
||||
getOrderAgingState,
|
||||
filterArchiveOrders,
|
||||
filterRegistryOrders,
|
||||
isCompletedOrderStatus,
|
||||
} from "./orderViews";
|
||||
|
||||
const baseOrders = [
|
||||
{ id: "1", status: "Новый" },
|
||||
{ id: "2", status: "Ожидает согласования доставки" },
|
||||
{ id: "3", status: "Проблема доставки" },
|
||||
{ id: "4", status: "Закрыт" },
|
||||
{ id: "5", status: "Доставлен" },
|
||||
{
|
||||
id: "1",
|
||||
orderNumber: "CD-1",
|
||||
customer: { name: "Иван Петров" },
|
||||
items: ["Шкаф | 1 шт"],
|
||||
status: "Новый",
|
||||
updatedAt: "2026-03-15T08:00:00Z",
|
||||
history: [{ id: "h1", newStatus: "Новый", at: "2026-03-15T08:00:00Z" }],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
orderNumber: "CD-2",
|
||||
customer: { name: "Мария Соколова" },
|
||||
items: ["Кухня | 1 шт"],
|
||||
status: "Ожидает согласования доставки",
|
||||
updatedAt: "2026-03-13T10:00:00Z",
|
||||
history: [
|
||||
{ id: "h2", newStatus: "Ожидает согласования доставки", at: "2026-03-13T10:00:00Z" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
orderNumber: "CD-3",
|
||||
customer: { name: "Анна Белова" },
|
||||
items: ["Стеклопакет | 2 шт"],
|
||||
status: "Проблема доставки",
|
||||
updatedAt: "2026-03-12T09:00:00Z",
|
||||
history: [{ id: "h3", newStatus: "Проблема доставки", at: "2026-03-12T09:00:00Z" }],
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
orderNumber: "CD-4",
|
||||
customer: { name: "Егор Громов" },
|
||||
items: ["Дверь | 1 шт"],
|
||||
status: "Закрыт",
|
||||
updatedAt: "2026-03-14T09:00:00Z",
|
||||
history: [{ id: "h4", newStatus: "Закрыт", at: "2026-03-14T09:00:00Z" }],
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
orderNumber: "CD-5",
|
||||
customer: { name: "Ольга Светлова" },
|
||||
items: ["Фурнитура | 1 набор"],
|
||||
status: "Доставлен",
|
||||
updatedAt: "2026-03-14T11:00:00Z",
|
||||
history: [{ id: "h5", newStatus: "Доставлен", at: "2026-03-14T11:00:00Z" }],
|
||||
},
|
||||
];
|
||||
|
||||
describe("orderViews", () => {
|
||||
|
|
@ -33,17 +77,92 @@ describe("orderViews", () => {
|
|||
expect(archive.map((order) => order.id)).toEqual(["4", "5"]);
|
||||
});
|
||||
|
||||
it("builds kanban with separate exceptions and optional completed column", () => {
|
||||
const withoutCompleted = buildKanbanColumns(baseOrders, { includeCompleted: false });
|
||||
const withCompleted = buildKanbanColumns(baseOrders, { includeCompleted: true });
|
||||
it("keeps completed drop-zone visible in stage mode and fills it only on demand", () => {
|
||||
const withoutCompleted = buildKanbanColumns(baseOrders, {
|
||||
includeCompleted: false,
|
||||
mode: "by_stage",
|
||||
now: "2026-03-15T12:00:00Z",
|
||||
});
|
||||
const withCompleted = buildKanbanColumns(baseOrders, {
|
||||
includeCompleted: true,
|
||||
mode: "by_stage",
|
||||
now: "2026-03-15T12:00:00Z",
|
||||
});
|
||||
|
||||
expect(withoutCompleted.map((column) => column.key)).toEqual([
|
||||
"new",
|
||||
"coordination",
|
||||
"execution",
|
||||
"exceptions",
|
||||
"manager",
|
||||
"production",
|
||||
"logistics",
|
||||
"delivery",
|
||||
"delivered",
|
||||
"completed",
|
||||
]);
|
||||
expect(withoutCompleted.find((column) => column.key === "exceptions")?.items).toHaveLength(1);
|
||||
expect(withoutCompleted.find((column) => column.key === "logistics")?.items).toHaveLength(2);
|
||||
expect(withoutCompleted.find((column) => column.key === "delivered")?.items).toHaveLength(1);
|
||||
expect(withoutCompleted.find((column) => column.key === "completed")?.items).toHaveLength(0);
|
||||
expect(withCompleted.map((column) => column.key)).toContain("completed");
|
||||
expect(withCompleted.find((column) => column.key === "delivered")?.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("builds status-based kanban columns with per-card metadata", () => {
|
||||
const columns = buildKanbanColumns(baseOrders, {
|
||||
includeCompleted: false,
|
||||
mode: "by_status",
|
||||
now: "2026-03-15T12:00:00Z",
|
||||
});
|
||||
|
||||
expect(columns[0].items[0]).toMatchObject({
|
||||
ownerRole: "manager",
|
||||
stageKey: "manager",
|
||||
stageLabel: "Менеджер",
|
||||
agingState: "normal",
|
||||
});
|
||||
expect(columns.map((column) => column.key)).toContain("Ожидает согласования доставки");
|
||||
expect(columns.map((column) => column.key)).toContain("Доставлен");
|
||||
expect(columns.map((column) => column.key)).not.toContain("Закрыт");
|
||||
});
|
||||
|
||||
it("marks orders as warning or critical based on time in status", () => {
|
||||
expect(
|
||||
getOrderAgingState(baseOrders[0], { now: "2026-03-15T12:00:00Z" }),
|
||||
).toMatchObject({ agingState: "normal" });
|
||||
expect(
|
||||
getOrderAgingState(baseOrders[1], { now: "2026-03-15T12:00:00Z" }),
|
||||
).toMatchObject({ agingState: "warning" });
|
||||
expect(
|
||||
getOrderAgingState(baseOrders[2], { now: "2026-03-15T12:00:00Z" }),
|
||||
).toMatchObject({ agingState: "critical" });
|
||||
});
|
||||
|
||||
it("filters kanban columns by department for stage and status modes", () => {
|
||||
const stageColumns = buildKanbanColumns(baseOrders, {
|
||||
includeCompleted: true,
|
||||
mode: "by_stage",
|
||||
now: "2026-03-15T12:00:00Z",
|
||||
});
|
||||
const statusColumns = buildKanbanColumns(baseOrders, {
|
||||
includeCompleted: true,
|
||||
mode: "by_status",
|
||||
now: "2026-03-15T12:00:00Z",
|
||||
});
|
||||
|
||||
expect(filterKanbanColumnsByStage(stageColumns, "logistics").map((column) => column.key)).toEqual([
|
||||
"logistics",
|
||||
]);
|
||||
expect(
|
||||
filterKanbanColumnsByStage(statusColumns, "logistics").map((column) => column.key),
|
||||
).toEqual([
|
||||
"Ожидает ответа клиента",
|
||||
"Ожидает согласования доставки",
|
||||
"Доставка согласована",
|
||||
"Передан логисту",
|
||||
"Проблема доставки",
|
||||
"Платное хранение",
|
||||
]);
|
||||
expect(filterKanbanColumnsByStage(statusColumns, "delivery").map((column) => column.key)).toEqual([
|
||||
"Назначен водитель",
|
||||
"Загружен",
|
||||
"В пути",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
if (!value) {
|
||||
const date = toValidDate(value);
|
||||
|
||||
if (!date) {
|
||||
return "Не указано";
|
||||
}
|
||||
return format(new Date(value), "dd.MM.yyyy HH:mm");
|
||||
return format(date, "dd.MM.yyyy HH:mm");
|
||||
};
|
||||
|
||||
export const formatDate = (value) => {
|
||||
if (!value) {
|
||||
const date = toValidDate(value);
|
||||
|
||||
if (!date) {
|
||||
return "Не указано";
|
||||
}
|
||||
return format(new Date(value), "dd.MM.yyyy");
|
||||
return format(date, "dd.MM.yyyy");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ curl -X POST \
|
|||
`chat_messages`.
|
||||
|
||||
Если передан `workflowAction=send_delivery_offer`, функция дополнительно переводит заказ в
|
||||
`Ожидает согласования доставки` и выставляет `delivery_agreement_status = 'Отправлено клиенту'`.
|
||||
`Ожидает ответа клиента` и выставляет `delivery_agreement_status = 'Отправлено клиенту'`.
|
||||
|
||||
Ожидаемые переменные:
|
||||
|
||||
|
|
@ -35,3 +35,26 @@ curl -X POST \
|
|||
- `TELEGRAM_BOT_TOKEN`
|
||||
- `VK_BOT_TOKEN`
|
||||
- `MESSENGER_MAX_TOKEN`
|
||||
|
||||
## `create-delivery-invitation`
|
||||
|
||||
Создает или обновляет активное приглашение для публичной клиентской ссылки, сохраняет
|
||||
`delivery_invitations`, обновляет заказ в статус `Ожидает ответа клиента` и возвращает публичный URL.
|
||||
|
||||
## `get-delivery-invitation`
|
||||
|
||||
Возвращает публичное состояние приглашения по токену. Используется страницей клиента для показа
|
||||
актуального статуса заказа.
|
||||
|
||||
## `confirm-delivery-choice`
|
||||
|
||||
Фиксирует выбор времени доставки клиентом, переводит заказ в `Доставка согласована` и создает
|
||||
историю события.
|
||||
|
||||
## `transfer-to-logistics`
|
||||
|
||||
Используется для ручной передачи заказа логисту или перевода в `Платное хранение`.
|
||||
|
||||
## `report-delivery-result`
|
||||
|
||||
Фиксирует итог доставки, включая успешную доставку и проблемные сценарии.
|
||||
|
|
|
|||
|
|
@ -19,9 +19,16 @@ describe("chatbot workflow mapping", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("marks outbound delivery offer as sent to client", () => {
|
||||
it("marks outbound delivery offer as awaiting client response", () => {
|
||||
expect(getOrderUpdateForOutboundDispatch("send_delivery_offer")).toEqual({
|
||||
status: "Ожидает согласования доставки",
|
||||
status: "Ожидает ответа клиента",
|
||||
deliveryAgreementStatus: "Отправлено клиенту",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps reminder dispatch in the same awaiting response state", () => {
|
||||
expect(getOrderUpdateForOutboundDispatch("send_delivery_reminder")).toEqual({
|
||||
status: "Ожидает ответа клиента",
|
||||
deliveryAgreementStatus: "Отправлено клиенту",
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { getOrderUpdateForDeliveryInvitationAction } from "./delivery-invitations.ts";
|
||||
|
||||
export type InboundWorkflowAction =
|
||||
| "confirm_delivery"
|
||||
| "reschedule"
|
||||
|
|
@ -35,10 +37,7 @@ export const getOrderUpdateForOutboundDispatch = (action: OutboundWorkflowAction
|
|||
switch (action) {
|
||||
case "send_delivery_offer":
|
||||
case "send_delivery_reminder":
|
||||
return {
|
||||
status: "Ожидает согласования доставки",
|
||||
deliveryAgreementStatus: "Отправлено клиенту",
|
||||
};
|
||||
return getOrderUpdateForDeliveryInvitationAction(action);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,40 @@ create table if not exists public.error_logs (
|
|||
created_at timestamptz not null default timezone('utc', now())
|
||||
);
|
||||
|
||||
create table if not exists public.delivery_invitations (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
order_id uuid not null references public.orders (id) on delete cascade unique,
|
||||
token_hash text not null unique,
|
||||
state text not null default 'awaiting_choice',
|
||||
order_number text,
|
||||
customer_name text,
|
||||
customer_phone text,
|
||||
customer_messenger text,
|
||||
available_slots text[] not null default array['Первая половина дня', 'Вторая половина дня'],
|
||||
delivery_date date,
|
||||
delivery_time text,
|
||||
sent_at timestamptz,
|
||||
opened_at timestamptz,
|
||||
confirmed_at timestamptz,
|
||||
logistics_transferred_at timestamptz,
|
||||
paid_storage_at timestamptz,
|
||||
delivered_at timestamptz,
|
||||
created_at timestamptz not null default timezone('utc', now()),
|
||||
updated_at timestamptz not null default timezone('utc', now())
|
||||
);
|
||||
|
||||
create table if not exists public.integration_events (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
order_id uuid references public.orders (id) on delete set null,
|
||||
event_type text not null,
|
||||
direction text not null default 'internal',
|
||||
source text not null default 'supabase-function',
|
||||
status text not null default 'success',
|
||||
payload jsonb not null default '{}'::jsonb,
|
||||
error_message text,
|
||||
created_at timestamptz not null default timezone('utc', now())
|
||||
);
|
||||
|
||||
alter table public.orders add column if not exists delivery_agreement_status text not null default 'Не начато';
|
||||
alter table public.orders add column if not exists assigned_driver_id uuid references public.users (id);
|
||||
alter table public.chat_messages drop constraint if exists chat_messages_channel_check;
|
||||
|
|
@ -88,6 +122,22 @@ alter table public.chat_messages
|
|||
add constraint chat_messages_channel_check
|
||||
check (channel in ('telegram', 'vk', 'messenger_max', 'sms', 'email'));
|
||||
|
||||
alter table public.delivery_invitations add column if not exists state text not null default 'awaiting_choice';
|
||||
alter table public.delivery_invitations add column if not exists delivery_date date;
|
||||
alter table public.delivery_invitations add column if not exists delivery_time text;
|
||||
alter table public.delivery_invitations add column if not exists sent_at timestamptz;
|
||||
alter table public.delivery_invitations add column if not exists opened_at timestamptz;
|
||||
alter table public.delivery_invitations add column if not exists confirmed_at timestamptz;
|
||||
alter table public.delivery_invitations add column if not exists logistics_transferred_at timestamptz;
|
||||
alter table public.delivery_invitations add column if not exists paid_storage_at timestamptz;
|
||||
alter table public.delivery_invitations add column if not exists delivered_at timestamptz;
|
||||
alter table public.delivery_invitations add column if not exists updated_at timestamptz not null default timezone('utc', now());
|
||||
alter table public.integration_events add column if not exists direction text not null default 'internal';
|
||||
alter table public.integration_events add column if not exists source text not null default 'supabase-function';
|
||||
alter table public.integration_events add column if not exists status text not null default 'success';
|
||||
alter table public.integration_events add column if not exists payload jsonb not null default '{}'::jsonb;
|
||||
alter table public.integration_events add column if not exists error_message text;
|
||||
|
||||
insert into public.roles (name, permissions)
|
||||
values
|
||||
(
|
||||
|
|
@ -128,6 +178,12 @@ before update on public.orders
|
|||
for each row
|
||||
execute function public.set_updated_at();
|
||||
|
||||
drop trigger if exists delivery_invitations_set_updated_at on public.delivery_invitations;
|
||||
create trigger delivery_invitations_set_updated_at
|
||||
before update on public.delivery_invitations
|
||||
for each row
|
||||
execute function public.set_updated_at();
|
||||
|
||||
create or replace function public.current_role_name()
|
||||
returns text
|
||||
language sql
|
||||
|
|
@ -246,6 +302,11 @@ create index if not exists idx_orders_search on public.orders using gin (
|
|||
create index if not exists idx_chat_messages_search on public.chat_messages using gin (
|
||||
to_tsvector('russian', coalesce(text, ''))
|
||||
);
|
||||
create index if not exists idx_delivery_invitations_order_id on public.delivery_invitations (order_id);
|
||||
create index if not exists idx_delivery_invitations_token_hash on public.delivery_invitations (token_hash);
|
||||
create index if not exists idx_delivery_invitations_state on public.delivery_invitations (state);
|
||||
create index if not exists idx_integration_events_order_id on public.integration_events (order_id, created_at desc);
|
||||
create index if not exists idx_integration_events_event_type on public.integration_events (event_type);
|
||||
|
||||
alter table public.roles enable row level security;
|
||||
alter table public.users enable row level security;
|
||||
|
|
@ -255,6 +316,8 @@ alter table public.order_history enable row level security;
|
|||
alter table public.delivery_slots enable row level security;
|
||||
alter table public.chat_messages enable row level security;
|
||||
alter table public.error_logs enable row level security;
|
||||
alter table public.delivery_invitations enable row level security;
|
||||
alter table public.integration_events enable row level security;
|
||||
|
||||
drop policy if exists "roles admin only" on public.roles;
|
||||
create policy "roles admin only" on public.roles
|
||||
|
|
@ -443,3 +506,15 @@ create policy "error logs admin only" on public.error_logs
|
|||
for all
|
||||
using (public.current_role_name() = 'admin')
|
||||
with check (public.current_role_name() = 'admin');
|
||||
|
||||
drop policy if exists "delivery invitations admin only" on public.delivery_invitations;
|
||||
create policy "delivery invitations admin only" on public.delivery_invitations
|
||||
for all
|
||||
using (public.current_role_name() = 'admin')
|
||||
with check (public.current_role_name() = 'admin');
|
||||
|
||||
drop policy if exists "integration events admin only" on public.integration_events;
|
||||
create policy "integration events admin only" on public.integration_events
|
||||
for all
|
||||
using (public.current_role_name() = 'admin')
|
||||
with check (public.current_role_name() = 'admin');
|
||||
|
|
|
|||
Loading…
Reference in New Issue