Add logistics UI actions and demo login buttons

- Fix bug in transfer-to-logistics: body.note was incorrectly used for delivery_agreement_status
- Add quick actions section in OrderDetailPanel for status changes (visible to role)
- Add driver assignment buttons for logistician/admin
- Add demo login buttons for all roles (works with real Supabase config)
- Add loginAsDemoUser function to AuthContext for quick demo login
This commit is contained in:
Codex 2026-05-06 13:16:22 +03:00
parent 219670583b
commit f2230f3277
8 changed files with 572 additions and 12 deletions

11
.opencode.json Normal file
View File

@ -0,0 +1,11 @@
{
"mcpServers": {
"n8n-mcp": {
"type": "http",
"url": "https://n8n.supersamsev.ru/mcp-server/http",
"headers": {
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMjMyZTZiZC00MzlhLTQwYjMtODgzMi05MzAxZTM3ZjRlMzgiLCJpc3MiOiJuOG4iLCJhdWQiOiJtY3Atc2VydmVyLWFwaSIsImp0aSI6ImE1OTU3YjgzLWExYTUtNDg4OC1iMzUyLWQxM2JhZDEyZDg0NCIsImlhdCI6MTc3Nzg5ODEyN30.wT6z8T9V8gRsS1uUg3HYEFFoS223krEC0KjQPBxlfis"
}
}
}
}

11
.vscode/mcp.json vendored Normal file
View File

@ -0,0 +1,11 @@
{
"servers": {
"n8n-mcp": {
"type": "http",
"url": "https://n8n.supersamsev.ru/mcp-server/http",
"headers": {
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMjMyZTZiZC00MzlhLTQwYjMtODgzMi05MzAxZTM3ZjRlMzgiLCJpc3MiOiJuOG4iLCJhdWQiOiJtY3Atc2VydmVyLWFwaSIsImp0aSI6ImE1OTU3YjgzLWExYTUtNDg4OC1iMzUyLWQxM2JhZDEyZDg0NCIsImlhdCI6MTc3Nzg5ODEyN30.wT6z8T9V8gRsS1uUg3HYEFFoS223krEC0KjQPBxlfis"
}
}
}
}

427
anonymize_xml.py Normal file
View File

@ -0,0 +1,427 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Скрипт для анонимизации персональных данных в XML-файле.
Заменяет ФИО и телефоны на сгенерированные, сохраняя соответствие дублей.
Использование:
python3 anonymize_xml.py <входной_xml> [выходной_xml]
"""
import sys
import random
import xml.etree.ElementTree as ET
from pathlib import Path
# --- Генераторы фейковых данных ---
MALE_SURNAMES = [
"Иванов",
"Смирнов",
"Кузнецов",
"Попов",
"Васильев",
"Петров",
"Соколов",
"Михайлов",
"Новиков",
"Федоров",
"Морозов",
"Волков",
"Алексеев",
"Лебедев",
"Семенов",
"Егоров",
"Павлов",
"Козлов",
"Степанов",
"Николаев",
"Орлов",
"Андреев",
"Макаров",
"Зайцев",
"Соловьев",
"Борисов",
"Яковлев",
"Григорьев",
"Романов",
"Воробьев",
"Антонов",
"Захаров",
"Максимов",
"Фролов",
"Дмитриев",
"Калинин",
"Беляев",
"Гусев",
"Назаров",
"Карпов",
"Афанасьев",
"Тихонов",
"Марков",
"Комаров",
"Шестаков",
"Артемьев",
"Кукушкин",
"Плотников",
"Дорофеев",
]
FEMALE_SURNAMES = [
"Иванова",
"Смирнова",
"Кузнецова",
"Попова",
"Васильева",
"Петрова",
"Соколова",
"Михайлова",
"Новикова",
"Федорова",
"Морозова",
"Волкова",
"Алексеева",
"Лебедева",
"Семенова",
"Егорова",
"Павлова",
"Козлова",
"Степанова",
"Николаева",
"Орлова",
"Андреева",
"Макарова",
"Зайцева",
"Соловьева",
"Борисова",
"Яковлева",
"Григорьева",
"Романова",
"Воробьева",
"Атонова",
"Захарова",
"Максимова",
"Фролова",
"Дмитриева",
"Калинина",
"Беляева",
"Гусева",
"Назарова",
"Карпова",
"Афанасьева",
"Тихонова",
"Маркова",
"Комарова",
"Шестакова",
"Артемьева",
"Кукушкина",
"Плотникова",
"Дорофеева",
]
MALE_NAMES = [
"Александр",
"Дмитрий",
"Максим",
"Сергей",
"Андрей",
"Алексей",
"Артем",
"Илья",
"Кирилл",
"Михаил",
"Никита",
"Матвей",
"Роман",
"Егор",
"Арсений",
"Иван",
"Денис",
"Евгений",
"Даниил",
"Тимофей",
"Владимир",
"Павел",
"Руслан",
"Марк",
"Константин",
"Тимур",
"Олег",
"Ярослав",
"Степан",
"Глеб",
"Николай",
"Петр",
"Виктор",
"Семен",
"Леонид",
"Григорий",
"Богдан",
"Захар",
"Тарас",
"Федор",
]
FEMALE_NAMES = [
"Анна",
"Мария",
"Елена",
"Алиса",
"Виктория",
"Полина",
"Александра",
"Елизавета",
"Дарья",
"Анастасия",
"Ксения",
"Валерия",
"Варвара",
"Вероника",
"София",
"Арина",
"Марина",
"Юлия",
"Татьяна",
"Наталья",
"Ольга",
"Светлана",
"Ирина",
"Екатерина",
"Оксана",
"Алина",
"Кристина",
"Людмила",
"Галина",
"Надежда",
"Любовь",
"Маргарита",
"Софья",
"Яна",
"Диана",
"Алла",
"Инна",
"Эмилия",
"Лариса",
"Злата",
]
MALE_PATRONYMICS = [
"Александрович",
"Дмитриевич",
"Максимович",
"Сергеевич",
"Андреевич",
"Алексеевич",
"Артемович",
"Ильич",
"Кириллович",
"Михайлович",
"Никитич",
"Матвеевич",
"Романович",
"Егорович",
"Арсеньевич",
"Иванович",
"Денисович",
"Евгеньевич",
"Даниилович",
"Тимофеевич",
"Владимирович",
"Павлович",
"Константинович",
"Тимурович",
"Олегович",
"Ярославович",
"Степанович",
"Глебович",
"Николаевич",
"Петрович",
"Викторович",
"Семенович",
"Леонидович",
"Григорьевич",
"Богданович",
"Захарович",
"Тарасович",
"Федорович",
]
FEMALE_PATRONYMICS = [
"Александровна",
"Дмитриевна",
"Максимовна",
"Сергеевна",
"Андреевна",
"Алексеевна",
"Артемовна",
"Ильинична",
"Кирилловна",
"Михайловна",
"Никитична",
"Матвеевна",
"Романовна",
"Егоровна",
"Арсеньевна",
"Ивановна",
"Денисовна",
"Евгеньевна",
"Данииловна",
"Тимофеевна",
"Владимировна",
"Павловна",
"Константиновна",
"Тимуровна",
"Олеговна",
"Ярославовна",
"Степановна",
"Глебовна",
"Николаевна",
"Петровна",
"Викторовна",
"Семеновна",
"Леонидовна",
"Григорьевна",
"Богдановна",
"Захаровна",
"Тарасовна",
"Федоровна",
]
def generate_fake_fio():
"""Генерирует случайное русское ФИО с инициалами или полным отчеством."""
if random.choice([True, False]):
# Полное ФИО
surname = random.choice(MALE_SURNAMES + FEMALE_SURNAMES)
# Попытка согласовать род фамилии и имя/отчество
if surname.endswith("а") and not surname.endswith(
("ина", "ова", "ева", "ына", "ая")
):
# Может быть мужская фамилия типа Буря, но проще — выбираем случайно
is_female = random.choice([True, False])
else:
is_female = surname.endswith(
("ина", "ова", "ева", "ына", "ая", "ска", "цка", "ёва")
)
if is_female:
name = random.choice(FEMALE_NAMES)
patronymic = random.choice(FEMALE_PATRONYMICS)
else:
name = random.choice(MALE_NAMES)
patronymic = random.choice(MALE_PATRONYMICS)
return f"{surname} {name} {patronymic}"
else:
# Фамилия с инициалами
surname = random.choice(MALE_SURNAMES + FEMALE_SURNAMES)
is_female = surname.endswith(
("ина", "ова", "ева", "ына", "ая", "ска", "цка", "ёва")
)
if is_female:
name_initial = random.choice(FEMALE_NAMES)[0]
patronymic_initial = random.choice(FEMALE_PATRONYMICS)[0]
else:
name_initial = random.choice(MALE_NAMES)[0]
patronymic_initial = random.choice(MALE_PATRONYMICS)[0]
return f"{surname} {name_initial}.{patronymic_initial}."
def generate_fake_phone():
"""Генерирует случайный российский мобильный номер (10 цифр, начинается с 9)."""
return f"9{random.randint(100000000, 999999999)}"
def anonymize_xml(input_path: str, output_path: str = None):
input_file = Path(input_path)
if not input_file.exists():
print(f"Ошибка: файл не найден: {input_path}")
sys.exit(1)
# Определяем выходной путь
if output_path is None:
output_file = input_file.with_suffix(".anonymized.xml")
else:
output_file = Path(output_path)
# Читаем исходный файл
# Пробуем cp1251 (WINDOWS-1251), если ошибка — utf-8
raw_bytes = input_file.read_bytes()
try:
text = raw_bytes.decode("cp1251")
except UnicodeDecodeError:
text = raw_bytes.decode("utf-8", errors="replace")
# Парсим XML
try:
root = ET.fromstring(text)
except ET.ParseError as e:
print(f"Ошибка парсинга XML: {e}")
sys.exit(1)
# Собираем уникальные значения
fio_map = {}
tel_map = {}
fio_values = set()
tel_values = set()
for account in root.findall("Account"):
fio = account.get("fio")
tel = account.get("tel")
if fio:
fio_values.add(fio)
if tel:
tel_values.add(tel)
# Генерируем маппинги
random.seed(42) # Для воспроизводимости (можно убрать или изменить)
for fio in sorted(fio_values):
fio_map[fio] = generate_fake_fio()
for tel in sorted(tel_values):
tel_map[tel] = generate_fake_phone()
# Заменяем в дереве
for account in root.findall("Account"):
original_fio = account.get("fio")
original_tel = account.get("tel")
if original_fio and original_fio in fio_map:
account.set("fio", fio_map[original_fio])
if original_tel and original_tel in tel_map:
account.set("tel", tel_map[original_tel])
# Сохраняем результат
# Используем собственную сериализацию для сохранения формата
# ElementTree.write может слегка изменять формат, поэтому используем tostring
tree = ET.ElementTree(root)
# Добавляем XML declaration
xml_bytes = ET.tostring(root, encoding="unicode")
result = f'<?xml version="1.0" encoding="UTF-8"?>\n{xml_bytes}'
output_file.write_text(result, encoding="utf-8")
print(f"Готово! Результат сохранён в: {output_file.resolve()}")
print(f" Уникальных ФИО заменено: {len(fio_map)}")
print(f" Уникальных телефонов заменено: {len(tel_map)}")
# Печатаем часть маппинга для информации
print("\nПримеры замен (первые 10):")
count = 0
for k, v in fio_map.items():
if count >= 10:
break
tel_k = list(tel_map.keys())[count] if count < len(tel_map) else None
tel_v = tel_map[tel_k] if tel_k else ""
print(f" ФИО: '{k}' -> '{v}'")
if tel_k:
print(f" Тел: '{tel_k}' -> '{tel_v}'")
count += 1
if __name__ == "__main__":
if len(sys.argv) < 2:
script_name = Path(sys.argv[0]).name
print(f"Использование: python3 {script_name}ходной_xml> [выходной_xml]")
print(f"Пример: python3 {script_name} data.xml data_anon.xml")
sys.exit(1)
input_path = sys.argv[1]
output_path = sys.argv[2] if len(sys.argv) > 2 else None
anonymize_xml(input_path, output_path)

View File

@ -1,8 +1,9 @@
import React from "react";
import { getDeliveryAgreementComment, getOrderStatusComment, getStatusTone } from "../../constants/deliveryWorkflow";
import { getAvailableTransitionsByRole, getDeliveryAgreementComment, getOrderStatusComment, getStatusTone } from "../../constants/deliveryWorkflow";
import { demoUsers } from "../../data/mockAppData";
import { formatDateTime } from "../../utils/formatters";
import { Badge } from "../UI/Badge";
import { Button } from "../UI/Button";
import { Panel } from "../UI/Panel";
const getUsers = (users) => (Array.isArray(users) && users.length ? users : demoUsers);
@ -31,7 +32,7 @@ const splitItem = (item) => {
return { name: "Позиция", quantity: "" };
};
export const OrderDetailPanel = ({ order, users }) => {
export const OrderDetailPanel = ({ order, users, currentUser, onStatusChange, onAssignDriver }) => {
if (!order) {
return (
<Panel className="flex min-h-[460px] items-center justify-center">
@ -42,6 +43,10 @@ export const OrderDetailPanel = ({ order, users }) => {
const orderItems = Array.isArray(order.items) ? order.items.map(splitItem) : [];
const orderHistory = Array.isArray(order.history) ? order.history : [];
const role = currentUser?.role;
const availableTransitions = role ? getAvailableTransitionsByRole({ status: order.status, role }) : [];
const drivers = (Array.isArray(users) && users.length ? users : demoUsers).filter((u) => u.role === "driver");
const canAssignDriver = role === "logistician" || role === "admin";
return (
<div className="space-y-5">
@ -169,6 +174,45 @@ export const OrderDetailPanel = ({ order, users }) => {
</Panel>
) : null}
{availableTransitions.length ? (
<Panel className="space-y-4 p-5">
<strong>Действия</strong>
<div className="flex flex-wrap gap-2">
{availableTransitions.map((status) => (
<Button
key={status}
variant={status === "Проблема доставки" || status === "Платное хранение" || status === "Отменён" ? "ghost" : "secondary"}
onClick={() => onStatusChange?.(status)}
>
{status}
</Button>
))}
</div>
</Panel>
) : null}
{canAssignDriver ? (
<Panel className="space-y-4 p-5">
<strong>Назначить водителя</strong>
<div className="flex flex-wrap gap-2">
{drivers.map((driver) => (
<Button
key={driver.id}
variant={order.assignedDriverId === driver.id ? "primary" : "secondary"}
onClick={() => onAssignDriver?.({ orderId: order.id, driverId: driver.id, actorName: currentUser.name })}
>
{driver.name}
</Button>
))}
{order.assignedDriverId ? (
<Button variant="ghost" onClick={() => onAssignDriver?.({ orderId: order.id, driverId: null, actorName: currentUser.name })}>
Снять водителя
</Button>
) : null}
</div>
</Panel>
) : null}
{orderHistory.length ? (
<Panel className="space-y-3 p-5">
<strong>История</strong>

View File

@ -209,6 +209,10 @@ export const AuthProvider = ({ children }) => {
requestOtp,
verifyOtp,
signOut,
loginAsDemoUser: (demoUser) => {
setUser(demoUser);
setAuthError("");
},
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

View File

@ -51,6 +51,7 @@ export const DashboardPage = () => {
filters,
setFilters,
updateStatus,
assignDriver,
users,
isLoading,
loadError,
@ -204,7 +205,15 @@ export const DashboardPage = () => {
Закрыть
</Button>
</div>
<OrderDetailPanel order={selectedOrder} users={users} />
<OrderDetailPanel
order={selectedOrder}
users={users}
currentUser={user}
onStatusChange={(nextStatus) =>
selectedOrder && updateStatus(selectedOrder.id, nextStatus, user.name)
}
onAssignDriver={assignDriver}
/>
</div>
)}
</Modal>

View File

@ -1,10 +1,15 @@
import React from "react";
import { Navigate } from "react-router-dom";
import { OtpLoginForm } from "../components/auth/OtpLoginForm";
import { ROLE_LABELS } from "../constants/roles";
import { useAuth } from "../context/AuthContext";
import { demoUsers } from "../data/mockAppData";
import { Button } from "../components/UI/Button";
import { OtpLoginForm } from "../components/auth/OtpLoginForm";
const DEMO_ROLE_ORDER = ["logistician", "driver", "manager", "admin"];
export const LoginPage = () => {
const { user, isOtpSent, isLoading, authError, requestOtp, verifyOtp } = useAuth();
const { user, isOtpSent, isLoading, authError, isDemoMode, requestOtp, verifyOtp, loginAsDemoUser } = useAuth();
const [email, setEmail] = React.useState("");
const [otp, setOtp] = React.useState("");
const [error, setError] = React.useState("");
@ -29,12 +34,37 @@ export const LoginPage = () => {
setError("");
};
const handleDemoLogin = (role) => {
const demoUser = demoUsers.find((u) => u.role === role);
if (!demoUser) {
return;
}
if (isDemoMode) {
setEmail(demoUser.email);
requestOtp({ email: demoUser.email, roleHint: role }).then((requestResponse) => {
if (!requestResponse.success) {
setError(requestResponse.error?.message || "Ошибка демо-входа");
return;
}
verifyOtp({ email: demoUser.email, otp: "000000" }).then((verifyResponse) => {
if (!verifyResponse.success) {
setError(verifyResponse.error?.message || "Ошибка демо-входа");
}
});
});
} else {
loginAsDemoUser(demoUser);
}
};
if (user) {
return <Navigate to="/dashboard" replace />;
}
return (
<div className="flex min-h-screen items-center justify-center px-3 py-6 sm:px-4 sm:py-10">
<div className="flex min-h-screen flex-col items-center justify-center gap-6 px-3 py-6 sm:px-4 sm:py-10">
<OtpLoginForm
email={email}
setEmail={setEmail}
@ -46,6 +76,29 @@ export const LoginPage = () => {
onVerifyOtp={handleVerifyOtp}
error={displayError}
/>
{(isDemoMode || import.meta.env.DEV) ? (
<div className="w-full max-w-md space-y-3">
<p className="text-center text-sm text-[var(--color-text-muted)]">
{isDemoMode ? "Демо-режим — войдите под любой ролью" : "Быстрый вход (только для разработки)"}
</p>
<div className="flex flex-wrap justify-center gap-2">
{DEMO_ROLE_ORDER.map((role) => {
const demoUser = demoUsers.find((u) => u.role === role);
return (
<Button
key={role}
variant="secondary"
onClick={() => handleDemoLogin(role)}
disabled={isLoading}
>
{ROLE_LABELS[role]}
</Button>
);
})}
</div>
</div>
) : null}
</div>
);
};
};

View File

@ -79,11 +79,11 @@ Deno.serve(async (request) => {
throw invitationError;
}
const { error: updateError } = await supabase
const { error: updateError } = await supabase
.from("orders")
.update({
status: orderUpdate?.status,
delivery_agreement_status: body.note || orderUpdate?.deliveryAgreementStatus,
delivery_agreement_status: orderUpdate?.deliveryAgreementStatus,
})
.eq("id", body.orderId);
@ -91,15 +91,16 @@ Deno.serve(async (request) => {
throw updateError;
}
const { error: historyError } = await supabase.from("order_history").insert({
const { error: historyError } = await supabase.from("order_history").insert({
order_id: body.orderId,
action: targetStatus === "Платное хранение" ? "Перевод на платное хранение" : "Передача заказа логисту",
old_status: currentOrder.status,
new_status: orderUpdate?.status,
metadata: {
old_delivery_agreement_status: currentOrder.delivery_agreement_status,
new_delivery_agreement_status: body.note || orderUpdate?.deliveryAgreementStatus,
new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus,
reason: body.reason || null,
note: body.note || null,
target_status: targetStatus,
},
});
@ -126,7 +127,7 @@ Deno.serve(async (request) => {
ok: true,
orderId: body.orderId,
status: orderUpdate?.status,
deliveryAgreementStatus: body.note || orderUpdate?.deliveryAgreementStatus,
deliveryAgreementStatus: orderUpdate?.deliveryAgreementStatus,
},
200,
corsHeaders,