Compare commits
10 Commits
219670583b
...
488e478841
| Author | SHA1 | Date |
|---|---|---|
|
|
488e478841 | |
|
|
496abc761b | |
|
|
2367ff2554 | |
|
|
fc74241378 | |
|
|
eeb2620547 | |
|
|
7e399f2517 | |
|
|
633973142d | |
|
|
b79de7afba | |
|
|
684424dd25 | |
|
|
f2230f3277 |
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"n8n-mcp": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://n8n.supersamsev.ru/mcp-server/http",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMjMyZTZiZC00MzlhLTQwYjMtODgzMi05MzAxZTM3ZjRlMzgiLCJpc3MiOiJuOG4iLCJhdWQiOiJtY3Atc2VydmVyLWFwaSIsImp0aSI6ImE1OTU3YjgzLWExYTUtNDg4OC1iMzUyLWQxM2JhZDEyZDg0NCIsImlhdCI6MTc3Nzg5ODEyN30.wT6z8T9V8gRsS1uUg3HYEFFoS223krEC0KjQPBxlfis"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"servers": {
|
||||||
|
"n8n-mcp": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://n8n.supersamsev.ru/mcp-server/http",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMjMyZTZiZC00MzlhLTQwYjMtODgzMi05MzAxZTM3ZjRlMzgiLCJpc3MiOiJuOG4iLCJhdWQiOiJtY3Atc2VydmVyLWFwaSIsImp0aSI6ImE1OTU3YjgzLWExYTUtNDg4OC1iMzUyLWQxM2JhZDEyZDg0NCIsImlhdCI6MTc3Nzg5ODEyN30.wT6z8T9V8gRsS1uUg3HYEFFoS223krEC0KjQPBxlfis"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"version": "5",
|
||||||
|
"remote": {
|
||||||
|
"https://esm.sh/@supabase/supabase-js@2.49.8": "fd72c6e822ed41d5fe7ad3bbe3a48420abbb21a579c73d532b36a6467f5b5f7d"
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"packageJson": {
|
||||||
|
"dependencies": [
|
||||||
|
"npm:@eslint/js@^9.22.0",
|
||||||
|
"npm:@supabase/supabase-js@^2.52.0",
|
||||||
|
"npm:@types/react-dom@^18.3.5",
|
||||||
|
"npm:@types/react@^18.3.18",
|
||||||
|
"npm:@vitejs/plugin-react@^4.3.4",
|
||||||
|
"npm:autoprefixer@^10.4.21",
|
||||||
|
"npm:clsx@^2.1.1",
|
||||||
|
"npm:date-fns@^4.1.0",
|
||||||
|
"npm:eslint-plugin-react-hooks@^5.2.0",
|
||||||
|
"npm:eslint-plugin-react@^7.37.5",
|
||||||
|
"npm:eslint@^9.22.0",
|
||||||
|
"npm:framer-motion@^12.7.4",
|
||||||
|
"npm:globals@16",
|
||||||
|
"npm:postcss@^8.5.3",
|
||||||
|
"npm:react-dom@^18.3.1",
|
||||||
|
"npm:react-router-dom@^7.3.0",
|
||||||
|
"npm:react@^18.3.1",
|
||||||
|
"npm:tailwind-merge@^3.3.0",
|
||||||
|
"npm:tailwindcss@^3.4.17",
|
||||||
|
"npm:vite@^6.2.0",
|
||||||
|
"npm:vitest@^3.0.9"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,294 @@
|
||||||
|
# Поток n8n для `order_groups`
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
`Supabase` хранит состояние доставки, генерирует ссылку для клиента и
|
||||||
|
сохраняет все временные отметки.
|
||||||
|
`n8n` отвечает за отправку SMS, повторные попытки и перевод зависших
|
||||||
|
групп в ручную обработку.
|
||||||
|
|
||||||
|
Короткая ссылка с нашей стороны не нужна. Мы храним полную `delivery_link`,
|
||||||
|
а сервис SMS сам сокращает ссылку при отправке.
|
||||||
|
|
||||||
|
## Источник истины
|
||||||
|
|
||||||
|
Основная запись находится в `public.order_groups`.
|
||||||
|
|
||||||
|
Важные поля:
|
||||||
|
|
||||||
|
- `status` - бизнес-готовность группы
|
||||||
|
- `delivery_status` - состояние согласования доставки для клиента и логиста
|
||||||
|
- `delivery_link` - полная публичная ссылка на `/delivery/:token`
|
||||||
|
- `delivery_invitation_id` - связанная запись приглашения
|
||||||
|
- `notification_status` - состояние SMS-оркестрации для `n8n`
|
||||||
|
- `sms_attempts` - сколько было попыток отправки SMS
|
||||||
|
- `first_sms_sent_at` - время первой SMS
|
||||||
|
- `second_sms_sent_at` - время второй SMS
|
||||||
|
- `last_sms_error` - текст последней ошибки провайдера
|
||||||
|
- `next_notification_check_at` - когда `n8n` должен вернуться к записи
|
||||||
|
- `delivery_date` и `delivery_time` - выбранный слот после подтверждения клиентом
|
||||||
|
|
||||||
|
## Окно отправки SMS
|
||||||
|
|
||||||
|
Чтобы не тревожить клиентов ночью, SMS отправляются только в местное окно:
|
||||||
|
|
||||||
|
```text
|
||||||
|
09:00-20:00 Europe/Simferopol
|
||||||
|
```
|
||||||
|
|
||||||
|
Если запись стала готова к отправке вне этого окна, SMS не отправляется сразу.
|
||||||
|
`Supabase` должен поставить ближайшее разрешенное время в
|
||||||
|
`next_notification_check_at`:
|
||||||
|
|
||||||
|
- до `09:00` - сегодня в `09:00`
|
||||||
|
- с `09:00` до `20:00` - сразу
|
||||||
|
- после `20:00` - завтра в `09:00`
|
||||||
|
|
||||||
|
Для этого в SQL есть helper:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
public.next_order_group_sms_check_at(start_from timestamptz, delay interval)
|
||||||
|
```
|
||||||
|
|
||||||
|
`n8n` не должен сам решать, можно ли сейчас тревожить клиента. Он просто выбирает
|
||||||
|
записи, где `next_notification_check_at <= now()`.
|
||||||
|
|
||||||
|
## Рекомендуемая модель статусов
|
||||||
|
|
||||||
|
### `delivery_status`
|
||||||
|
|
||||||
|
- `pending_confirmation` - клиент еще не выбрал слот
|
||||||
|
- `agreed` - клиент выбрал слот доставки
|
||||||
|
- `manual_confirmation_required` - автоматический поток не сработал,
|
||||||
|
менеджер/логист должен продолжить вручную
|
||||||
|
- `no_contact` - менеджер/логист пытался связаться вручную, но связи с клиентом нет
|
||||||
|
- `assigned_to_driver` - доставка согласована и передана в планирование водителю
|
||||||
|
- `out_for_delivery` - водитель уже в работе
|
||||||
|
- `delivered` - доставка завершена
|
||||||
|
- `cancelled` - группу больше не нужно обрабатывать
|
||||||
|
|
||||||
|
### `notification_status`
|
||||||
|
|
||||||
|
- `not_started` - ссылка еще не подготовлена
|
||||||
|
- `link_ready` - `Supabase` создал `delivery_link`, `n8n` может отправлять первую SMS
|
||||||
|
- `first_sms_sent` - первая SMS принята провайдером
|
||||||
|
- `second_sms_sent` - отправлено напоминание
|
||||||
|
- `confirmed` - клиент выбрал слот
|
||||||
|
- `manual_required` - после повторов подтверждения нет
|
||||||
|
- `send_failed` - ошибка провайдера/API, можно повторить
|
||||||
|
|
||||||
|
## Ответственность Supabase
|
||||||
|
|
||||||
|
### 1. Подготовка ссылки
|
||||||
|
|
||||||
|
Когда `order_group` попадает в поток согласования доставки:
|
||||||
|
|
||||||
|
- `status = 'ready_for_notification'`
|
||||||
|
- `delivery_status = 'pending_confirmation'`
|
||||||
|
|
||||||
|
Триггер Supabase `order_groups_ensure_delivery_link` должен:
|
||||||
|
|
||||||
|
- создать запись `delivery_invitations` с `order_group_id`
|
||||||
|
- сгенерировать token и полную публичную ссылку
|
||||||
|
- подготовить слоты только на завтра и послезавтра
|
||||||
|
- записать `delivery_link` в `order_groups`
|
||||||
|
- установить `delivery_invitation_id`
|
||||||
|
- установить `notification_status = 'link_ready'`
|
||||||
|
- установить `next_notification_check_at` через `public.next_order_group_sms_check_at()`
|
||||||
|
|
||||||
|
SQL для этого триггера лежит здесь:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/sql/order-groups-auto-delivery-link.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
`n8n` больше не должен вызывать `create-delivery-invitation` для `order_groups`.
|
||||||
|
Ему нужно ждать, пока в строке уже будут
|
||||||
|
`notification_status = 'link_ready'` и `delivery_link is not null`.
|
||||||
|
|
||||||
|
### 2. Прием выбора клиента
|
||||||
|
|
||||||
|
Публичная страница использует token.
|
||||||
|
Когда клиент подтверждает слот, `confirm-delivery-choice` должен:
|
||||||
|
|
||||||
|
- сохранить `delivery_date` и `delivery_time`
|
||||||
|
- установить `delivery_status = 'agreed'`
|
||||||
|
- установить `notification_status = 'confirmed'`
|
||||||
|
|
||||||
|
Это становится стоп-сигналом для всех напоминаний в `n8n`.
|
||||||
|
|
||||||
|
## Потоки n8n
|
||||||
|
|
||||||
|
### Поток 1. Отправка первой SMS
|
||||||
|
|
||||||
|
Триггер:
|
||||||
|
|
||||||
|
- Cron каждые 5-10 минут
|
||||||
|
- Опционально запасной webhook-триггер, если потом захочешь push-старт
|
||||||
|
|
||||||
|
Запрос:
|
||||||
|
|
||||||
|
- `status = 'ready_for_notification'`
|
||||||
|
- `delivery_status = 'pending_confirmation'`
|
||||||
|
- `notification_status = 'link_ready'`
|
||||||
|
- `delivery_link is not null`
|
||||||
|
- `next_notification_check_at <= now()`
|
||||||
|
|
||||||
|
Действие:
|
||||||
|
|
||||||
|
- отправить SMS с `delivery_link`
|
||||||
|
|
||||||
|
При успехе обновить `order_groups`:
|
||||||
|
|
||||||
|
- `notification_status = 'first_sms_sent'`
|
||||||
|
- `sms_attempts = 1`
|
||||||
|
- `first_sms_sent_at = now()`
|
||||||
|
- `sms_sent_at = now()`
|
||||||
|
- `last_sms_error = null`
|
||||||
|
- `next_notification_check_at = public.next_order_group_sms_check_at(now(), interval '3 hours')`
|
||||||
|
|
||||||
|
Важно: если первая SMS ушла, например, в `18:30`, проверка через 3 часа попала бы
|
||||||
|
на `21:30`. Helper перенесет следующую проверку на завтра `09:00`.
|
||||||
|
|
||||||
|
При ошибке обновить `order_groups`:
|
||||||
|
|
||||||
|
- `notification_status = 'send_failed'`
|
||||||
|
- `last_sms_error = <ошибка провайдера>`
|
||||||
|
- `next_notification_check_at = public.next_order_group_sms_check_at(now(), interval '10 minutes')`
|
||||||
|
|
||||||
|
### Поток 2. Наблюдатель доставки
|
||||||
|
|
||||||
|
Триггер:
|
||||||
|
|
||||||
|
- Cron каждые 10 минут
|
||||||
|
|
||||||
|
Назначение:
|
||||||
|
|
||||||
|
- найти записи, где первый поток не завершился корректно
|
||||||
|
- повторить неудачные первые отправки
|
||||||
|
|
||||||
|
Кандидаты для выбора:
|
||||||
|
|
||||||
|
- `notification_status = 'send_failed'`
|
||||||
|
- `delivery_status = 'pending_confirmation'`
|
||||||
|
- `next_notification_check_at <= now()`
|
||||||
|
|
||||||
|
Поведение:
|
||||||
|
|
||||||
|
- повторить первую SMS
|
||||||
|
- если успех, перевести в `first_sms_sent`
|
||||||
|
- если повторные ошибки превысили выбранный порог, перевести в `manual_required`
|
||||||
|
- все новые значения `next_notification_check_at` считать через
|
||||||
|
`public.next_order_group_sms_check_at(...)`
|
||||||
|
|
||||||
|
### Поток 3. Напоминание по SMS
|
||||||
|
|
||||||
|
Триггер:
|
||||||
|
|
||||||
|
- Cron каждые 10 минут
|
||||||
|
|
||||||
|
Запрос:
|
||||||
|
|
||||||
|
- `delivery_status = 'pending_confirmation'`
|
||||||
|
- `notification_status = 'first_sms_sent'`
|
||||||
|
- `next_notification_check_at <= now()`
|
||||||
|
|
||||||
|
Действие:
|
||||||
|
|
||||||
|
- отправить вторую SMS-напоминалку с той же `delivery_link`
|
||||||
|
|
||||||
|
При успехе обновить:
|
||||||
|
|
||||||
|
- `notification_status = 'second_sms_sent'`
|
||||||
|
- `sms_attempts = 2`
|
||||||
|
- `second_sms_sent_at = now()`
|
||||||
|
- `last_sms_error = null`
|
||||||
|
- `next_notification_check_at = public.next_order_group_sms_check_at(now(), interval '3 hours')`
|
||||||
|
|
||||||
|
При ошибке обновить:
|
||||||
|
|
||||||
|
- `notification_status = 'send_failed'`
|
||||||
|
- `last_sms_error = <ошибка провайдера>`
|
||||||
|
- `next_notification_check_at = public.next_order_group_sms_check_at(now(), interval '30 minutes')`
|
||||||
|
|
||||||
|
### Поток 4. Передача в ручную обработку
|
||||||
|
|
||||||
|
Триггер:
|
||||||
|
|
||||||
|
- Cron каждые 10 минут
|
||||||
|
|
||||||
|
Запрос:
|
||||||
|
|
||||||
|
- `delivery_status = 'pending_confirmation'`
|
||||||
|
- `notification_status = 'second_sms_sent'`
|
||||||
|
- `next_notification_check_at <= now()`
|
||||||
|
|
||||||
|
Действие:
|
||||||
|
|
||||||
|
- остановить автоматические напоминания
|
||||||
|
- перевести группу в ручную обработку
|
||||||
|
|
||||||
|
Обновление:
|
||||||
|
|
||||||
|
- `delivery_status = 'manual_confirmation_required'`
|
||||||
|
- `notification_status = 'manual_required'`
|
||||||
|
|
||||||
|
После этого автоматические SMS больше не отправляются. В ЛК менеджер/логист
|
||||||
|
должен вручную выбрать дату доставки или поставить результат `no_contact`, если
|
||||||
|
связаться с клиентом не удалось.
|
||||||
|
|
||||||
|
### Поток 5. Условия остановки
|
||||||
|
|
||||||
|
Каждый поток должен игнорировать строки, где:
|
||||||
|
|
||||||
|
- `delivery_status in ('agreed', 'no_contact', 'assigned_to_driver', 'out_for_delivery', 'delivered', 'cancelled')`
|
||||||
|
- `notification_status in ('confirmed', 'manual_required')`
|
||||||
|
|
||||||
|
Это не дает слать дублирующие SMS после ответа клиента или передачи кейса человеку.
|
||||||
|
|
||||||
|
## Рекомендуемый текст SMS
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Ваш заказ готов к согласованию доставки.
|
||||||
|
Выберите удобные дату и время по ссылке:
|
||||||
|
{{delivery_link}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Напоминание:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Напоминаем: нужно выбрать дату и время доставки вашего заказа.
|
||||||
|
Ссылка:
|
||||||
|
{{delivery_link}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Что нужно фронтенду
|
||||||
|
|
||||||
|
Публичной странице нужны только:
|
||||||
|
|
||||||
|
- token из URL
|
||||||
|
- `get-delivery-invitation`
|
||||||
|
- `confirm-delivery-choice`
|
||||||
|
|
||||||
|
Никакой логики SMS на фронтенде быть не должно.
|
||||||
|
Никакой генерации ссылок на фронтенде быть не должно.
|
||||||
|
|
||||||
|
## Минимальный порядок внедрения
|
||||||
|
|
||||||
|
1. Развернуть обновленную схему `Supabase` и `docs/sql/order-groups-auto-delivery-link.sql`.
|
||||||
|
2. Проверить, что insert/update в `order_groups` пишет `delivery_link` и `notification_status = 'link_ready'`.
|
||||||
|
3. Собрать поток `n8n` для первой SMS.
|
||||||
|
4. Собрать поток `n8n` для напоминаний.
|
||||||
|
5. Собрать поток `n8n` для ручной передачи.
|
||||||
|
6. Проверить полный цикл на одной реальной записи `order_group`.
|
||||||
|
|
||||||
|
## Сценарий проверки
|
||||||
|
|
||||||
|
1. Пометить одну `order_group` как готовую к клиентскому согласованию доставки.
|
||||||
|
2. Убедиться, что `delivery_link` появился в `order_groups` автоматически.
|
||||||
|
3. Дать `n8n` отправить первую SMS.
|
||||||
|
4. Открыть ссылку и подтвердить слот на клиентской странице.
|
||||||
|
5. Убедиться, что `delivery_status = 'agreed'` и `notification_status = 'confirmed'`.
|
||||||
|
6. Убедиться, что потоки напоминаний больше не трогают эту группу.
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
-- Allow n8n to insert order_groups with the anon key, but only with a private
|
||||||
|
-- integration secret passed in the x-n8n-secret HTTP header.
|
||||||
|
--
|
||||||
|
-- n8n REST headers:
|
||||||
|
-- apikey: <SUPABASE_ANON_KEY>
|
||||||
|
-- Authorization: Bearer <SUPABASE_ANON_KEY>
|
||||||
|
-- x-n8n-secret: <LONG_RANDOM_SECRET>
|
||||||
|
-- Content-Type: application/json
|
||||||
|
-- Prefer: resolution=merge-duplicates,return=representation
|
||||||
|
--
|
||||||
|
-- Endpoint:
|
||||||
|
-- POST https://supa.supersamsev.ru/rest/v1/order_groups
|
||||||
|
--
|
||||||
|
-- Important:
|
||||||
|
-- - Keep this secret only in n8n credentials/environment.
|
||||||
|
-- - Do not put it in the frontend.
|
||||||
|
-- - Replace CHANGE_ME_LONG_RANDOM_SECRET before running this SQL.
|
||||||
|
|
||||||
|
create extension if not exists pgcrypto;
|
||||||
|
|
||||||
|
create table if not exists public.integration_api_secrets (
|
||||||
|
name text primary key,
|
||||||
|
secret_hash text not null,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.integration_api_secrets enable row level security;
|
||||||
|
|
||||||
|
drop policy if exists "integration api secrets admin only" on public.integration_api_secrets;
|
||||||
|
create policy "integration api secrets admin only"
|
||||||
|
on public.integration_api_secrets
|
||||||
|
for all
|
||||||
|
using (public.current_role_name() = 'admin')
|
||||||
|
with check (public.current_role_name() = 'admin');
|
||||||
|
|
||||||
|
insert into public.integration_api_secrets (name, secret_hash)
|
||||||
|
values (
|
||||||
|
'n8n_order_groups_insert',
|
||||||
|
crypt('CHANGE_ME_LONG_RANDOM_SECRET', gen_salt('bf'))
|
||||||
|
)
|
||||||
|
on conflict (name) do update
|
||||||
|
set secret_hash = excluded.secret_hash;
|
||||||
|
|
||||||
|
create or replace function public.is_valid_n8n_order_groups_secret()
|
||||||
|
returns boolean
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
select coalesce(
|
||||||
|
exists (
|
||||||
|
select 1
|
||||||
|
from public.integration_api_secrets s
|
||||||
|
where s.name = 'n8n_order_groups_insert'
|
||||||
|
and crypt(
|
||||||
|
nullif(current_setting('request.headers', true)::jsonb ->> 'x-n8n-secret', ''),
|
||||||
|
s.secret_hash
|
||||||
|
) = s.secret_hash
|
||||||
|
),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.is_valid_n8n_order_groups_secret() from public;
|
||||||
|
grant execute on function public.is_valid_n8n_order_groups_secret() to anon, authenticated;
|
||||||
|
|
||||||
|
alter table public.order_groups enable row level security;
|
||||||
|
|
||||||
|
drop policy if exists "order groups insert service roles" on public.order_groups;
|
||||||
|
drop policy if exists "order groups insert coordination and integration roles" on public.order_groups;
|
||||||
|
drop policy if exists "order groups insert n8n anon secret" on public.order_groups;
|
||||||
|
|
||||||
|
create policy "order groups insert n8n anon secret"
|
||||||
|
on public.order_groups
|
||||||
|
for insert
|
||||||
|
to anon
|
||||||
|
with check (public.is_valid_n8n_order_groups_secret());
|
||||||
|
|
||||||
|
create policy "order groups insert coordination and integration roles"
|
||||||
|
on public.order_groups
|
||||||
|
for insert
|
||||||
|
to authenticated
|
||||||
|
with check (
|
||||||
|
public.current_role_name() in ('manager', 'logistician', 'admin', 'integration')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- If n8n uses upsert, update must also be allowed for the same anon secret.
|
||||||
|
drop policy if exists "order groups update n8n anon secret" on public.order_groups;
|
||||||
|
drop policy if exists "order groups update coordination roles" on public.order_groups;
|
||||||
|
drop policy if exists "order groups update coordination and integration roles" on public.order_groups;
|
||||||
|
|
||||||
|
create policy "order groups update n8n anon secret"
|
||||||
|
on public.order_groups
|
||||||
|
for update
|
||||||
|
to anon
|
||||||
|
using (public.is_valid_n8n_order_groups_secret())
|
||||||
|
with check (public.is_valid_n8n_order_groups_secret());
|
||||||
|
|
||||||
|
create policy "order groups update coordination and integration roles"
|
||||||
|
on public.order_groups
|
||||||
|
for update
|
||||||
|
to authenticated
|
||||||
|
using (
|
||||||
|
public.current_role_name() in ('manager', 'logistician', 'admin', 'integration')
|
||||||
|
)
|
||||||
|
with check (
|
||||||
|
public.current_role_name() in ('manager', 'logistician', 'admin', 'integration')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Diagnostics:
|
||||||
|
-- select policyname, cmd, roles, qual, with_check
|
||||||
|
-- from pg_policies
|
||||||
|
-- where schemaname = 'public' and tablename = 'order_groups'
|
||||||
|
-- order by policyname;
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
-- Auto-create public delivery links for rows imported into public.order_groups.
|
||||||
|
-- Run this in Supabase SQL Editor after the order_groups delivery columns exist.
|
||||||
|
|
||||||
|
create extension if not exists pgcrypto with schema extensions;
|
||||||
|
|
||||||
|
create or replace function public.next_order_group_sms_check_at(
|
||||||
|
start_from timestamptz default now(),
|
||||||
|
delay interval default interval '0 minutes'
|
||||||
|
)
|
||||||
|
returns timestamptz
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_timezone text := 'Europe/Simferopol';
|
||||||
|
v_local_time timestamp;
|
||||||
|
v_local_date date;
|
||||||
|
v_work_start timestamp;
|
||||||
|
v_work_end timestamp;
|
||||||
|
v_candidate timestamp;
|
||||||
|
begin
|
||||||
|
v_local_time := (start_from at time zone v_timezone) + delay;
|
||||||
|
v_local_date := v_local_time::date;
|
||||||
|
v_work_start := v_local_date + time '09:00';
|
||||||
|
v_work_end := v_local_date + time '20:00';
|
||||||
|
|
||||||
|
if v_local_time < v_work_start then
|
||||||
|
v_candidate := v_work_start;
|
||||||
|
elsif v_local_time >= v_work_end then
|
||||||
|
v_candidate := (v_local_date + 1) + time '09:00';
|
||||||
|
else
|
||||||
|
v_candidate := v_local_time;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return v_candidate at time zone v_timezone;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.build_order_group_default_available_slots(
|
||||||
|
start_from timestamptz default now()
|
||||||
|
)
|
||||||
|
returns text[]
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $$
|
||||||
|
with candidate_days as (
|
||||||
|
select ((start_from at time zone 'Europe/Simferopol')::date + offset_days) as delivery_day
|
||||||
|
from generate_series(1, 2) as offset_days
|
||||||
|
),
|
||||||
|
slots as (
|
||||||
|
select format('%s, %s', delivery_day, half_day) as slot_name
|
||||||
|
from candidate_days
|
||||||
|
cross join (
|
||||||
|
values ('Первая половина дня'), ('Вторая половина дня')
|
||||||
|
) as halves(half_day)
|
||||||
|
)
|
||||||
|
select coalesce(array_agg(slot_name order by slot_name), array[]::text[])
|
||||||
|
from slots;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.ensure_order_group_delivery_link()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public, extensions
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_customer_name text;
|
||||||
|
v_customer_phone text;
|
||||||
|
v_order_number text;
|
||||||
|
v_token text;
|
||||||
|
v_token_hash text;
|
||||||
|
v_delivery_link text;
|
||||||
|
v_invitation_id uuid;
|
||||||
|
v_base_url text := 'https://dost.supersamsev.ru';
|
||||||
|
begin
|
||||||
|
if pg_trigger_depth() > 1 then
|
||||||
|
return new;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if coalesce(new.status, '') <> 'ready_for_notification' then
|
||||||
|
return new;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if coalesce(new.delivery_status, 'pending_confirmation') <> 'pending_confirmation' then
|
||||||
|
return new;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if coalesce(new.notification_status, 'not_started') not in ('not_started', 'send_failed') then
|
||||||
|
return new;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if new.delivery_link is not null or new.delivery_invitation_id is not null then
|
||||||
|
return new;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_customer_name := nullif(
|
||||||
|
coalesce(new.customer_name, new.customer ->> 'name'),
|
||||||
|
''
|
||||||
|
);
|
||||||
|
v_customer_phone := nullif(
|
||||||
|
coalesce(
|
||||||
|
new.customer_phone_normalized,
|
||||||
|
new.customer_phone,
|
||||||
|
new.customer ->> 'phone_normalized',
|
||||||
|
new.customer ->> 'phone',
|
||||||
|
split_part(new.group_key, '|', 1)
|
||||||
|
),
|
||||||
|
''
|
||||||
|
);
|
||||||
|
v_order_number := coalesce(new.group_key::text, new.order_numbers[1]::text);
|
||||||
|
|
||||||
|
if v_customer_phone is null then
|
||||||
|
update public.order_groups
|
||||||
|
set
|
||||||
|
notification_status = 'manual_required',
|
||||||
|
last_sms_error = 'Не найден телефон клиента для формирования SMS-ссылки',
|
||||||
|
next_notification_check_at = null,
|
||||||
|
updated_at = timezone('utc', now())
|
||||||
|
where id = new.id;
|
||||||
|
|
||||||
|
return new;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_token := replace(gen_random_uuid()::text, '-', '') || replace(gen_random_uuid()::text, '-', '');
|
||||||
|
v_token_hash := encode(digest(v_token, 'sha256'), 'hex');
|
||||||
|
v_delivery_link := v_base_url || '/delivery/' || v_token;
|
||||||
|
|
||||||
|
insert into public.delivery_invitations (
|
||||||
|
order_id,
|
||||||
|
order_group_id,
|
||||||
|
token_hash,
|
||||||
|
state,
|
||||||
|
order_number,
|
||||||
|
customer_name,
|
||||||
|
customer_phone,
|
||||||
|
available_slots,
|
||||||
|
expires_at,
|
||||||
|
sent_at
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
null,
|
||||||
|
new.id,
|
||||||
|
v_token_hash,
|
||||||
|
'awaiting_choice',
|
||||||
|
v_order_number,
|
||||||
|
v_customer_name,
|
||||||
|
v_customer_phone,
|
||||||
|
public.build_order_group_default_available_slots(),
|
||||||
|
timezone('utc', now()) + interval '7 days',
|
||||||
|
null
|
||||||
|
)
|
||||||
|
returning id into v_invitation_id;
|
||||||
|
|
||||||
|
update public.order_groups
|
||||||
|
set
|
||||||
|
delivery_invitation_id = v_invitation_id,
|
||||||
|
delivery_link = v_delivery_link,
|
||||||
|
notification_status = 'link_ready',
|
||||||
|
last_sms_error = null,
|
||||||
|
next_notification_check_at = public.next_order_group_sms_check_at(),
|
||||||
|
updated_at = timezone('utc', now())
|
||||||
|
where id = new.id;
|
||||||
|
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists order_groups_ensure_delivery_link on public.order_groups;
|
||||||
|
create trigger order_groups_ensure_delivery_link
|
||||||
|
after insert or update of status, delivery_status, notification_status, delivery_link, delivery_invitation_id
|
||||||
|
on public.order_groups
|
||||||
|
for each row
|
||||||
|
execute function public.ensure_order_group_delivery_link();
|
||||||
|
|
||||||
|
-- Backfill links for already imported groups that are still waiting for SMS.
|
||||||
|
update public.order_groups
|
||||||
|
set
|
||||||
|
notification_status = notification_status,
|
||||||
|
updated_at = timezone('utc', now())
|
||||||
|
where status = 'ready_for_notification'
|
||||||
|
and delivery_status = 'pending_confirmation'
|
||||||
|
and notification_status in ('not_started', 'send_failed')
|
||||||
|
and delivery_link is null
|
||||||
|
and delivery_invitation_id is null;
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
alter table public.order_groups add column if not exists delivery_date date;
|
||||||
|
alter table public.order_groups add column if not exists delivery_time text;
|
||||||
|
alter table public.order_groups add column if not exists notification_status text not null default 'not_started';
|
||||||
|
|
||||||
|
create index if not exists idx_order_groups_delivery_status
|
||||||
|
on public.order_groups (delivery_status);
|
||||||
|
|
||||||
|
create index if not exists idx_order_groups_notification_status
|
||||||
|
on public.order_groups (notification_status, updated_at);
|
||||||
|
|
||||||
|
alter table public.order_groups enable row level security;
|
||||||
|
|
||||||
|
drop policy if exists "order groups select by role" on public.order_groups;
|
||||||
|
create policy "order groups select by role" on public.order_groups
|
||||||
|
for select
|
||||||
|
using (true);
|
||||||
|
|
||||||
|
drop policy if exists "order groups update coordination roles" on public.order_groups;
|
||||||
|
create policy "order groups update coordination roles" on public.order_groups
|
||||||
|
for update
|
||||||
|
using (public.current_role_name() in ('manager', 'logistician', 'admin'))
|
||||||
|
with check (public.current_role_name() in ('manager', 'logistician', 'admin'));
|
||||||
|
|
||||||
|
drop policy if exists "order groups insert service roles" on public.order_groups;
|
||||||
|
create policy "order groups insert service roles" on public.order_groups
|
||||||
|
for insert
|
||||||
|
with check (public.current_role_name() in ('manager', 'logistician', 'admin'));
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
-- n8n import into public.order_groups
|
||||||
|
--
|
||||||
|
-- Recommended setup:
|
||||||
|
-- 1. n8n must call Supabase REST with the SERVICE_ROLE key, not the anon key.
|
||||||
|
-- 2. Keep RLS closed for anon/authenticated inserts unless the request comes
|
||||||
|
-- from an authenticated application user with a coordination role.
|
||||||
|
--
|
||||||
|
-- n8n HTTP headers for REST inserts:
|
||||||
|
-- apikey: <SUPABASE_SERVICE_ROLE_KEY>
|
||||||
|
-- Authorization: Bearer <SUPABASE_SERVICE_ROLE_KEY>
|
||||||
|
-- Content-Type: application/json
|
||||||
|
-- Prefer: resolution=merge-duplicates,return=representation
|
||||||
|
--
|
||||||
|
-- Endpoint example:
|
||||||
|
-- POST https://<project-ref>.supabase.co/rest/v1/order_groups
|
||||||
|
--
|
||||||
|
-- Why this is needed:
|
||||||
|
-- current_role_name() is based on auth.uid() and public.users. A plain n8n
|
||||||
|
-- anon request has no application user, so insert policies such as
|
||||||
|
-- current_role_name() in ('manager', 'logistician', 'admin') reject the row.
|
||||||
|
-- service_role bypasses RLS and is the correct key for trusted server workflows.
|
||||||
|
|
||||||
|
alter table public.order_groups enable row level security;
|
||||||
|
|
||||||
|
drop policy if exists "order groups insert service roles" on public.order_groups;
|
||||||
|
create policy "order groups insert service roles" on public.order_groups
|
||||||
|
for insert
|
||||||
|
with check (public.current_role_name() in ('manager', 'logistician', 'admin'));
|
||||||
|
|
||||||
|
-- Optional diagnostic query: if this returns null for the JWT used by n8n,
|
||||||
|
-- that JWT is not an app user and cannot pass the application-user RLS policy.
|
||||||
|
select public.current_role_name() as current_app_role;
|
||||||
|
|
@ -0,0 +1,389 @@
|
||||||
|
create extension if not exists pgcrypto;
|
||||||
|
|
||||||
|
create or replace function public.get_delivery_invitation_by_token(p_token text)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public, extensions
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_invitation public.delivery_invitations%rowtype;
|
||||||
|
v_group public.order_groups%rowtype;
|
||||||
|
v_order record;
|
||||||
|
v_token_hash text;
|
||||||
|
v_state text;
|
||||||
|
v_order_number text;
|
||||||
|
v_customer_name text;
|
||||||
|
v_customer_phone text;
|
||||||
|
v_order_items jsonb;
|
||||||
|
v_order_numbers jsonb;
|
||||||
|
v_now timestamptz := timezone('utc', now());
|
||||||
|
begin
|
||||||
|
if nullif(trim(coalesce(p_token, '')), '') is null then
|
||||||
|
raise exception 'token is required';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_token_hash := encode(digest(p_token, 'sha256'), 'hex');
|
||||||
|
|
||||||
|
select *
|
||||||
|
into v_invitation
|
||||||
|
from public.delivery_invitations
|
||||||
|
where token_hash = v_token_hash;
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
raise exception 'Invitation not found';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_invitation.revoked_at is not null then
|
||||||
|
raise exception 'Invitation expired';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_invitation.expires_at is not null and v_invitation.expires_at <= v_now then
|
||||||
|
raise exception 'Invitation expired';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_invitation.order_group_id is not null then
|
||||||
|
select *
|
||||||
|
into v_group
|
||||||
|
from public.order_groups
|
||||||
|
where id = v_invitation.order_group_id;
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
raise exception 'Order group not found';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_state := case
|
||||||
|
when v_group.delivery_status = 'agreed' then 'agreed'
|
||||||
|
when v_group.delivery_status = 'delivered' then 'delivered'
|
||||||
|
when v_invitation.state in ('awaiting_choice', 'opened', 'reminder_sent') then v_invitation.state
|
||||||
|
else 'default'
|
||||||
|
end;
|
||||||
|
|
||||||
|
update public.delivery_invitations
|
||||||
|
set
|
||||||
|
opened_at = case
|
||||||
|
when v_state in ('awaiting_choice', 'opened', 'reminder_sent') and opened_at is null then v_now
|
||||||
|
else opened_at
|
||||||
|
end,
|
||||||
|
access_count = coalesce(access_count, 0) + 1,
|
||||||
|
last_accessed_at = v_now
|
||||||
|
where id = v_invitation.id
|
||||||
|
returning * into v_invitation;
|
||||||
|
|
||||||
|
v_order_number := coalesce(
|
||||||
|
nullif(v_invitation.order_number, ''),
|
||||||
|
to_jsonb(v_group.order_numbers) ->> 0,
|
||||||
|
nullif(v_group.group_key, '')
|
||||||
|
);
|
||||||
|
v_customer_name := coalesce(
|
||||||
|
nullif(v_group.customer_name, ''),
|
||||||
|
nullif(v_group.customer ->> 'name', ''),
|
||||||
|
nullif(v_invitation.customer_name, '')
|
||||||
|
);
|
||||||
|
v_customer_phone := coalesce(
|
||||||
|
nullif(v_group.customer_phone, ''),
|
||||||
|
nullif(v_group.customer ->> 'phone', ''),
|
||||||
|
nullif(v_invitation.customer_phone, '')
|
||||||
|
);
|
||||||
|
select coalesce(
|
||||||
|
jsonb_agg(jsonb_build_object('name', order_number, 'quantity', '')),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
into v_order_items
|
||||||
|
from jsonb_array_elements_text(
|
||||||
|
case
|
||||||
|
when jsonb_typeof(to_jsonb(v_group.order_numbers)) = 'array' then to_jsonb(v_group.order_numbers)
|
||||||
|
else '[]'::jsonb
|
||||||
|
end
|
||||||
|
) as order_number;
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'ok', true,
|
||||||
|
'invitation', jsonb_build_object(
|
||||||
|
'orderId', coalesce(v_invitation.order_group_id, v_group.id)::text,
|
||||||
|
'orderGroupId', coalesce(v_invitation.order_group_id, v_group.id)::text,
|
||||||
|
'state', v_state,
|
||||||
|
'token', p_token,
|
||||||
|
'orderNumber', v_order_number,
|
||||||
|
'customerName', v_customer_name,
|
||||||
|
'customerPhone', v_customer_phone,
|
||||||
|
'orderItems', v_order_items,
|
||||||
|
'availableSlots', coalesce(to_jsonb(v_invitation.available_slots), '[]'::jsonb),
|
||||||
|
'deliveryDate', v_invitation.delivery_date,
|
||||||
|
'deliveryTime', v_invitation.delivery_time,
|
||||||
|
'orderStatus', null,
|
||||||
|
'deliveryAgreementStatus', null
|
||||||
|
)
|
||||||
|
);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select id, order_number, status, delivery_agreement_status, customer
|
||||||
|
into v_order
|
||||||
|
from public.orders
|
||||||
|
where id = v_invitation.order_id;
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
raise exception 'Order not found';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_state := case v_order.status
|
||||||
|
when 'Ожидает ответа клиента' then 'awaiting_choice'
|
||||||
|
when 'Ожидает согласования доставки' then 'opened'
|
||||||
|
when 'Напоминание отправлено' then 'reminder_sent'
|
||||||
|
when 'Переход отправлен' then 'reminder_sent'
|
||||||
|
when 'Передан логисту' then 'transferred_to_logistics'
|
||||||
|
when 'Платное хранение' then 'paid_storage'
|
||||||
|
when 'Доставлен' then 'delivered'
|
||||||
|
when 'Доставка согласована' then 'agreed'
|
||||||
|
else 'default'
|
||||||
|
end;
|
||||||
|
|
||||||
|
update public.delivery_invitations
|
||||||
|
set
|
||||||
|
opened_at = case
|
||||||
|
when v_state in ('awaiting_choice', 'opened', 'reminder_sent') and opened_at is null then v_now
|
||||||
|
else opened_at
|
||||||
|
end,
|
||||||
|
access_count = coalesce(access_count, 0) + 1,
|
||||||
|
last_accessed_at = v_now
|
||||||
|
where id = v_invitation.id
|
||||||
|
returning * into v_invitation;
|
||||||
|
|
||||||
|
v_order_items := case
|
||||||
|
when jsonb_typeof(v_order.customer -> 'items') = 'array' then v_order.customer -> 'items'
|
||||||
|
else '[]'::jsonb
|
||||||
|
end;
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'ok', true,
|
||||||
|
'invitation', jsonb_build_object(
|
||||||
|
'orderId', v_invitation.order_id::text,
|
||||||
|
'state', v_state,
|
||||||
|
'token', p_token,
|
||||||
|
'orderNumber', coalesce(nullif(v_order.order_number, ''), nullif(v_invitation.order_number, '')),
|
||||||
|
'customerName', coalesce(nullif(v_order.customer ->> 'name', ''), nullif(v_invitation.customer_name, '')),
|
||||||
|
'customerPhone', coalesce(nullif(v_order.customer ->> 'phone', ''), nullif(v_invitation.customer_phone, '')),
|
||||||
|
'orderItems', v_order_items,
|
||||||
|
'availableSlots', coalesce(to_jsonb(v_invitation.available_slots), '[]'::jsonb),
|
||||||
|
'deliveryDate', v_invitation.delivery_date,
|
||||||
|
'deliveryTime', v_invitation.delivery_time,
|
||||||
|
'orderStatus', v_order.status,
|
||||||
|
'deliveryAgreementStatus', v_order.delivery_agreement_status
|
||||||
|
)
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.confirm_delivery_choice_by_token(
|
||||||
|
p_token text,
|
||||||
|
p_delivery_date date,
|
||||||
|
p_delivery_time text
|
||||||
|
)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public, extensions
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_invitation public.delivery_invitations%rowtype;
|
||||||
|
v_group public.order_groups%rowtype;
|
||||||
|
v_order record;
|
||||||
|
v_token_hash text;
|
||||||
|
v_slot_label text;
|
||||||
|
v_now timestamptz := timezone('utc', now());
|
||||||
|
begin
|
||||||
|
if nullif(trim(coalesce(p_token, '')), '') is null then
|
||||||
|
raise exception 'token is required';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if p_delivery_date is null or nullif(trim(coalesce(p_delivery_time, '')), '') is null then
|
||||||
|
raise exception 'Selected slot is not available';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_token_hash := encode(digest(p_token, 'sha256'), 'hex');
|
||||||
|
v_slot_label := concat(p_delivery_date::text, ', ', trim(p_delivery_time));
|
||||||
|
|
||||||
|
select *
|
||||||
|
into v_invitation
|
||||||
|
from public.delivery_invitations
|
||||||
|
where token_hash = v_token_hash
|
||||||
|
for update;
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
raise exception 'Invitation not found';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_invitation.revoked_at is not null then
|
||||||
|
raise exception 'Invitation expired';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_invitation.expires_at is not null and v_invitation.expires_at <= v_now then
|
||||||
|
raise exception 'Invitation expired';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_invitation.state not in ('awaiting_choice', 'opened', 'reminder_sent') then
|
||||||
|
raise exception 'Invitation is no longer active';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if cardinality(v_invitation.available_slots) > 0 and not (v_slot_label = any(v_invitation.available_slots)) then
|
||||||
|
raise exception 'Selected slot is not available';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_invitation.order_group_id is not null then
|
||||||
|
select *
|
||||||
|
into v_group
|
||||||
|
from public.order_groups
|
||||||
|
where id = v_invitation.order_group_id
|
||||||
|
for update;
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
raise exception 'Order group not found';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_group.delivery_status <> 'pending_confirmation' then
|
||||||
|
raise exception 'Invitation is no longer active';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update public.delivery_invitations
|
||||||
|
set
|
||||||
|
state = 'agreed',
|
||||||
|
delivery_date = p_delivery_date,
|
||||||
|
delivery_time = trim(p_delivery_time),
|
||||||
|
confirmed_at = v_now,
|
||||||
|
access_count = coalesce(access_count, 0) + 1,
|
||||||
|
last_accessed_at = v_now
|
||||||
|
where id = v_invitation.id;
|
||||||
|
|
||||||
|
update public.order_groups
|
||||||
|
set
|
||||||
|
delivery_status = 'agreed',
|
||||||
|
delivery_date = p_delivery_date,
|
||||||
|
delivery_time = trim(p_delivery_time),
|
||||||
|
notification_status = 'confirmed',
|
||||||
|
updated_at = v_now
|
||||||
|
where id = v_group.id;
|
||||||
|
|
||||||
|
insert into public.integration_events (
|
||||||
|
order_id,
|
||||||
|
event_type,
|
||||||
|
direction,
|
||||||
|
status,
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
null,
|
||||||
|
'delivery_choice_confirmed',
|
||||||
|
'inbound',
|
||||||
|
'success',
|
||||||
|
jsonb_build_object(
|
||||||
|
'order_group_id', v_group.id,
|
||||||
|
'delivery_invitation_id', v_invitation.id,
|
||||||
|
'delivery_date', p_delivery_date,
|
||||||
|
'delivery_time', trim(p_delivery_time)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'ok', true,
|
||||||
|
'orderGroupId', v_group.id,
|
||||||
|
'deliveryStatus', 'agreed'
|
||||||
|
);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select id, status, delivery_agreement_status
|
||||||
|
into v_order
|
||||||
|
from public.orders
|
||||||
|
where id = v_invitation.order_id
|
||||||
|
for update;
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
raise exception 'Order not found';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_order.status not in ('Ожидает ответа клиента', 'Ожидает согласования доставки') then
|
||||||
|
raise exception 'Invitation is no longer active';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update public.delivery_invitations
|
||||||
|
set
|
||||||
|
state = 'agreed',
|
||||||
|
delivery_date = p_delivery_date,
|
||||||
|
delivery_time = trim(p_delivery_time),
|
||||||
|
confirmed_at = v_now,
|
||||||
|
access_count = coalesce(access_count, 0) + 1,
|
||||||
|
last_accessed_at = v_now
|
||||||
|
where id = v_invitation.id;
|
||||||
|
|
||||||
|
update public.orders
|
||||||
|
set
|
||||||
|
status = 'Доставка согласована',
|
||||||
|
delivery_agreement_status = 'Подтверждено клиентом'
|
||||||
|
where id = v_order.id;
|
||||||
|
|
||||||
|
insert into public.delivery_slots (
|
||||||
|
order_id,
|
||||||
|
delivery_date,
|
||||||
|
delivery_time,
|
||||||
|
logistician_id,
|
||||||
|
status
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
v_order.id,
|
||||||
|
p_delivery_date,
|
||||||
|
trim(p_delivery_time),
|
||||||
|
null,
|
||||||
|
'confirmed_by_client'
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into public.order_history (
|
||||||
|
order_id,
|
||||||
|
action,
|
||||||
|
old_status,
|
||||||
|
new_status,
|
||||||
|
metadata
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
v_order.id,
|
||||||
|
'Подтверждение выбора доставки клиентом',
|
||||||
|
v_order.status,
|
||||||
|
'Доставка согласована',
|
||||||
|
jsonb_build_object(
|
||||||
|
'old_delivery_agreement_status', v_order.delivery_agreement_status,
|
||||||
|
'new_delivery_agreement_status', 'Подтверждено клиентом',
|
||||||
|
'delivery_date', p_delivery_date,
|
||||||
|
'delivery_time', trim(p_delivery_time)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into public.integration_events (
|
||||||
|
order_id,
|
||||||
|
event_type,
|
||||||
|
direction,
|
||||||
|
status,
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
v_order.id,
|
||||||
|
'delivery_choice_confirmed',
|
||||||
|
'inbound',
|
||||||
|
'success',
|
||||||
|
jsonb_build_object(
|
||||||
|
'delivery_date', p_delivery_date,
|
||||||
|
'delivery_time', trim(p_delivery_time)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'ok', true,
|
||||||
|
'orderId', v_order.id,
|
||||||
|
'status', 'Доставка согласована',
|
||||||
|
'deliveryAgreementStatus', 'Подтверждено клиентом'
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.get_delivery_invitation_by_token(text) from public;
|
||||||
|
grant execute on function public.get_delivery_invitation_by_token(text) to anon, authenticated;
|
||||||
|
|
||||||
|
revoke all on function public.confirm_delivery_choice_by_token(text, date, text) from public;
|
||||||
|
grant execute on function public.confirm_delivery_choice_by_token(text, date, text) to anon, authenticated;
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
# order_groups Migration Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Move the dashboard UI from legacy `orders` records to `order_groups` so all roles work from the grouped delivery table.
|
||||||
|
|
||||||
|
**Architecture:** Introduce a dedicated `order_groups` repository and normalize each row into a delivery-group view model. Update the dashboard, list panels, and detail panels to render that model directly, removing assumptions about order-level fields like address, items, history, and delivery slots that no longer exist.
|
||||||
|
|
||||||
|
**Tech Stack:** React, Vite, Supabase JS, Vitest, Tailwind CSS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 1: Data access and demo fallback
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/services/supabase/orderGroupRepository.js`
|
||||||
|
- Modify: `src/hooks/useOrders.js`
|
||||||
|
- Modify: `src/data/mockAppData.js`
|
||||||
|
- Modify: `src/services/deliverySetViews.js`
|
||||||
|
- Test: `src/services/supabase/orderGroupRepository.test.js` if needed
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Cover the `order_groups` row mapper and a few derived view fields, including customer identity, group counts, and status.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `npm test -- --run src/services/supabase/orderGroupRepository.test.js`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Add `fetchOrderGroups`, `mapOrderGroupRowToGroup`, and a demo fallback array shaped like the new table.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npm test -- --run src/services/supabase/orderGroupRepository.test.js`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/services/supabase/orderGroupRepository.js src/hooks/useOrders.js src/data/mockAppData.js src/services/deliverySetViews.js src/services/supabase/orderGroupRepository.test.js
|
||||||
|
git commit -m "feat: load dashboard from order_groups"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Chunk 2: Dashboard surfaces
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/pages/DashboardPage.jsx`
|
||||||
|
- Modify: `src/components/orders/OrdersTable.jsx`
|
||||||
|
- Modify: `src/components/orders/OrderDetailPanel.jsx`
|
||||||
|
- Modify: `src/components/driver/DriverDeliveryPlanner.jsx`
|
||||||
|
- Modify: `src/components/logistics/LogisticsReadinessBoard.jsx`
|
||||||
|
- Modify: `src/components/logistics/DeliverySetDetailPanel.jsx`
|
||||||
|
- Modify: `src/components/orders/OrderFilters.jsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Update component tests to expect group labels, counts, and `order_numbers`-based summaries instead of order-level fields.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `npm test -- --run src/components/orders/OrdersTable.test.jsx src/components/driver/DriverDeliveryPlanner.test.jsx src/components/logistics/DeliverySetDetailPanel.test.jsx`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Replace order-specific text and bindings with group-based fields and simplify unsupported actions.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npm test -- --run src/components/orders/OrdersTable.test.jsx src/components/driver/DriverDeliveryPlanner.test.jsx src/components/logistics/DeliverySetDetailPanel.test.jsx`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/DashboardPage.jsx src/components/orders/OrdersTable.jsx src/components/orders/OrderDetailPanel.jsx src/components/driver/DriverDeliveryPlanner.jsx src/components/logistics/LogisticsReadinessBoard.jsx src/components/logistics/DeliverySetDetailPanel.jsx src/components/orders/OrderFilters.jsx
|
||||||
|
git commit -m "feat: render order groups in dashboard"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Chunk 3: Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/layouts/AppShell.jsx` if counts or labels need adjustment
|
||||||
|
- Modify: `src/layouts/AppShell.test.jsx` if badge labels change
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run the full test suite**
|
||||||
|
|
||||||
|
Run: `npm test`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build the app**
|
||||||
|
|
||||||
|
Run: `npm run build`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Check the UI in the browser**
|
||||||
|
|
||||||
|
Open `http://localhost:5174/dashboard` and confirm the grouped delivery list, detail modal, and driver view all render from `order_groups`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/layouts/AppShell.jsx src/layouts/AppShell.test.jsx
|
||||||
|
git commit -m "test: verify order_groups dashboard migration"
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,453 @@
|
||||||
|
# Manual Delivery Agreement Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Make manager/logistician manual delivery agreement safe and clear: only future delivery dates are allowed, controls match the app theme, order group counters are correct, and confusing technical fields are removed from the card.
|
||||||
|
|
||||||
|
**Architecture:** Keep the workflow centered on `order_groups`. The UI validates future dates before submit, the Edge Function enforces the same rule server-side, and the repository maps partial `order_groups` rows into a clean view model for dashboards and cards.
|
||||||
|
|
||||||
|
**Tech Stack:** React 18, Vite, Vitest, Supabase JS, Supabase Edge Functions in Deno, Tailwind utility classes with CSS variables.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- Modify: `src/components/orders/OrderDetailPanel.jsx`
|
||||||
|
Responsible for the order group detail card, manual agreement UI, local form validation, and hiding confusing technical fields.
|
||||||
|
|
||||||
|
- Modify: `src/components/orders/OrderDetailPanel.test.jsx`
|
||||||
|
Server-rendered component tests for the detail card, editable controls, and visible copy.
|
||||||
|
|
||||||
|
- Modify: `src/services/supabase/orderGroupRepository.js`
|
||||||
|
Responsible for mapping raw `order_groups` rows into the frontend delivery group model and saving manual delivery choices through the Edge Function.
|
||||||
|
|
||||||
|
- Modify: `src/services/supabase/orderGroupRepository.test.js`
|
||||||
|
Mapping tests for missing counters, real delivery dates, and fallback behavior.
|
||||||
|
|
||||||
|
- Modify: `supabase/functions/update-order-group-delivery-choice/index.ts`
|
||||||
|
Server-side manual agreement validation and update logic.
|
||||||
|
|
||||||
|
- Optional modify: `src/components/UI/Select.jsx`
|
||||||
|
Only touch if other select controls still need a global design correction after the manual agreement block switches to app-styled buttons.
|
||||||
|
|
||||||
|
- Optional modify: `docs/sql/order-groups-manual-delivery-choice.sql`
|
||||||
|
Only touch if database constraints are added later. Current requirement can be enforced in the Edge Function.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 1: Data Mapping Correctness
|
||||||
|
|
||||||
|
### Task 1: Stop Showing Fake Delivery Dates
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/services/supabase/orderGroupRepository.js`
|
||||||
|
- Test: `src/services/supabase/orderGroupRepository.test.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing mapping test**
|
||||||
|
|
||||||
|
Add a case where `customer_date` exists but `delivery_date` is null.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const group = mapOrderGroupRowToDeliveryGroup({
|
||||||
|
id: "group-without-delivery-date",
|
||||||
|
group_key: "9781632663|28.04.26",
|
||||||
|
customer_date: "28.04.26",
|
||||||
|
order_numbers: ["СФ Т\\ЕА-26979"],
|
||||||
|
status: "ready_for_notification",
|
||||||
|
delivery_status: "pending_confirmation",
|
||||||
|
created_at: "2026-05-05 09:43:53.750061+00",
|
||||||
|
updated_at: "2026-05-05 09:43:53.750061+00",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(group.customerDate).toBe("28.04.26");
|
||||||
|
expect(group.deliveryDate).toBe("");
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the focused test**
|
||||||
|
|
||||||
|
Run: `npm test -- --run src/services/supabase/orderGroupRepository.test.js`
|
||||||
|
|
||||||
|
Expected before implementation: FAIL because `deliveryDate` is incorrectly filled from `customerDate`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the mapping fix**
|
||||||
|
|
||||||
|
In `mapOrderGroupRowToDeliveryGroup`, set:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const deliveryDate = normalizeText(row.delivery_date);
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not fall back to `customerDate` for actual delivery agreement data.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the focused test**
|
||||||
|
|
||||||
|
Run: `npm test -- --run src/services/supabase/orderGroupRepository.test.js`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 2: Infer Counters When `order_groups` Counter Columns Are Empty
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/services/supabase/orderGroupRepository.js`
|
||||||
|
- Test: `src/services/supabase/orderGroupRepository.test.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing counter test**
|
||||||
|
|
||||||
|
Use a real-shaped row where `order_numbers` has values, but `orders_count`, `ready_count`, and `not_ready_count` are missing.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const group = mapOrderGroupRowToDeliveryGroup({
|
||||||
|
id: "group-without-counters",
|
||||||
|
group_key: "9781632663|28.04.26",
|
||||||
|
order_numbers: ["СФ Т\\ЕА-26979"],
|
||||||
|
status: "ready_for_notification",
|
||||||
|
delivery_status: "pending_confirmation",
|
||||||
|
created_at: "2026-05-05 09:43:53.750061+00",
|
||||||
|
updated_at: "2026-05-05 09:43:53.750061+00",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(group.ordersCount).toBe(1);
|
||||||
|
expect(group.readyCount).toBe(1);
|
||||||
|
expect(group.notReadyCount).toBe(0);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the focused test**
|
||||||
|
|
||||||
|
Run: `npm test -- --run src/services/supabase/orderGroupRepository.test.js`
|
||||||
|
|
||||||
|
Expected before implementation: FAIL with `0` counters.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement fallback counters**
|
||||||
|
|
||||||
|
Use `order_numbers.length` as a fallback for total count. For `status === "ready_for_notification"`, infer `readyCount` as `ordersCount` when explicit ready counters are absent.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const orderNumbers = toStringArray(row.order_numbers);
|
||||||
|
const inferredOrderCount = orderNumbers.length;
|
||||||
|
const ordersCount = toNumber(row.orders_count ?? row.orders_total ?? row.legacy_orders_total, inferredOrderCount);
|
||||||
|
const readyCount = toNumber(
|
||||||
|
row.ready_count ?? row.orders_ready ?? row.legacy_orders_ready,
|
||||||
|
row.status === "ready_for_notification" ? ordersCount : 0,
|
||||||
|
);
|
||||||
|
const notReadyCount = toNumber(
|
||||||
|
row.not_ready_count ?? row.orders_not_ready ?? row.legacy_orders_not_ready,
|
||||||
|
Math.max(ordersCount - readyCount, 0),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run mapping tests**
|
||||||
|
|
||||||
|
Run: `npm test -- --run src/services/supabase/orderGroupRepository.test.js`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 2: Manual Agreement UI
|
||||||
|
|
||||||
|
### Task 3: Replace Native Date Input With Themed Future-Date Picker
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/orders/OrderDetailPanel.jsx`
|
||||||
|
- Test: `src/components/orders/OrderDetailPanel.test.jsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add date helper functions**
|
||||||
|
|
||||||
|
Add local helpers near `normalizeDateForInput`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const toDateKey = (date) => {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addDays = (date, amount) => {
|
||||||
|
const nextDate = new Date(date);
|
||||||
|
nextDate.setDate(nextDate.getDate() + amount);
|
||||||
|
return nextDate;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTomorrowDateKey = () => toDateKey(addDays(new Date(), 1));
|
||||||
|
const isFutureDeliveryDate = (value) => Boolean(value) && value >= getTomorrowDateKey();
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Default the editable form to tomorrow**
|
||||||
|
|
||||||
|
When the selected group has no valid future `deliveryDate`, initialize the manual form with tomorrow.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const normalizedDeliveryDate = normalizeDateForInput(order?.deliveryDate);
|
||||||
|
setDeliveryDate(isFutureDeliveryDate(normalizedDeliveryDate) ? normalizedDeliveryDate : getTomorrowDateKey());
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Replace `<Input type="date">`**
|
||||||
|
|
||||||
|
Render a styled button that opens a compact 21-day date grid. The grid should use app CSS variables: `--color-card`, `--color-surface`, `--color-border`, `--color-accent`, `--color-accent-soft`, `--color-text`, and `--color-text-muted`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Test editable controls**
|
||||||
|
|
||||||
|
Update `OrderDetailPanel.test.jsx` so editable markup includes:
|
||||||
|
|
||||||
|
```js
|
||||||
|
expect(editableMarkup).toContain("Ближайшие даты");
|
||||||
|
expect(editableMarkup).toContain("Согласовать");
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run component test**
|
||||||
|
|
||||||
|
Run: `npm test -- --run src/components/orders/OrderDetailPanel.test.jsx`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 4: Replace Native Time Select With Themed Segmented Buttons
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/orders/OrderDetailPanel.jsx`
|
||||||
|
- Test: `src/components/orders/OrderDetailPanel.test.jsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove `Select` import from `OrderDetailPanel.jsx`**
|
||||||
|
|
||||||
|
The manual agreement block should no longer use the native dropdown.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Render time options as buttons**
|
||||||
|
|
||||||
|
Use `DELIVERY_TIME_OPTIONS`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const DELIVERY_TIME_OPTIONS = ["Первая половина дня", "Вторая половина дня"];
|
||||||
|
```
|
||||||
|
|
||||||
|
Each option should be a `button type="button"` with `aria-pressed={deliveryTime === option}` and selected styling through app CSS variables.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Ensure mobile layout is comfortable**
|
||||||
|
|
||||||
|
Use a responsive grid:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run component test**
|
||||||
|
|
||||||
|
Run: `npm test -- --run src/components/orders/OrderDetailPanel.test.jsx`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 3: Validation And Server Enforcement
|
||||||
|
|
||||||
|
### Task 5: Block Today And Past Dates In The UI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/orders/OrderDetailPanel.jsx`
|
||||||
|
- Test: `src/components/orders/OrderDetailPanel.test.jsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add submit validation**
|
||||||
|
|
||||||
|
Before calling `onSaveManualDeliveryChoice`, check:
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (!isFutureDeliveryDate(deliveryDate)) {
|
||||||
|
setFormMessage("Выберите дату доставки позже сегодняшнего дня.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add a client-side interaction test if the test setup supports events**
|
||||||
|
|
||||||
|
If this component is only tested with `renderToStaticMarkup`, keep validation covered by a server-side Edge Function test/check instead. Do not add a brittle DOM test just to satisfy coverage.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run component tests**
|
||||||
|
|
||||||
|
Run: `npm test -- --run src/components/orders/OrderDetailPanel.test.jsx`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 6: Enforce Future Dates In Edge Function
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `supabase/functions/update-order-group-delivery-choice/index.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add date comparison helpers**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const getTodayKey = () => new Date().toISOString().slice(0, 10);
|
||||||
|
const isFutureDeliveryDate = (value: string) => isValidDate(value) && value > getTodayKey();
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace date validation**
|
||||||
|
|
||||||
|
Change:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if (!isValidDate(deliveryDate)) {
|
||||||
|
return jsonResponse({ ok: false, error: "Valid deliveryDate is required" }, 400, corsHeaders);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if (!isFutureDeliveryDate(deliveryDate)) {
|
||||||
|
return jsonResponse({ ok: false, error: "Future deliveryDate is required" }, 400, corsHeaders);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run Deno check**
|
||||||
|
|
||||||
|
Run: `deno check supabase/functions/update-order-group-delivery-choice/index.ts`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Deploy function after local verification**
|
||||||
|
|
||||||
|
Run when ready:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
supabase functions deploy update-order-group-delivery-choice
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: deployed function rejects today/past dates even if someone bypasses the UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 4: Detail Card Cleanup
|
||||||
|
|
||||||
|
### Task 7: Remove Confusing Legacy Fields From The Card
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/orders/OrderDetailPanel.jsx`
|
||||||
|
- Test: `src/components/orders/OrderDetailPanel.test.jsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Change empty value wording**
|
||||||
|
|
||||||
|
Use `Нет данных` for generic missing data instead of `Не указано`, except for binary fields where the user expects `Да` or `Нет`.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const renderValue = (value) => value || "Нет данных";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Show SMS as a binary value**
|
||||||
|
|
||||||
|
Change the SMS field:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<p className="mt-1 font-medium">{order.smsSentAt ? "Да" : "Нет"}</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Hide legacy customer**
|
||||||
|
|
||||||
|
Remove the visible `Клиент из старых данных` field from the card.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Hide empty technical fields**
|
||||||
|
|
||||||
|
Only render `Создано из обмена` and `Ключ источника` when values exist. Do not show `Нет данных` for these technical fields.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run component tests**
|
||||||
|
|
||||||
|
Run: `npm test -- --run src/components/orders/OrderDetailPanel.test.jsx`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 5: Verification
|
||||||
|
|
||||||
|
### Task 8: Run Focused Tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: `src/components/orders/OrderDetailPanel.test.jsx`
|
||||||
|
- Test: `src/services/supabase/orderGroupRepository.test.js`
|
||||||
|
- Test: `src/pages/DashboardPage.test.jsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run focused frontend tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test -- --run src/components/orders/OrderDetailPanel.test.jsx src/services/supabase/orderGroupRepository.test.js src/pages/DashboardPage.test.jsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run Edge Function type check**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
deno check supabase/functions/update-order-group-delivery-choice/index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run production build**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 9: Manual Browser Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Manual check: `http://localhost:5174/dashboard`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Open an order group as manager/logistician**
|
||||||
|
|
||||||
|
Expected: card shows order counters based on available orders, not misleading `0/0` when `order_numbers` has values.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Check manual agreement block**
|
||||||
|
|
||||||
|
Expected: date picker starts from tomorrow, not today or an old customer date.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Select date and half-day**
|
||||||
|
|
||||||
|
Expected: controls visually match dark/light theme, no native browser dropdown styling dominates the UI.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Save manual agreement**
|
||||||
|
|
||||||
|
Expected: valid future date saves; today/past date cannot be sent from UI.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Check additional data block**
|
||||||
|
|
||||||
|
Expected: `SMS отправлено` shows `Да` or `Нет`; no `Клиент из старых данных`; empty technical fields are hidden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit Plan
|
||||||
|
|
||||||
|
- [ ] **Commit 1: Data mapping**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/services/supabase/orderGroupRepository.js src/services/supabase/orderGroupRepository.test.js
|
||||||
|
git commit -m "fix(order-groups): normalize delivery group counters"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Commit 2: Manual agreement UI**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/components/orders/OrderDetailPanel.jsx src/components/orders/OrderDetailPanel.test.jsx
|
||||||
|
git commit -m "feat(order-groups): improve manual delivery agreement"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Commit 3: Edge Function validation**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add supabase/functions/update-order-group-delivery-choice/index.ts
|
||||||
|
git commit -m "fix(edge): require future delivery dates"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollout Notes
|
||||||
|
|
||||||
|
- The frontend change is immediate after deploy.
|
||||||
|
- The Edge Function must be deployed separately with `supabase functions deploy update-order-group-delivery-choice`.
|
||||||
|
- Existing rows with old `delivery_date` values will still contain those dates in the database. This plan prevents new manual agreements from writing today or past dates.
|
||||||
|
- Temporary open RLS used for testing should be revisited before production hardening.
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
# Themed Status Dropdown Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace the native status `<select>` in the delivery filters with a themed dropdown that matches the app styling and does not expose browser-default chrome.
|
||||||
|
|
||||||
|
**Architecture:** Keep the change local to the existing filters component so behavior stays the same while the rendering shifts from a native form control to an app-styled button plus floating menu. Reuse the existing filter state and active-chip logic so downstream list filtering does not change.
|
||||||
|
|
||||||
|
**Tech Stack:** React 18, Vitest, Tailwind utility classes with CSS variables.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- Modify: `src/components/orders/OrderFilters.jsx`
|
||||||
|
Responsible for rendering the search field and the themed status dropdown, including open/close behavior and option selection.
|
||||||
|
|
||||||
|
- Modify: `src/components/orders/OrderFilters.test.jsx`
|
||||||
|
Server-rendered tests for the new dropdown markup and the existing active-chip behavior.
|
||||||
|
|
||||||
|
## Chunk 1: Themed Dropdown Rendering
|
||||||
|
|
||||||
|
### Task 1: Replace the Native `<select>` With a Custom Dropdown
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/orders/OrderFilters.jsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the status `<select>` with a button-triggered dropdown**
|
||||||
|
|
||||||
|
Use the existing `statusOptions` array and `filters.displayStatus` state, but render a button that opens a floating menu of options instead of the browser's native select chrome.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Keep the current filter semantics unchanged**
|
||||||
|
|
||||||
|
Selecting an option must still call `setFilters((current) => ({ ...current, displayStatus: value }))`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Keep the component visually aligned with the app theme**
|
||||||
|
|
||||||
|
Use the same rounded borders, surface/background colors, accent border on focus/open, and muted text used elsewhere in the dashboard.
|
||||||
|
|
||||||
|
### Task 2: Update the Component Test
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/orders/OrderFilters.test.jsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update the expectation that currently checks for `<select>`**
|
||||||
|
|
||||||
|
Assert that the markup no longer contains a native `select` element.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add a markup assertion for the custom trigger**
|
||||||
|
|
||||||
|
Verify that the closed state still renders the selected label and the status control remains present.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the focused test**
|
||||||
|
|
||||||
|
Run: `npm test -- --run src/components/orders/OrderFilters.test.jsx`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 3: Build Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- None
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run the production build**
|
||||||
|
|
||||||
|
Run: `npm run build`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
@ -1,17 +1,18 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { cn } from "../../lib/cn";
|
import { cn } from "../../lib/cn";
|
||||||
|
|
||||||
export const Badge = ({ children, tone = "neutral" }) => {
|
export const Badge = ({ children, tone = "neutral", className }) => {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex rounded-full px-3 py-1 text-xs font-semibold",
|
"inline-flex items-center justify-center rounded-full border px-3 py-1 text-center text-xs font-semibold leading-tight tracking-[0.01em] shadow-sm",
|
||||||
{
|
{
|
||||||
"bg-[var(--color-accent-soft)] text-[var(--color-accent)]": tone === "accent",
|
"border-[rgba(18,128,92,0.18)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]": tone === "accent",
|
||||||
"bg-[rgba(201,61,61,0.12)] text-[var(--color-danger)]": tone === "danger",
|
"border-[rgba(201,61,61,0.22)] bg-[rgba(201,61,61,0.12)] text-[var(--color-danger)]": tone === "danger",
|
||||||
"bg-[rgba(191,123,33,0.12)] text-[var(--color-warning)]": tone === "warning",
|
"border-[rgba(191,123,33,0.22)] bg-[rgba(191,123,33,0.12)] text-[var(--color-warning)]": tone === "warning",
|
||||||
"bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]": tone === "neutral",
|
"border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)]": tone === "neutral",
|
||||||
},
|
},
|
||||||
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { getInvitationReferenceLabel } from "./invitationReference";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
import { Button } from "../UI/Button";
|
import { Button } from "../UI/Button";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
|
|
@ -51,12 +52,10 @@ export const DeliveryChoiceFlow = ({
|
||||||
invitation = {},
|
invitation = {},
|
||||||
selectedSlot = null,
|
selectedSlot = null,
|
||||||
onConfirmChoice = () => {},
|
onConfirmChoice = () => {},
|
||||||
onRequestNewLink = () => {},
|
|
||||||
}) => {
|
}) => {
|
||||||
const state = invitation.state || "awaiting_choice";
|
const state = invitation.state || "awaiting_choice";
|
||||||
const isActive = ACTIVE_STATES.has(state);
|
const isActive = ACTIVE_STATES.has(state);
|
||||||
const orderNumber = invitation.orderNumber || "—";
|
const invitationReference = getInvitationReferenceLabel(invitation);
|
||||||
const customerName = invitation.customerName || "Клиент";
|
|
||||||
const orderItems = (invitation.orderItems || invitation.items || [])
|
const orderItems = (invitation.orderItems || invitation.items || [])
|
||||||
.map(splitOrderItem)
|
.map(splitOrderItem)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
@ -79,7 +78,7 @@ export const DeliveryChoiceFlow = ({
|
||||||
<Badge tone="warning">{STATE_LABELS[state]}</Badge>
|
<Badge tone="warning">{STATE_LABELS[state]}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
Заказ {orderNumber} для {customerName}. Проверьте состав заказа и выберите удобную половину дня.
|
{invitationReference}. Проверьте состав заказа и выберите удобную половину дня.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -108,9 +107,6 @@ export const DeliveryChoiceFlow = ({
|
||||||
>
|
>
|
||||||
Сохранить
|
Сохранить
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" className="w-full sm:w-auto" onClick={onRequestNewLink}>
|
|
||||||
Запросить новую ссылку
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,13 @@ describe("DeliveryChoiceFlow", () => {
|
||||||
customerName: "Мария Волкова",
|
customerName: "Мария Волкова",
|
||||||
}}
|
}}
|
||||||
onConfirmChoice={() => {}}
|
onConfirmChoice={() => {}}
|
||||||
onRequestNewLink={() => {}}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(markup).toContain("Выберите время доставки");
|
expect(markup).toContain("Выберите время доставки");
|
||||||
expect(markup).toContain("Сохранить");
|
expect(markup).toContain("Сохранить");
|
||||||
expect(markup).toContain("Ожидает ответа клиента");
|
expect(markup).toContain("Ожидает ответа клиента");
|
||||||
|
expect(markup).not.toContain("Запросить новую ссылку");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a disabled save action when nothing is selected", () => {
|
it("renders a disabled save action when nothing is selected", () => {
|
||||||
|
|
@ -31,7 +31,6 @@ describe("DeliveryChoiceFlow", () => {
|
||||||
customerName: "Мария Волкова",
|
customerName: "Мария Волкова",
|
||||||
}}
|
}}
|
||||||
onConfirmChoice={() => {}}
|
onConfirmChoice={() => {}}
|
||||||
onRequestNewLink={() => {}}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -54,7 +53,6 @@ describe("DeliveryChoiceFlow", () => {
|
||||||
availableSlots: ["Первая половина дня", "Вторая половина дня"],
|
availableSlots: ["Первая половина дня", "Вторая половина дня"],
|
||||||
}}
|
}}
|
||||||
onConfirmChoice={() => {}}
|
onConfirmChoice={() => {}}
|
||||||
onRequestNewLink={() => {}}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -75,7 +73,6 @@ describe("DeliveryChoiceFlow", () => {
|
||||||
customerName: "Мария Волкова",
|
customerName: "Мария Волкова",
|
||||||
}}
|
}}
|
||||||
onConfirmChoice={() => {}}
|
onConfirmChoice={() => {}}
|
||||||
onRequestNewLink={() => {}}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -92,7 +89,6 @@ describe("DeliveryChoiceFlow", () => {
|
||||||
customerName: "Мария Волкова",
|
customerName: "Мария Волкова",
|
||||||
}}
|
}}
|
||||||
onConfirmChoice={() => {}}
|
onConfirmChoice={() => {}}
|
||||||
onRequestNewLink={() => {}}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -110,11 +106,10 @@ describe("DeliveryChoiceFlow", () => {
|
||||||
availableSlots: ["15 апреля, первая половина дня"],
|
availableSlots: ["15 апреля, первая половина дня"],
|
||||||
}}
|
}}
|
||||||
onConfirmChoice={() => {}}
|
onConfirmChoice={() => {}}
|
||||||
onRequestNewLink={() => {}}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(markup).toContain("CD-240032");
|
expect(markup).toContain("Счет CD-240032");
|
||||||
expect(markup).toContain("Александр Савин");
|
expect(markup).not.toContain("Александр Савин");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,20 @@ import { formatDeliverySlotGroupLabel } from "./deliveryDateFormatting";
|
||||||
const groupSlotsByDate = (slots) => {
|
const groupSlotsByDate = (slots) => {
|
||||||
const groups = new Map();
|
const groups = new Map();
|
||||||
|
|
||||||
|
const getSlotPriority = (slot) => {
|
||||||
|
const time = String(slot?.time || "").toLowerCase();
|
||||||
|
|
||||||
|
if (time.includes("первая") || time.includes("до обеда")) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (time.includes("вторая") || time.includes("после обеда")) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 2;
|
||||||
|
};
|
||||||
|
|
||||||
for (const slot of slots) {
|
for (const slot of slots) {
|
||||||
if (!groups.has(slot.date)) {
|
if (!groups.has(slot.date)) {
|
||||||
groups.set(slot.date, []);
|
groups.set(slot.date, []);
|
||||||
|
|
@ -14,7 +28,12 @@ const groupSlotsByDate = (slots) => {
|
||||||
groups.get(slot.date).push(slot);
|
groups.get(slot.date).push(slot);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(groups.entries()).sort(([a], [b]) => a.localeCompare(b));
|
return Array.from(groups.entries())
|
||||||
|
.map(([date, dateSlots]) => [
|
||||||
|
date,
|
||||||
|
[...dateSlots].sort((left, right) => getSlotPriority(left) - getSlotPriority(right)),
|
||||||
|
])
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b));
|
||||||
};
|
};
|
||||||
|
|
||||||
export { formatDeliverySlotGroupLabel } from "./deliveryDateFormatting";
|
export { formatDeliverySlotGroupLabel } from "./deliveryDateFormatting";
|
||||||
|
|
@ -37,13 +56,6 @@ export const DeliverySlotsPicker = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Panel className="p-5 sm:p-6">
|
|
||||||
<h3 className="text-lg font-semibold">Выберите день и половину дня доставки</h3>
|
|
||||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
|
||||||
Раскройте нужный день, выберите подходящую половину и затем сохраните выбор ниже.
|
|
||||||
</p>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
{grouped.map(([date, dateSlots]) => (
|
{grouped.map(([date, dateSlots]) => (
|
||||||
<details key={date} className="group rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-soft backdrop-blur" open>
|
<details key={date} className="group rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-soft backdrop-blur" open>
|
||||||
<summary className="cursor-pointer list-none p-5 sm:p-6">
|
<summary className="cursor-pointer list-none p-5 sm:p-6">
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,23 @@ describe("DeliverySlotsPicker", () => {
|
||||||
expect(markup).toContain("послезавтра · 15.04.2026");
|
expect(markup).toContain("послезавтра · 15.04.2026");
|
||||||
expect(markup).toContain("первая половина дня");
|
expect(markup).toContain("первая половина дня");
|
||||||
expect(markup).toContain("вторая половина дня");
|
expect(markup).toContain("вторая половина дня");
|
||||||
|
expect(markup).not.toContain("выберите день и половину дня доставки");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the first half of day before the second half", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<DeliverySlotsPicker
|
||||||
|
slots={[
|
||||||
|
{ date: "2026-04-14", time: "Вторая половина дня", id: "slot-2" },
|
||||||
|
{ date: "2026-04-14", time: "Первая половина дня", id: "slot-1" },
|
||||||
|
]}
|
||||||
|
onSelectSlot={() => {}}
|
||||||
|
selectedSlotId={null}
|
||||||
|
referenceDate={new Date("2026-04-13T09:00:00Z")}
|
||||||
|
/>,
|
||||||
|
).toLowerCase();
|
||||||
|
|
||||||
|
expect(markup.indexOf("первая половина дня")).toBeLessThan(markup.indexOf("вторая половина дня"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks the selected slot", () => {
|
it("marks the selected slot", () => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
const getOrderItemNames = (invitation) => {
|
||||||
|
const rawItems = Array.isArray(invitation?.orderItems)
|
||||||
|
? invitation.orderItems
|
||||||
|
: Array.isArray(invitation?.items)
|
||||||
|
? invitation.items
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return rawItems
|
||||||
|
.map((item) => {
|
||||||
|
if (typeof item === "string") {
|
||||||
|
return item.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item && typeof item === "object" && typeof item.name === "string") {
|
||||||
|
return item.name.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getInvitationReferenceLabel = (invitation) => {
|
||||||
|
const invoiceNumbers = [...new Set(getOrderItemNames(invitation))];
|
||||||
|
if (invoiceNumbers.length === 1) {
|
||||||
|
return `Счет ${invoiceNumbers[0]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoiceNumbers.length > 1) {
|
||||||
|
return `Счета: ${invoiceNumbers.join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderNumber = typeof invitation?.orderNumber === "string" ? invitation.orderNumber.trim() : "";
|
||||||
|
if (orderNumber) {
|
||||||
|
return `Счет ${orderNumber}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Счет —";
|
||||||
|
};
|
||||||
|
|
@ -1,29 +1,136 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { getAvailableTransitionsByRole, getStatusTone } from "../../constants/deliveryWorkflow";
|
import {
|
||||||
import { groupDriverDeliveriesByDate, getDeliveryCity, getDeliveryHalfDay } from "../../services/driverDeliveries";
|
filterOrderGroups,
|
||||||
|
getOrderGroupDeliveryHalfDay,
|
||||||
|
getOrderGroupDeliveryStatusLabel,
|
||||||
|
getOrderGroupDeliveryStatusTone,
|
||||||
|
ORDER_GROUP_DELIVERY_HALF_DAY_OPTIONS,
|
||||||
|
DRIVER_VISIBLE_DELIVERY_STATUSES,
|
||||||
|
isOrderGroupVisibleToDriver,
|
||||||
|
groupOrderGroupsByDate,
|
||||||
|
} from "../../services/orderGroupViews";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
import { Button } from "../UI/Button";
|
import { Input } from "../UI/Input";
|
||||||
|
import { Select } from "../UI/Select";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
|
|
||||||
export const DriverDeliveryPlanner = ({ orders, onOpenOrder, onStatusChange }) => {
|
const DRIVER_DELIVERY_STATUS_OPTIONS = [
|
||||||
const groupedOrders = React.useMemo(() => groupDriverDeliveriesByDate(orders), [orders]);
|
{ value: "all", label: "Все статусы" },
|
||||||
|
...DRIVER_VISIBLE_DELIVERY_STATUSES.map((status) => ({
|
||||||
|
value: status,
|
||||||
|
label: getOrderGroupDeliveryStatusLabel(status),
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DriverDeliveryPlanner = ({ orderGroups = [], onOpenOrder }) => {
|
||||||
|
const [filters, setFilters] = React.useState({
|
||||||
|
dateFrom: "",
|
||||||
|
dateTo: "",
|
||||||
|
deliveryHalfDay: "all",
|
||||||
|
deliveryStatus: "all",
|
||||||
|
});
|
||||||
|
|
||||||
|
const agreedOrderGroups = React.useMemo(
|
||||||
|
() => orderGroups.filter((group) => isOrderGroupVisibleToDriver(group)),
|
||||||
|
[orderGroups],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredOrderGroups = React.useMemo(
|
||||||
|
() =>
|
||||||
|
filterOrderGroups(agreedOrderGroups, {
|
||||||
|
dateFrom: filters.dateFrom,
|
||||||
|
dateTo: filters.dateTo,
|
||||||
|
deliveryHalfDay: filters.deliveryHalfDay,
|
||||||
|
deliveryStatus: filters.deliveryStatus,
|
||||||
|
}),
|
||||||
|
[agreedOrderGroups, filters.dateFrom, filters.dateTo, filters.deliveryHalfDay, filters.deliveryStatus],
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupedOrderGroups = React.useMemo(
|
||||||
|
() => groupOrderGroupsByDate(filteredOrderGroups),
|
||||||
|
[filteredOrderGroups],
|
||||||
|
);
|
||||||
|
const deliveryCountLabel = `${filteredOrderGroups.length} ${
|
||||||
|
filteredOrderGroups.length === 1 ? "доставка" : filteredOrderGroups.length < 5 ? "доставки" : "доставок"
|
||||||
|
}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Panel className="space-y-3 p-5">
|
<Panel className="space-y-3 p-5">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="space-y-4">
|
||||||
<div>
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<h3 className="text-lg font-semibold">Мои доставки</h3>
|
<div>
|
||||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
Список доставок с адресом, клиентом, составом заказа и базовыми действиями по статусу.
|
<h3 className="text-lg font-semibold">Мои доставки</h3>
|
||||||
</p>
|
<Badge tone="neutral">{deliveryCountLabel}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
|
Показываем только согласованные к доставке группы. Можно сузить список по дате и половине дня.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-[repeat(4,minmax(0,1fr))]">
|
||||||
|
<label className="flex min-w-0 flex-col gap-2">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||||
|
Дата от
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={filters.dateFrom}
|
||||||
|
onChange={(event) => setFilters((current) => ({ ...current, dateFrom: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex min-w-0 flex-col gap-2">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||||
|
Дата до
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={filters.dateTo}
|
||||||
|
onChange={(event) => setFilters((current) => ({ ...current, dateTo: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex min-w-0 flex-col gap-2">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||||
|
Время суток
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
value={filters.deliveryHalfDay}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFilters((current) => ({ ...current, deliveryHalfDay: event.target.value }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ORDER_GROUP_DELIVERY_HALF_DAY_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
<label className="flex min-w-0 flex-col gap-2">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||||
|
Статус
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
value={filters.deliveryStatus}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFilters((current) => ({ ...current, deliveryStatus: event.target.value }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{DRIVER_DELIVERY_STATUS_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<Badge tone="neutral">{orders.length}</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{groupedOrders.length ? (
|
{groupedOrderGroups.length ? (
|
||||||
groupedOrders.map((group) => (
|
groupedOrderGroups.map((group) => (
|
||||||
<Panel key={group.date} className="space-y-4 p-5">
|
<Panel key={group.date} className="space-y-4 p-5">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -35,60 +142,44 @@ export const DriverDeliveryPlanner = ({ orders, onOpenOrder, onStatusChange }) =
|
||||||
})}
|
})}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
{group.items.length} {group.items.length === 1 ? "доставка" : "доставки"}
|
{group.items.length} {group.items.length === 1 ? "группа" : "группы"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge tone="neutral">{group.date}</Badge>
|
<Badge tone="neutral">{group.date}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
{group.items.map((order) => {
|
{group.items.map((item) => (
|
||||||
const availableTransitions = getAvailableTransitionsByRole({
|
<button
|
||||||
status: order.status,
|
key={item.id}
|
||||||
role: "driver",
|
type="button"
|
||||||
});
|
className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-left transition hover:bg-[var(--color-accent-soft)]"
|
||||||
|
onClick={() => onOpenOrder?.(item.id)}
|
||||||
return (
|
>
|
||||||
<button
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
key={order.id}
|
<div>
|
||||||
type="button"
|
<div className="font-medium text-[var(--color-text)]">
|
||||||
className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-left transition hover:bg-[var(--color-accent-soft)]"
|
{item.displayTitle || item.customerName || item.groupKey}
|
||||||
onClick={() => onOpenOrder?.(order.id)}
|
</div>
|
||||||
>
|
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
{item.customerDate} · {item.customerPhone}
|
||||||
<div>
|
{getOrderGroupDeliveryHalfDay(item) ? ` · ${getOrderGroupDeliveryHalfDay(item)}` : ""}
|
||||||
<div className="font-medium">{order.customer.address}</div>
|
|
||||||
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
|
|
||||||
{order.orderNumber} · {order.customer.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Badge tone={getStatusTone(order.status)}>{order.status}</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Badge tone={getOrderGroupDeliveryStatusTone(item.deliveryStatus || item.delivery_status)}>
|
||||||
|
{getOrderGroupDeliveryStatusLabel(item.deliveryStatus || item.delivery_status)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 grid gap-2 text-sm text-[var(--color-text-muted)] md:grid-cols-3">
|
<div className="mt-3 grid gap-2 text-sm text-[var(--color-text-muted)] md:grid-cols-3">
|
||||||
<div>{getDeliveryCity(order)}</div>
|
<div>{item.orderNumbers?.[0] || "Номера не указаны"}</div>
|
||||||
<div>{getDeliveryHalfDay(order)}</div>
|
<div>
|
||||||
<div>{order.customer.phone}</div>
|
{item.readyCount || 0}/{item.ordersCount || 0} готово
|
||||||
</div>
|
</div>
|
||||||
|
<div>{item.smsSentAt ? "SMS отправлено" : "SMS не отправлено"}</div>
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
</div>
|
||||||
{availableTransitions.map((status) => (
|
</button>
|
||||||
<Button
|
))}
|
||||||
key={status}
|
|
||||||
size="sm"
|
|
||||||
variant={status === "Проблема доставки" ? "ghost" : "secondary"}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
onStatusChange?.(order.id, status);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{status}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
))
|
))
|
||||||
|
|
@ -96,7 +187,7 @@ export const DriverDeliveryPlanner = ({ orders, onOpenOrder, onStatusChange }) =
|
||||||
<Panel className="p-6">
|
<Panel className="p-6">
|
||||||
<h4 className="text-lg font-semibold">Доставки не найдены</h4>
|
<h4 className="text-lg font-semibold">Доставки не найдены</h4>
|
||||||
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
|
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
|
||||||
Сейчас у вас нет назначенных доставок.
|
Сейчас у вас нет назначенных групп доставки.
|
||||||
</p>
|
</p>
|
||||||
</Panel>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,41 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { DriverDeliveryPlanner } from "./DriverDeliveryPlanner";
|
import { DriverDeliveryPlanner } from "./DriverDeliveryPlanner";
|
||||||
|
|
||||||
const orders = [
|
const orderGroups = [
|
||||||
{
|
{
|
||||||
id: "driver-order-1",
|
id: "driver-order-1",
|
||||||
orderNumber: "CD-240031",
|
groupKey: "9780001231|16.04.26",
|
||||||
status: "К доставке",
|
displayTitle: "Мария Волкова",
|
||||||
scheduledDelivery: "2026-04-16T12:00:00Z",
|
displaySubtitle: "+7 978 000-12-31 · 16.04.26",
|
||||||
customer: {
|
customerName: "Мария Волкова",
|
||||||
name: "Мария Волкова",
|
customerPhone: "+7 978 000-12-31",
|
||||||
address: "Симферополь, ул. Ленина, 10",
|
customerDate: "16.04.26",
|
||||||
phone: "+7 978 000-12-31",
|
orderNumbers: ["CD-240031"],
|
||||||
},
|
ordersCount: 1,
|
||||||
orderNotes: [{ text: "Подъезд узкий" }],
|
readyCount: 1,
|
||||||
comments: ["Позвонить за час"],
|
notReadyCount: 0,
|
||||||
driverRouteOrder: 1,
|
status: "ready_for_notification",
|
||||||
|
deliveryStatus: "agreed",
|
||||||
|
deliveryHalfDay: "Первая половина дня",
|
||||||
|
smsSentAt: null,
|
||||||
|
updatedAt: "2026-04-16T12:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "driver-order-2",
|
||||||
|
groupKey: "9780001232|16.04.26",
|
||||||
|
displayTitle: "Не показывать",
|
||||||
|
customerName: "Не показывать",
|
||||||
|
customerPhone: "+7 978 000-12-32",
|
||||||
|
customerDate: "16.04.26",
|
||||||
|
orderNumbers: ["CD-240032"],
|
||||||
|
ordersCount: 1,
|
||||||
|
readyCount: 0,
|
||||||
|
notReadyCount: 1,
|
||||||
|
status: "manual_work",
|
||||||
|
deliveryStatus: "pending_confirmation",
|
||||||
|
deliveryHalfDay: "Вторая половина дня",
|
||||||
|
smsSentAt: null,
|
||||||
|
updatedAt: "2026-04-16T13:00:00Z",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -24,16 +45,20 @@ describe("DriverDeliveryPlanner", () => {
|
||||||
it("renders a simple delivery list without kanban or route editing", () => {
|
it("renders a simple delivery list without kanban or route editing", () => {
|
||||||
const markup = renderToStaticMarkup(
|
const markup = renderToStaticMarkup(
|
||||||
<DriverDeliveryPlanner
|
<DriverDeliveryPlanner
|
||||||
orders={orders}
|
orderGroups={orderGroups}
|
||||||
onOpenOrder={() => {}}
|
onOpenOrder={() => {}}
|
||||||
onStatusChange={() => {}}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(markup).toContain("Мои доставки");
|
expect(markup).toContain("Мои доставки");
|
||||||
expect(markup).toContain("CD-240031");
|
expect(markup).toContain("1 доставка");
|
||||||
expect(markup).toContain("Мария Волкова");
|
expect(markup).toContain("Мария Волкова");
|
||||||
expect(markup).toContain("Симферополь, ул. Ленина, 10");
|
expect(markup).toContain("CD-240031");
|
||||||
|
expect(markup).not.toContain("Не показывать");
|
||||||
|
expect(markup).toContain("Дата от");
|
||||||
|
expect(markup).toContain("Время суток");
|
||||||
|
expect(markup).toContain("Статус");
|
||||||
|
expect(markup).toContain("Согласовано");
|
||||||
expect(markup).not.toContain("Канбан");
|
expect(markup).not.toContain("Канбан");
|
||||||
expect(markup).not.toContain("Перетащите");
|
expect(markup).not.toContain("Перетащите");
|
||||||
expect(markup).not.toContain("Календарь");
|
expect(markup).not.toContain("Календарь");
|
||||||
|
|
|
||||||
|
|
@ -1,124 +1,14 @@
|
||||||
import React from "react";
|
|
||||||
import { Badge } from "../UI/Badge";
|
|
||||||
import { Button } from "../UI/Button";
|
import { Button } from "../UI/Button";
|
||||||
import { Panel } from "../UI/Panel";
|
import { OrderDetailPanel } from "../orders/OrderDetailPanel";
|
||||||
import { DELIVERY_SET_BUCKET_LABELS } from "../../services/deliverySetViews";
|
|
||||||
|
|
||||||
const PRODUCTION_STEP_LABELS = {
|
|
||||||
sourceProductionAt: "\u0417\u0430\u043F\u0443\u0441\u043A \u043F\u0440\u043E\u0438\u0437\u0432\u043E\u0434\u0441\u0442\u0432\u0430",
|
|
||||||
sourceSawAt: "\u0420\u0430\u0441\u043A\u0440\u043E\u0439",
|
|
||||||
sourceGlueAt: "\u0421\u043A\u043B\u0435\u0439\u043A\u0430",
|
|
||||||
sourceHGlueAt: "H-\u0441\u043A\u043B\u0435\u0439\u043A\u0430",
|
|
||||||
sourceCurveAt: "\u041A\u0440\u0438\u0432\u043E\u043B\u0438\u043D\u0435\u0439\u043D\u044B\u0435",
|
|
||||||
sourceAcceptAt: "Контроль качества",
|
|
||||||
sourceShipAt: "\u041E\u0442\u0433\u0440\u0443\u0437\u043A\u0430",
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatStepDate = (iso) => {
|
|
||||||
if (!iso) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Date(iso).toLocaleDateString("ru-RU", {
|
|
||||||
day: "numeric",
|
|
||||||
month: "short",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DeliverySetDetailPanel = ({ deliverySet, onClose }) => {
|
export const DeliverySetDetailPanel = ({ deliverySet, onClose }) => {
|
||||||
if (!deliverySet) {
|
if (!deliverySet) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bucketLabel = DELIVERY_SET_BUCKET_LABELS[deliverySet.status] || deliverySet.status;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<Panel className="space-y-4 p-6">
|
<OrderDetailPanel order={deliverySet} />
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold">{deliverySet.name}</h3>
|
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">
|
|
||||||
{deliverySet.sourceCustomerCity || "\u2014"} \u00B7 {deliverySet.orderCount}{" "}
|
|
||||||
{deliverySet.orderCount === 1 ? "заказ" : deliverySet.orderCount < 5 ? "заказа" : "заказов"} в наборе
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Badge tone={deliverySet.status === "ready_to_launch" ? "accent" : "neutral"}>
|
|
||||||
{bucketLabel}
|
|
||||||
</Badge>
|
|
||||||
{deliverySet.readyAt ? (
|
|
||||||
<Badge tone="neutral">
|
|
||||||
Готов с {formatStepDate(deliverySet.readyAt)}
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{deliverySet.linkedBillTexts ? (
|
|
||||||
<div className="text-sm text-[var(--color-text-muted)]">
|
|
||||||
Связанные счета: {deliverySet.linkedBillTexts}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{deliverySet.readyReason ? (
|
|
||||||
<div className="text-sm text-[var(--color-text-muted)]">
|
|
||||||
{deliverySet.readyReason === "all_accepted"
|
|
||||||
? "Все заказы набора прошли контроль качества, можно запускать доставку."
|
|
||||||
: "Не все заказы набора ещё прошли контроль качества."}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
{deliverySet.orders.map((order) => (
|
|
||||||
<Panel key={order.id} className="space-y-3 p-5">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="font-semibold">{order.orderNumber}</div>
|
|
||||||
{order.sourceFieldSummary?.sourceOrderNumber ? (
|
|
||||||
<div className="text-sm text-[var(--color-text-muted)]">
|
|
||||||
1С: {order.sourceFieldSummary.sourceOrderNumber}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<Badge tone="neutral">{order.status}</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2 md:grid-cols-3">
|
|
||||||
{Object.entries(PRODUCTION_STEP_LABELS).map(([key, label]) => {
|
|
||||||
const value = order.sourceFieldSummary?.[key];
|
|
||||||
if (!value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={key} className="text-sm">
|
|
||||||
<span className="text-[var(--color-text-muted)]">{label}:</span>{" "}
|
|
||||||
<span className="font-medium">{formatStepDate(value)}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{order.sourceFieldSummary?.sourceCustomerPhone ? (
|
|
||||||
<div className="text-sm text-[var(--color-text-muted)]">
|
|
||||||
\u260E {order.sourceFieldSummary.sourceCustomerPhone}
|
|
||||||
{order.sourceFieldSummary.sourceCustomerEmail
|
|
||||||
? ` \u00B7 ${order.sourceFieldSummary.sourceCustomerEmail}`
|
|
||||||
: ""}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{order.deliverySlots?.length ? (
|
|
||||||
<div className="text-sm">
|
|
||||||
<span className="text-[var(--color-text-muted)]">Слот:</span>{" "}
|
|
||||||
<span className="font-medium">
|
|
||||||
{order.deliverySlots[0].date} \u00B7 {order.deliverySlots[0].time}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</Panel>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{onClose ? (
|
{onClose ? (
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
|
|
|
||||||
|
|
@ -1,122 +1,138 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { DELIVERY_SET_BUCKET_LABELS } from "../../services/deliverySetViews";
|
import {
|
||||||
|
buildOrderGroupBuckets,
|
||||||
|
filterOrderGroups,
|
||||||
|
getOrderGroupDisplayStatusLabel,
|
||||||
|
getOrderGroupStatusTone,
|
||||||
|
ORDER_GROUP_BUCKET_LABELS,
|
||||||
|
ORDER_GROUP_DISPLAY_STATUS_OPTIONS,
|
||||||
|
} from "../../services/orderGroupViews";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
|
import { OrderFilters } from "../orders/OrderFilters";
|
||||||
const BUCKET_TONES = {
|
|
||||||
approaching: "neutral",
|
|
||||||
ready_to_launch: "accent",
|
|
||||||
awaiting_client: "warning",
|
|
||||||
manual_work: "danger",
|
|
||||||
agreed: "accent",
|
|
||||||
completed: "neutral",
|
|
||||||
};
|
|
||||||
|
|
||||||
const BUCKET_ICONS = {
|
const BUCKET_ICONS = {
|
||||||
approaching: "\u2192",
|
|
||||||
ready_to_launch: "\u2713",
|
ready_to_launch: "\u2713",
|
||||||
awaiting_client: "\u23F3",
|
sms_sent: "\u2709",
|
||||||
manual_work: "\u26A0",
|
manual_work: "\u26A0",
|
||||||
agreed: "\u2B50",
|
|
||||||
completed: "\u2714",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LogisticsReadinessBoard = ({ deliverySetBuckets, onSelectSet }) => {
|
const renderOrderNumbers = (group) => {
|
||||||
const bucketKeys = Object.keys(DELIVERY_SET_BUCKET_LABELS);
|
if (!Array.isArray(group.orderNumbers) || !group.orderNumbers.length) {
|
||||||
const buckets = deliverySetBuckets || {};
|
return <span>Номера не указаны</span>;
|
||||||
const totalSets = bucketKeys.reduce(
|
}
|
||||||
(sum, key) => sum + (buckets[key]?.length || 0),
|
|
||||||
0,
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{group.orderNumbers.map((number) => (
|
||||||
|
<span
|
||||||
|
key={number}
|
||||||
|
className="rounded-full bg-[var(--color-surface)] px-3 py-1 text-xs text-[var(--color-text-muted)]"
|
||||||
|
>
|
||||||
|
{number}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LogisticsReadinessBoard = ({ orderGroups = [], onSelectSet }) => {
|
||||||
|
const [filters, setFilters] = React.useState({ query: "", displayStatus: "all" });
|
||||||
|
|
||||||
|
const filteredGroups = React.useMemo(
|
||||||
|
() => filterOrderGroups(orderGroups, filters),
|
||||||
|
[filters, orderGroups],
|
||||||
|
);
|
||||||
|
const deliveryGroupBuckets = React.useMemo(
|
||||||
|
() => buildOrderGroupBuckets(filteredGroups),
|
||||||
|
[filteredGroups],
|
||||||
|
);
|
||||||
|
|
||||||
|
const bucketKeys = Object.keys(ORDER_GROUP_BUCKET_LABELS);
|
||||||
|
const buckets = deliveryGroupBuckets || {};
|
||||||
|
const totalGroups = filteredGroups.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Panel className="flex items-center justify-between p-5">
|
<Panel className="space-y-4 p-5">
|
||||||
<div>
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<h2 className="text-lg font-semibold">Наборы доставки</h2>
|
<div className="min-w-0">
|
||||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
<h2 className="text-lg font-semibold">Наборы доставки</h2>
|
||||||
Группировка импортированных заказов по клиентским наборам. Каждый набор запускается в доставку целиком после приёмки всех заказов.
|
</div>
|
||||||
</p>
|
<Badge tone="neutral">{totalGroups} групп</Badge>
|
||||||
</div>
|
</div>
|
||||||
<Badge tone="neutral">{totalSets} наборов</Badge>
|
|
||||||
|
<OrderFilters
|
||||||
|
filters={filters}
|
||||||
|
setFilters={setFilters}
|
||||||
|
statusOptions={ORDER_GROUP_DISPLAY_STATUS_OPTIONS}
|
||||||
|
/>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-2">
|
{!totalGroups ? (
|
||||||
{bucketKeys.map((bucketKey) => {
|
<Panel className="border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
|
||||||
const sets = buckets[bucketKey] || [];
|
По этому поиску ничего не найдено.
|
||||||
const label = DELIVERY_SET_BUCKET_LABELS[bucketKey];
|
</Panel>
|
||||||
const tone = BUCKET_TONES[bucketKey];
|
) : (
|
||||||
const icon = BUCKET_ICONS[bucketKey];
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
|
{bucketKeys.map((bucketKey) => {
|
||||||
|
const groups = buckets[bucketKey] || [];
|
||||||
|
const label = ORDER_GROUP_BUCKET_LABELS[bucketKey];
|
||||||
|
const icon = BUCKET_ICONS[bucketKey];
|
||||||
|
|
||||||
|
if (!groups.length) {
|
||||||
|
return (
|
||||||
|
<Panel key={bucketKey} className="p-5 opacity-50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-lg">{icon}</span>
|
||||||
|
<h3 className="font-semibold">{label}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-[var(--color-text-muted)]">Нет групп</p>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!sets.length) {
|
|
||||||
return (
|
return (
|
||||||
<Panel key={bucketKey} className="p-5 opacity-50">
|
<div key={bucketKey} className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-lg">{icon}</span>
|
<span className="text-lg">{icon}</span>
|
||||||
<h3 className="font-semibold">{label}</h3>
|
<h3 className="font-semibold">{label}</h3>
|
||||||
|
<Badge tone={bucketKey === "sms_sent" ? "accent" : "neutral"}>{groups.length}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-sm text-[var(--color-text-muted)]">Нет наборов</p>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
{groups.map((group) => (
|
||||||
<div key={bucketKey} className="space-y-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-lg">{icon}</span>
|
|
||||||
<h3 className="font-semibold">{label}</h3>
|
|
||||||
<Badge tone={tone}>{sets.length}</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{sets.map((set) => {
|
|
||||||
const setOrders = Array.isArray(set.orders) ? set.orders : [];
|
|
||||||
const orderCount = set.orderCount ?? setOrders.length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
<button
|
||||||
key={set.key}
|
key={group.id}
|
||||||
className="w-full rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-5 text-left transition hover:bg-[var(--color-accent-soft)]"
|
className="w-full rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 !text-left !text-[var(--color-text)] transition hover:bg-[var(--color-accent-soft)] sm:p-5"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (onSelectSet) {
|
if (onSelectSet) {
|
||||||
onSelectSet(set);
|
onSelectSet(group);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="space-y-2">
|
||||||
<div className="min-w-0">
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3">
|
||||||
<div className="truncate font-semibold">{set.name}</div>
|
<div className="break-words text-base font-semibold leading-tight !text-[var(--color-text)] sm:text-lg">
|
||||||
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
|
{group.displayTitle || group.customerName || group.groupKey}
|
||||||
{set.sourceCustomerCity || "\u2014"} \u00B7 {orderCount}{" "}
|
|
||||||
{orderCount === 1 ? "заказ" : orderCount < 5 ? "заказа" : "заказов"}
|
|
||||||
</div>
|
</div>
|
||||||
{set.linkedBillTexts ? (
|
<Badge className="self-start" tone={getOrderGroupStatusTone(group)}>
|
||||||
<div className="mt-1 text-xs text-[var(--color-text-muted)]">
|
{getOrderGroupDisplayStatusLabel(group)}
|
||||||
Связанные счета: {set.linkedBillTexts}
|
</Badge>
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<Badge tone={tone}>{label}</Badge>
|
<div className="text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
</div>
|
{group.customerDate || "—"} · {group.customerPhone || "—"} · {group.ordersCount || 0}{" "}
|
||||||
|
{group.ordersCount === 1 ? "заказ" : group.ordersCount < 5 ? "заказа" : "заказов"}
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
</div>
|
||||||
{setOrders.map((order) => (
|
<div>{renderOrderNumbers(group)}</div>
|
||||||
<span
|
|
||||||
key={order.id}
|
|
||||||
className="rounded-full bg-[var(--color-surface)] px-3 py-1 text-xs text-[var(--color-text-muted)]"
|
|
||||||
>
|
|
||||||
{order.orderNumber}
|
|
||||||
{order.sourceOrderNumber ? ` (${order.sourceOrderNumber})` : ""}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
))}
|
||||||
})}
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,29 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { DELIVERY_SET_BUCKET_LABELS } from "../../services/deliverySetViews";
|
import {
|
||||||
|
ORDER_GROUP_BUCKET_LABELS,
|
||||||
|
ORDER_GROUP_DISPLAY_STATUS_OPTIONS,
|
||||||
|
ORDER_GROUP_STATUS_LABELS,
|
||||||
|
} from "../../services/orderGroupViews";
|
||||||
|
|
||||||
describe("LogisticsReadinessBoard", () => {
|
describe("LogisticsReadinessBoard", () => {
|
||||||
it("renders all delivery-set bucket labels from the model", () => {
|
it("renders all group bucket labels from the model", () => {
|
||||||
const bucketKeys = Object.keys(DELIVERY_SET_BUCKET_LABELS);
|
const bucketKeys = Object.keys(ORDER_GROUP_BUCKET_LABELS);
|
||||||
expect(bucketKeys).toContain("approaching");
|
|
||||||
expect(bucketKeys).toContain("ready_to_launch");
|
expect(bucketKeys).toContain("ready_to_launch");
|
||||||
expect(bucketKeys).toContain("awaiting_client");
|
|
||||||
expect(bucketKeys).toContain("manual_work");
|
expect(bucketKeys).toContain("manual_work");
|
||||||
expect(bucketKeys).toContain("agreed");
|
expect(bucketKeys).toContain("sms_sent");
|
||||||
expect(bucketKeys).toContain("completed");
|
expect(bucketKeys).toHaveLength(3);
|
||||||
expect(bucketKeys).toHaveLength(6);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders bucket labels in Russian", () => {
|
it("renders bucket labels in Russian", () => {
|
||||||
expect(DELIVERY_SET_BUCKET_LABELS.approaching).toBe("На подходе");
|
expect(ORDER_GROUP_BUCKET_LABELS.ready_to_launch).toBe("Готовы к уведомлению");
|
||||||
expect(DELIVERY_SET_BUCKET_LABELS.ready_to_launch).toBe("Готово к запуску");
|
expect(ORDER_GROUP_BUCKET_LABELS.sms_sent).toBe("Уведомления отправлены");
|
||||||
expect(DELIVERY_SET_BUCKET_LABELS.awaiting_client).toBe("Ожидает клиента");
|
expect(ORDER_GROUP_BUCKET_LABELS.manual_work).toBe("Нужна ручная работа");
|
||||||
expect(DELIVERY_SET_BUCKET_LABELS.manual_work).toBe("Нужна ручная работа");
|
|
||||||
expect(DELIVERY_SET_BUCKET_LABELS.agreed).toBe("Согласовано");
|
|
||||||
expect(DELIVERY_SET_BUCKET_LABELS.completed).toBe("Завершено");
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
it("renders status labels in Russian", () => {
|
||||||
|
expect(ORDER_GROUP_STATUS_LABELS.ready_for_notification).toBe("Готово к уведомлению");
|
||||||
|
expect(ORDER_GROUP_STATUS_LABELS.sms_sent).toBe("SMS отправлены");
|
||||||
|
expect(ORDER_GROUP_STATUS_LABELS.manual_work).toBe("Нужна ручная работа");
|
||||||
|
expect(ORDER_GROUP_DISPLAY_STATUS_OPTIONS.map((option) => option.label)).toContain("Согласовано");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,195 +1,547 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { getDeliveryAgreementComment, getOrderStatusComment, getStatusTone } from "../../constants/deliveryWorkflow";
|
|
||||||
import { demoUsers } from "../../data/mockAppData";
|
|
||||||
import { formatDateTime } from "../../utils/formatters";
|
import { formatDateTime } from "../../utils/formatters";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
|
import { Button } from "../UI/Button";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
|
import {
|
||||||
|
getOrderGroupDeliveryStatusLabel,
|
||||||
|
getOrderGroupDisplayStatusLabel,
|
||||||
|
getOrderGroupStatusTone,
|
||||||
|
} from "../../services/orderGroupViews";
|
||||||
|
|
||||||
const getUsers = (users) => (Array.isArray(users) && users.length ? users : demoUsers);
|
const DELIVERY_TIME_OPTIONS = ["Первая половина дня", "Вторая половина дня"];
|
||||||
const resolveUserName = (users, userId) => getUsers(users).find((user) => user.id === userId)?.name || "Не назначен";
|
const WEEK_DAY_LABELS = ["ПН", "ВТ", "СР", "ЧТ", "ПТ", "СБ", "ВС"];
|
||||||
|
const DELIVERY_TIME_ALIASES = {
|
||||||
const splitItem = (item) => {
|
"До обеда": "Первая половина дня",
|
||||||
if (!item) {
|
"После обеда": "Вторая половина дня",
|
||||||
return { name: "Позиция", quantity: "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof item === "string") {
|
|
||||||
const [name, quantity] = item.split("|").map((part) => part.trim());
|
|
||||||
return {
|
|
||||||
name: name || item,
|
|
||||||
quantity: quantity || "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof item === "object") {
|
|
||||||
return {
|
|
||||||
name: item.name || item.label || "Позиция",
|
|
||||||
quantity: typeof item.quantity === "number" ? String(item.quantity) : item.quantity || "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { name: "Позиция", quantity: "" };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const OrderDetailPanel = ({ order, users }) => {
|
const renderList = (values) => {
|
||||||
|
if (!Array.isArray(values) || !values.length) {
|
||||||
|
return <p className="text-sm text-[var(--color-text-muted)]">Нет данных</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{values.map((value, index) => (
|
||||||
|
<span
|
||||||
|
key={`${value}-${index}`}
|
||||||
|
className="rounded-full bg-[var(--color-surface)] px-3 py-1 text-xs text-[var(--color-text-muted)]"
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderValue = (value) => value || "Нет данных";
|
||||||
|
|
||||||
|
const getErrorMessage = (error, fallbackMessage) => {
|
||||||
|
if (!error) {
|
||||||
|
return fallbackMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message || fallbackMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error === "string") {
|
||||||
|
return error || fallbackMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return error?.message || fallbackMessage;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeDeliveryTimeChoice = (value) => {
|
||||||
|
const normalized = value ? String(value).trim() : "";
|
||||||
|
const deliveryTime = DELIVERY_TIME_ALIASES[normalized] || normalized;
|
||||||
|
return DELIVERY_TIME_OPTIONS.includes(deliveryTime) ? deliveryTime : DELIVERY_TIME_OPTIONS[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const toDateKey = (date) => {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fromDateKey = (value) => {
|
||||||
|
const normalized = normalizeDateForInput(value);
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [year, month, day] = normalized.split("-").map(Number);
|
||||||
|
return new Date(year, month - 1, day);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addDays = (date, amount) => {
|
||||||
|
const nextDate = new Date(date);
|
||||||
|
nextDate.setDate(nextDate.getDate() + amount);
|
||||||
|
return nextDate;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isWeekendDate = (date) => {
|
||||||
|
const day = date.getDay();
|
||||||
|
return day === 0 || day === 6;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getNextSelectableDateKey = (referenceDate = new Date()) => {
|
||||||
|
let current = addDays(referenceDate, 1);
|
||||||
|
|
||||||
|
while (isWeekendDate(current)) {
|
||||||
|
current = addDays(current, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return toDateKey(current);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFutureDeliveryDate = (value) => {
|
||||||
|
const parsedDate = fromDateKey(value);
|
||||||
|
|
||||||
|
if (!parsedDate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !isWeekendDate(parsedDate) && toDateKey(parsedDate) >= getNextSelectableDateKey();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSelectableCalendarDate = (date, minDateKey) => {
|
||||||
|
const dateKey = toDateKey(date);
|
||||||
|
return dateKey >= minDateKey && !isWeekendDate(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateForDisplay = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return "Выберите дату";
|
||||||
|
}
|
||||||
|
|
||||||
|
const [year, month, day] = value.split("-").map(Number);
|
||||||
|
if (!year || !month || !day) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(year, month - 1, day).toLocaleDateString("ru-RU", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDeliveryDateDisplay = (value) => {
|
||||||
|
const normalized = normalizeDateForInput(value);
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
return renderValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatDateForDisplay(normalized);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startOfMonth = (date) => new Date(date.getFullYear(), date.getMonth(), 1);
|
||||||
|
|
||||||
|
const addMonths = (date, amount) => new Date(date.getFullYear(), date.getMonth() + amount, 1);
|
||||||
|
|
||||||
|
const buildCalendarDays = (currentMonth) => {
|
||||||
|
const firstDay = startOfMonth(currentMonth);
|
||||||
|
const lastDay = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0);
|
||||||
|
const firstWeekDay = (firstDay.getDay() + 6) % 7;
|
||||||
|
const totalDays = lastDay.getDate();
|
||||||
|
const cells = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < firstWeekDay; index += 1) {
|
||||||
|
cells.push(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let day = 1; day <= totalDays; day += 1) {
|
||||||
|
cells.push(new Date(currentMonth.getFullYear(), currentMonth.getMonth(), day));
|
||||||
|
}
|
||||||
|
|
||||||
|
while (cells.length % 7 !== 0) {
|
||||||
|
cells.push(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeDateForInput = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortDateMatch = normalized.match(/^(\d{2})\.(\d{2})\.(\d{2})$/);
|
||||||
|
if (shortDateMatch) {
|
||||||
|
const [, day, month, year] = shortDateMatch;
|
||||||
|
return `20${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrderDetailPanel = ({
|
||||||
|
order,
|
||||||
|
canManageDelivery = false,
|
||||||
|
onSaveManualDeliveryChoice,
|
||||||
|
isSavingDeliveryChoice = false,
|
||||||
|
}) => {
|
||||||
|
const [deliveryDate, setDeliveryDate] = React.useState("");
|
||||||
|
const [deliveryTime, setDeliveryTime] = React.useState(DELIVERY_TIME_OPTIONS[0]);
|
||||||
|
const [formMessage, setFormMessage] = React.useState("");
|
||||||
|
const [isCalendarOpen, setIsCalendarOpen] = React.useState(false);
|
||||||
|
const minSelectableDateKey = React.useMemo(() => getNextSelectableDateKey(), []);
|
||||||
|
const [currentMonth, setCurrentMonth] = React.useState(() => {
|
||||||
|
const existingDeliveryDate = fromDateKey(order?.deliveryDate);
|
||||||
|
const fallbackDate = fromDateKey(minSelectableDateKey) || new Date();
|
||||||
|
const sourceDate = existingDeliveryDate && isFutureDeliveryDate(toDateKey(existingDeliveryDate))
|
||||||
|
? existingDeliveryDate
|
||||||
|
: fallbackDate;
|
||||||
|
|
||||||
|
return startOfMonth(sourceDate);
|
||||||
|
});
|
||||||
|
const calendarDays = React.useMemo(() => buildCalendarDays(currentMonth), [currentMonth]);
|
||||||
|
const monthLabel = React.useMemo(
|
||||||
|
() =>
|
||||||
|
currentMonth.toLocaleDateString("ru-RU", {
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
}),
|
||||||
|
[currentMonth],
|
||||||
|
);
|
||||||
|
const canGoBack = toDateKey(currentMonth) > toDateKey(startOfMonth(fromDateKey(minSelectableDateKey) || new Date()));
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const normalizedDeliveryDate = normalizeDateForInput(order?.deliveryDate);
|
||||||
|
const nextSelectableDateKey = getNextSelectableDateKey();
|
||||||
|
const selectedDateKey = isFutureDeliveryDate(normalizedDeliveryDate) ? normalizedDeliveryDate : nextSelectableDateKey;
|
||||||
|
setDeliveryDate(selectedDateKey);
|
||||||
|
const selectedDate = fromDateKey(selectedDateKey) || new Date();
|
||||||
|
setCurrentMonth(startOfMonth(selectedDate));
|
||||||
|
setDeliveryTime(normalizeDeliveryTimeChoice(order?.deliveryTime || order?.deliveryHalfDay));
|
||||||
|
setFormMessage("");
|
||||||
|
}, [order?.id, order?.deliveryDate, order?.deliveryHalfDay, order?.deliveryTime]);
|
||||||
|
|
||||||
if (!order) {
|
if (!order) {
|
||||||
return (
|
return (
|
||||||
<Panel className="flex min-h-[460px] items-center justify-center">
|
<Panel className="flex min-h-[460px] items-center justify-center">
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">Выберите заказ для просмотра деталей.</p>
|
<p className="text-sm text-[var(--color-text-muted)]">Выберите группу для просмотра деталей.</p>
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderItems = Array.isArray(order.items) ? order.items.map(splitItem) : [];
|
const isDeliveryAgreed = (order.deliveryStatus || order.delivery_status) === "agreed";
|
||||||
const orderHistory = Array.isArray(order.history) ? order.history : [];
|
const agreedDeliveryLabel = [
|
||||||
|
formatDeliveryDateDisplay(order.deliveryDate),
|
||||||
|
order.deliveryTime || order.deliveryHalfDay,
|
||||||
|
].filter((value) => value && value !== "Нет данных").join(" · ");
|
||||||
|
|
||||||
|
const handleSaveDeliveryChoice = async () => {
|
||||||
|
if (!deliveryDate || !deliveryTime) {
|
||||||
|
setFormMessage("Укажите дату и половину дня доставки.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFutureDeliveryDate(deliveryDate)) {
|
||||||
|
setFormMessage("Выберите дату доставки позже сегодняшнего дня.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await onSaveManualDeliveryChoice?.({
|
||||||
|
orderGroupId: order.id,
|
||||||
|
deliveryDate,
|
||||||
|
deliveryTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.success) {
|
||||||
|
setFormMessage("Доставка согласована вручную.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormMessage(getErrorMessage(result?.error, "Не удалось сохранить согласование доставки."));
|
||||||
|
} catch (error) {
|
||||||
|
setFormMessage(getErrorMessage(error, "Не удалось сохранить согласование доставки."));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<Panel className="space-y-5 p-6">
|
<Panel className="space-y-5 p-6">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Карточка заказа</p>
|
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
||||||
<h2 className="mt-2 text-2xl font-semibold">{order.orderNumber}</h2>
|
Карточка группы доставки
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-semibold">
|
||||||
|
{order.displayTitle || order.customerName || order.groupKey}
|
||||||
|
</h2>
|
||||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
{order.customer.name} · {order.customer.address}
|
{order.displaySubtitle || [order.customerPhone, order.customerDate].filter(Boolean).join(" · ") || "Не указано"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge tone={getStatusTone(order.status)}>{order.status}</Badge>
|
<Badge tone={getOrderGroupStatusTone(order)}>{getOrderGroupDisplayStatusLabel(order)}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
<div className="grid gap-3 rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface)] p-4 md:grid-cols-2">
|
||||||
{getOrderStatusComment(order.status)}
|
<div>
|
||||||
</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||||
|
Дата доставки
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xl font-semibold">{formatDeliveryDateDisplay(order.deliveryDate)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||||
|
Время доставки
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xl font-semibold">{renderValue(order.deliveryTime || order.deliveryHalfDay)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Менеджер</p>
|
<p className="text-xs text-[var(--color-text-muted)]">Группа</p>
|
||||||
<p className="mt-1 font-medium">{resolveUserName(users, order.managerId)}</p>
|
<p className="mt-1 font-medium">{renderValue(order.groupKey)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Логист</p>
|
|
||||||
<p className="mt-1 font-medium">{resolveUserName(users, order.logisticianIds?.[0])}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Водитель</p>
|
|
||||||
<p className="mt-1 font-medium">{resolveUserName(users, order.assignedDriverId)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Дата создания</p>
|
|
||||||
<p className="mt-1 font-medium">{formatDateTime(order.createdAt)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">План доставки</p>
|
|
||||||
<p className="mt-1 font-medium">{formatDateTime(order.scheduledDelivery)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Канал связи</p>
|
|
||||||
<p className="mt-1 font-medium">{order.customer.messenger}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Согласование доставки</p>
|
|
||||||
<p className="mt-1 font-medium">{order.deliveryAgreementStatus}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<Panel className="space-y-4 p-5">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<strong>Данные клиента</strong>
|
|
||||||
<Badge tone={getStatusTone(order.status)}>{order.status}</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
|
||||||
{getDeliveryAgreementComment(order.deliveryAgreementStatus)}
|
|
||||||
</p>
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Клиент</p>
|
<p className="text-xs text-[var(--color-text-muted)]">Клиент</p>
|
||||||
<p className="mt-1 font-medium">{order.customer.name}</p>
|
<p className="mt-1 font-medium">{renderValue(order.customerName)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Телефон</p>
|
<p className="text-xs text-[var(--color-text-muted)]">Телефон</p>
|
||||||
<p className="mt-1 font-medium">{order.customer.phone}</p>
|
<p className="mt-1 font-medium">{renderValue(order.customerPhone)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Адрес</p>
|
<p className="text-xs text-[var(--color-text-muted)]">Дата</p>
|
||||||
<p className="mt-1 font-medium">{order.customer.address}</p>
|
<p className="mt-1 font-medium">{renderValue(order.customerDate)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2 xl:col-span-4">
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">Адрес доставки</p>
|
||||||
|
<p className="mt-1 font-medium">{renderValue(order.deliveryAddress)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Дата доставки</p>
|
<p className="text-xs text-[var(--color-text-muted)]">Всего заказов</p>
|
||||||
<p className="mt-1 font-medium">{formatDateTime(order.scheduledDelivery)}</p>
|
<p className="mt-1 font-medium">{order.ordersCount ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">Готово</p>
|
||||||
|
<p className="mt-1 font-medium">{order.readyCount ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">Не готово</p>
|
||||||
|
<p className="mt-1 font-medium">{order.notReadyCount ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">Обновлена</p>
|
||||||
|
<p className="mt-1 font-medium">{formatDateTime(order.updatedAt)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">Статус доставки</p>
|
||||||
|
<p className="mt-1 font-medium">{getOrderGroupDeliveryStatusLabel(order.deliveryStatus)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
{canManageDelivery ? (
|
||||||
|
<Panel className="space-y-4 p-5">
|
||||||
|
<div>
|
||||||
|
<strong>Ручное согласование доставки</strong>
|
||||||
|
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
|
{isDeliveryAgreed
|
||||||
|
? "Дата и половина дня доставки уже зафиксированы."
|
||||||
|
: "Если клиент согласовал доставку по телефону, сохраните дату и половину дня здесь."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isDeliveryAgreed ? (
|
||||||
|
<div className="rounded-[24px] border border-[rgba(18,128,92,0.35)] bg-[var(--color-accent-soft)] p-4 text-[var(--color-text)]">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-accent)]">
|
||||||
|
Доставка согласована
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-lg font-semibold">
|
||||||
|
{agreedDeliveryLabel || "Дата и время сохранены"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge tone="accent">Согласовано</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:relative md:z-10">
|
||||||
|
<div className="space-y-3 md:relative md:z-30 md:min-w-0 md:flex-1 md:pr-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Дата доставки"
|
||||||
|
aria-expanded={isCalendarOpen}
|
||||||
|
className="flex min-h-[54px] w-full items-center justify-between rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 text-left text-sm font-medium text-[var(--color-text)] transition hover:border-[var(--color-accent)] focus:border-[var(--color-accent)] focus:outline-none"
|
||||||
|
onClick={() => setIsCalendarOpen((current) => !current)}
|
||||||
|
>
|
||||||
|
<span>{formatDateForDisplay(deliveryDate)}</span>
|
||||||
|
<span aria-hidden="true" className="text-[var(--color-text-muted)]">▾</span>
|
||||||
|
</button>
|
||||||
|
{isCalendarOpen ? (
|
||||||
|
<div className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface)] p-4 shadow-soft md:absolute md:left-0 md:top-full md:z-50 md:mt-3 md:w-[min(460px,calc(100vw-3rem))]">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||||
|
Календарь доставки
|
||||||
|
</p>
|
||||||
|
<h4
|
||||||
|
className="mt-1 text-base font-semibold capitalize"
|
||||||
|
style={{ color: "var(--color-text)" }}
|
||||||
|
>
|
||||||
|
{monthLabel}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canGoBack}
|
||||||
|
aria-label="Предыдущий месяц"
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-full border border-[var(--color-border)] text-sm text-[var(--color-text-muted)] transition hover:border-[var(--color-accent)] hover:text-[var(--color-text)] disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
onClick={() => setCurrentMonth((month) => addMonths(month, -1))}
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Следующий месяц"
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-full border border-[var(--color-border)] text-sm text-[var(--color-text-muted)] transition hover:border-[var(--color-accent)] hover:text-[var(--color-text)]"
|
||||||
|
onClick={() => setCurrentMonth((month) => addMonths(month, 1))}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid grid-cols-7 gap-1 text-center text-[10px] font-semibold uppercase text-[var(--color-text-muted)]">
|
||||||
|
{WEEK_DAY_LABELS.map((day) => (
|
||||||
|
<div key={day} className="px-1 py-1">
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 grid grid-cols-7 gap-1">
|
||||||
|
{calendarDays.map((day, index) => {
|
||||||
|
if (!day) {
|
||||||
|
return <div key={`empty-${index}`} className="aspect-square" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateKey = toDateKey(day);
|
||||||
|
const isWeekend = isWeekendDate(day);
|
||||||
|
const isSelectable = isSelectableCalendarDate(day, minSelectableDateKey);
|
||||||
|
const isSelected = dateKey === deliveryDate;
|
||||||
|
const isDisabled = !isSelectable;
|
||||||
|
const dayNumber = String(day.getDate()).padStart(2, "0");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={dateKey}
|
||||||
|
type="button"
|
||||||
|
disabled={isDisabled}
|
||||||
|
title={isWeekend ? "Выходной, доставки нет" : isSelectable ? "Можно выбрать" : "Недоступно"}
|
||||||
|
className={[
|
||||||
|
"relative flex aspect-square items-center justify-center rounded-xl border text-sm font-semibold transition",
|
||||||
|
isSelected
|
||||||
|
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||||||
|
: isWeekend
|
||||||
|
? "border-dashed border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)]"
|
||||||
|
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-text)]",
|
||||||
|
isDisabled ? "cursor-not-allowed opacity-45" : "",
|
||||||
|
].join(" ")}
|
||||||
|
onClick={() => {
|
||||||
|
if (isDisabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeliveryDate(dateKey);
|
||||||
|
setFormMessage("");
|
||||||
|
setIsCalendarOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{dayNumber}</span>
|
||||||
|
{isWeekend ? (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-x-2 top-1/2 h-px -rotate-12 bg-[var(--color-text-muted)] opacity-70"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-[var(--color-text-muted)]">
|
||||||
|
Выходные отмечены пунктиром и недоступны.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2 md:w-[320px] md:flex-none">
|
||||||
|
{DELIVERY_TIME_OPTIONS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={deliveryTime === option}
|
||||||
|
className={[
|
||||||
|
"min-h-[54px] rounded-2xl border px-4 text-left text-sm font-medium transition",
|
||||||
|
deliveryTime === option
|
||||||
|
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||||||
|
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-text)]",
|
||||||
|
].join(" ")}
|
||||||
|
onClick={() => {
|
||||||
|
setDeliveryTime(option);
|
||||||
|
setFormMessage("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="w-full md:w-[180px] md:flex-none md:self-start"
|
||||||
|
onClick={handleSaveDeliveryChoice}
|
||||||
|
disabled={isSavingDeliveryChoice}
|
||||||
|
>
|
||||||
|
{isSavingDeliveryChoice ? "Сохраняем..." : "Согласовать"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{formMessage ? (
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">{formMessage}</p>
|
||||||
|
) : null}
|
||||||
|
</Panel>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Panel className="space-y-4 p-5">
|
||||||
|
<strong>Номера заказов</strong>
|
||||||
|
{renderList(order.orderNumbers)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<Panel className="space-y-4 p-5">
|
<Panel className="space-y-4 p-5">
|
||||||
<strong>Состав заказа</strong>
|
<strong>Дополнительные данные</strong>
|
||||||
<div className="space-y-3">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
{orderItems.length ? (
|
<div>
|
||||||
orderItems.map((item) => (
|
<p className="text-xs text-[var(--color-text-muted)]">SMS отправлено</p>
|
||||||
<div
|
<p className="mt-1 font-medium">{order.smsSentAt ? "Да" : "Нет"}</p>
|
||||||
key={`${item.name}-${item.quantity || "item"}`}
|
</div>
|
||||||
className="flex items-center justify-between gap-3 rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-sm"
|
{order.createdFromExchangeAt ? (
|
||||||
>
|
<div>
|
||||||
<span>{item.name}</span>
|
<p className="text-xs text-[var(--color-text-muted)]">Создано из обмена</p>
|
||||||
{item.quantity ? <Badge tone="neutral">{item.quantity}</Badge> : null}
|
<p className="mt-1 font-medium">{formatDateTime(order.createdFromExchangeAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
))
|
) : null}
|
||||||
) : (
|
{order.sourceKey ? (
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">Состав заказа не указан.</p>
|
<div>
|
||||||
)}
|
<p className="text-xs text-[var(--color-text-muted)]">Ключ источника</p>
|
||||||
|
<p className="mt-1 font-medium">{order.sourceKey}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{order.orderNotes?.length ? (
|
|
||||||
<Panel className="space-y-3 p-5">
|
|
||||||
<strong>Комментарии</strong>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{order.orderNotes.map((note) => (
|
|
||||||
<div
|
|
||||||
key={note.id}
|
|
||||||
className="rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface)] p-4 text-sm leading-6"
|
|
||||||
>
|
|
||||||
{note.text}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{order.comments?.length ? (
|
|
||||||
<Panel className="space-y-3 p-5">
|
|
||||||
<strong>Дополнительные комментарии</strong>
|
|
||||||
<div className="space-y-2 text-sm leading-6 text-[var(--color-text-muted)]">
|
|
||||||
{order.comments.map((comment, index) => (
|
|
||||||
<div key={`${comment}-${index}`} className="rounded-[20px] bg-[var(--color-surface)] p-4">
|
|
||||||
{comment}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{orderHistory.length ? (
|
|
||||||
<Panel className="space-y-3 p-5">
|
|
||||||
<strong>История</strong>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{orderHistory.map((entry) => (
|
|
||||||
<div
|
|
||||||
key={entry.id}
|
|
||||||
className="rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface)] p-4 text-sm leading-6"
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<span className="font-medium">{entry.action}</span>
|
|
||||||
<span className="text-[var(--color-text-muted)]">{formatDateTime(entry.at)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-[var(--color-text-muted)]">
|
|
||||||
{entry.oldStatus || "Начало"} → {entry.newStatus}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,81 +1,112 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { OrderDetailPanel } from "./OrderDetailPanel";
|
import { OrderDetailPanel, getNextSelectableDateKey } from "./OrderDetailPanel";
|
||||||
|
|
||||||
const order = {
|
const order = {
|
||||||
id: "o-1",
|
id: "o-1",
|
||||||
orderNumber: "CD-240031",
|
groupKey: "9780001231|16.04.26",
|
||||||
status: "Ожидает согласования доставки",
|
displayTitle: "Мария Волкова",
|
||||||
deliveryAgreementStatus: "Ожидание ответа",
|
displaySubtitle: "+7 978 000-12-31 · 16.04.26",
|
||||||
managerId: "u-manager",
|
customerName: "Мария Волкова",
|
||||||
logisticianIds: ["u-logistics"],
|
customerPhone: "+7 978 000-12-31",
|
||||||
assignedDriverId: null,
|
customerDate: "16.04.26",
|
||||||
|
deliveryAddress: "Симферополь, ул. Ленина, 10",
|
||||||
|
ordersCount: 1,
|
||||||
|
readyCount: 1,
|
||||||
|
notReadyCount: 0,
|
||||||
|
orderNumbers: ["CD-240031"],
|
||||||
|
status: "ready_for_notification",
|
||||||
|
smsSentAt: null,
|
||||||
|
createdFromExchangeAt: null,
|
||||||
|
sourceKey: null,
|
||||||
|
legacyCustomerName: null,
|
||||||
|
sourceOrders: null,
|
||||||
createdAt: "2026-03-15T08:00:00Z",
|
createdAt: "2026-03-15T08:00:00Z",
|
||||||
scheduledDelivery: "2026-03-16T09:00:00Z",
|
updatedAt: "2026-03-16T09:00:00Z",
|
||||||
|
deliveryStatus: "pending_confirmation",
|
||||||
|
deliveryDate: "2026-05-18",
|
||||||
|
deliveryTime: "Первая половина дня",
|
||||||
customer: {
|
customer: {
|
||||||
name: "Мария Волкова",
|
name: "Мария Волкова",
|
||||||
phone: "+7 978 000-12-31",
|
phone: "+7 978 000-12-31",
|
||||||
address: "Симферополь",
|
date: "16.04.26",
|
||||||
messenger: "СМС",
|
|
||||||
},
|
},
|
||||||
items: ["Кухня | 1 шт"],
|
|
||||||
chatMessages: [],
|
|
||||||
internalMessages: [],
|
|
||||||
orderNotes: [],
|
|
||||||
history: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("OrderDetailPanel", () => {
|
describe("OrderDetailPanel", () => {
|
||||||
it("keeps the order card read-first without workflow controls", () => {
|
it("keeps the group card read-first", () => {
|
||||||
const markup = renderToStaticMarkup(
|
const markup = renderToStaticMarkup(
|
||||||
<OrderDetailPanel
|
<OrderDetailPanel order={order} />,
|
||||||
order={order}
|
|
||||||
users={[
|
|
||||||
{ id: "u-manager", name: "Анна Мельник", role: "manager" },
|
|
||||||
{ id: "u-logistics", name: "Ольга Синицына", role: "logistician" },
|
|
||||||
]}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(markup).toContain("CD-240031");
|
expect(markup).toContain("Карточка группы доставки");
|
||||||
expect(markup).toContain("Мария Волкова");
|
expect(markup).toContain("Мария Волкова");
|
||||||
expect(markup).toContain("Кухня");
|
expect(markup).toContain("Адрес доставки");
|
||||||
expect(markup).toContain("1 шт");
|
expect(markup).toContain("Симферополь, ул. Ленина, 10");
|
||||||
expect(markup).not.toContain("Назначение водителя");
|
expect(markup).toContain("Дата доставки");
|
||||||
expect(markup).not.toContain("Изменить статус");
|
expect(markup).toContain("18.05.2026");
|
||||||
expect(markup).not.toContain("Чат с клиентом");
|
expect(markup).toContain("Время доставки");
|
||||||
expect(markup).not.toContain("Команда");
|
expect(markup).toContain("Первая половина дня");
|
||||||
|
expect(markup).toContain("CD-240031");
|
||||||
|
expect(markup).toContain("Готово");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not crash when an order contains invalid date strings", () => {
|
it("does not crash when a group contains missing timestamps", () => {
|
||||||
const markup = renderToStaticMarkup(
|
const markup = renderToStaticMarkup(
|
||||||
<OrderDetailPanel
|
<OrderDetailPanel
|
||||||
order={{
|
order={{
|
||||||
...order,
|
...order,
|
||||||
createdAt: "2026-03-18T010:00:00Z",
|
createdAt: "broken-date",
|
||||||
scheduledDelivery: "not-a-date",
|
updatedAt: "broken-date",
|
||||||
orderNotes: [
|
smsSentAt: null,
|
||||||
{
|
createdFromExchangeAt: null,
|
||||||
id: "note-1",
|
deliveryAddress: "",
|
||||||
authorName: "Анна",
|
|
||||||
text: "Проверка даты",
|
|
||||||
createdAt: "broken-date",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(markup).toContain("Не указано");
|
expect(markup).toContain("Нет данных");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not expose driver assignment or status controls", () => {
|
it("renders order numbers as chips", () => {
|
||||||
const markup = renderToStaticMarkup(<OrderDetailPanel order={order} users={[]} />);
|
const markup = renderToStaticMarkup(<OrderDetailPanel order={order} />);
|
||||||
|
|
||||||
expect(markup).not.toContain("Назначение водителя");
|
expect(markup).toContain("CD-240031");
|
||||||
expect(markup).not.toContain("Изменить статус");
|
});
|
||||||
expect(markup).not.toContain("Чат с клиентом");
|
|
||||||
expect(markup).not.toContain("Команда");
|
it("shows manual delivery controls only for editable cards", () => {
|
||||||
|
const editableMarkup = renderToStaticMarkup(
|
||||||
|
<OrderDetailPanel order={order} canManageDelivery onSaveManualDeliveryChoice={() => {}} />,
|
||||||
|
);
|
||||||
|
const readonlyMarkup = renderToStaticMarkup(<OrderDetailPanel order={order} />);
|
||||||
|
|
||||||
|
expect(editableMarkup).toContain("Ручное согласование доставки");
|
||||||
|
expect(editableMarkup).toContain("Согласовать");
|
||||||
|
expect(editableMarkup).toContain("Выберите дату");
|
||||||
|
expect(readonlyMarkup).not.toContain("Ручное согласование доставки");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an explicit agreed delivery state in the manual agreement area", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<OrderDetailPanel
|
||||||
|
order={{
|
||||||
|
...order,
|
||||||
|
deliveryStatus: "agreed",
|
||||||
|
deliveryDate: "2026-05-18",
|
||||||
|
deliveryTime: "Первая половина дня",
|
||||||
|
}}
|
||||||
|
canManageDelivery
|
||||||
|
onSaveManualDeliveryChoice={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain("Доставка согласована");
|
||||||
|
expect(markup).toContain("18.05.2026 · Первая половина дня");
|
||||||
|
expect(markup).not.toContain("Согласовать</button>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips weekends when selecting the default manual delivery date", () => {
|
||||||
|
expect(getNextSelectableDateKey(new Date("2026-05-15T12:00:00Z"))).toBe("2026-05-18");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,204 +1,120 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { DELIVERY_REGISTRY_FILTER_STATUSES } from "../../constants/orderStatuses";
|
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
import { Button } from "../UI/Button";
|
|
||||||
import { Input } from "../UI/Input";
|
import { Input } from "../UI/Input";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
|
|
||||||
const messengers = ["СМС", "Эл. почта"];
|
export const OrderFilters = ({ filters, setFilters, statusOptions = [] }) => {
|
||||||
const statusOptions = [
|
const statusValue = filters.displayStatus || filters.status || "all";
|
||||||
{ value: "all", label: "Все статусы" },
|
const selectedStatusLabel = statusOptions.find((option) => option.value === statusValue)?.label || statusValue;
|
||||||
...DELIVERY_REGISTRY_FILTER_STATUSES.map((status) => ({ value: status, label: status })),
|
const [isStatusOpen, setIsStatusOpen] = React.useState(false);
|
||||||
];
|
const statusMenuRef = React.useRef(null);
|
||||||
const messengerOptions = [
|
|
||||||
{ value: "all", label: "Все каналы" },
|
|
||||||
...messengers.map((messenger) => ({ value: messenger, label: messenger })),
|
|
||||||
];
|
|
||||||
|
|
||||||
const FilterMenu = ({ label, value, options, isOpen, onToggle, onChange, onClose }) => {
|
React.useEffect(() => {
|
||||||
const selectedLabel = options.find((option) => option.value === value)?.label || label;
|
if (!isStatusOpen) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
const handlePointerDown = (event) => {
|
||||||
<div
|
if (statusMenuRef.current && !statusMenuRef.current.contains(event.target)) {
|
||||||
className="relative"
|
setIsStatusOpen(false);
|
||||||
onBlur={(event) => {
|
}
|
||||||
if (!event.currentTarget.contains(event.relatedTarget)) {
|
};
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={[
|
|
||||||
"flex w-full items-center justify-between gap-3 border border-[var(--color-border)]",
|
|
||||||
"bg-[var(--color-surface)] px-4 py-3 text-left text-sm text-[var(--color-text)] transition",
|
|
||||||
isOpen
|
|
||||||
? "rounded-t-2xl rounded-b-none border-[var(--color-accent)] border-b-transparent bg-[var(--color-dropdown-surface)]"
|
|
||||||
: "rounded-2xl hover:bg-[var(--color-surface-strong)]",
|
|
||||||
].join(" ")}
|
|
||||||
aria-haspopup="listbox"
|
|
||||||
aria-expanded={isOpen}
|
|
||||||
onClick={onToggle}
|
|
||||||
>
|
|
||||||
<span className="truncate">{selectedLabel}</span>
|
|
||||||
<span className="text-[var(--color-text-muted)]">v</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isOpen ? (
|
const handleKeyDown = (event) => {
|
||||||
<div
|
if (event.key === "Escape") {
|
||||||
className="overflow-hidden rounded-b-2xl border border-t-0 border-[var(--color-accent)] bg-[var(--color-dropdown-surface)] px-2 pb-2 pt-1"
|
setIsStatusOpen(false);
|
||||||
role="listbox"
|
}
|
||||||
aria-label={label}
|
};
|
||||||
>
|
|
||||||
{options.map((option) => {
|
|
||||||
const selected = option.value === value;
|
|
||||||
|
|
||||||
return (
|
document.addEventListener("pointerdown", handlePointerDown);
|
||||||
<button
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
className={[
|
|
||||||
"flex w-full items-center justify-between rounded-2xl px-3 py-2.5 text-left text-sm transition",
|
|
||||||
selected
|
|
||||||
? "bg-[var(--color-accent-soft)] font-semibold text-[var(--color-text)]"
|
|
||||||
: "text-[var(--color-text-muted)] hover:bg-[var(--color-accent-soft)] hover:text-[var(--color-text)]",
|
|
||||||
].join(" ")}
|
|
||||||
role="option"
|
|
||||||
aria-selected={selected}
|
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
|
||||||
onClick={() => {
|
|
||||||
onChange(option.value);
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>{option.label}</span>
|
|
||||||
{selected ? <span className="text-[var(--color-accent)]">✓</span> : null}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OrderFilters = ({ filters, setFilters }) => {
|
return () => {
|
||||||
const [isMobileFiltersOpen, setIsMobileFiltersOpen] = React.useState(false);
|
document.removeEventListener("pointerdown", handlePointerDown);
|
||||||
const [openMenu, setOpenMenu] = React.useState(null);
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
const activeChips = [
|
}, [isStatusOpen]);
|
||||||
filters.status !== "all" ? { key: "status", label: filters.status } : null,
|
|
||||||
filters.messenger !== "all" ? { key: "messenger", label: filters.messenger } : null,
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
const updateFilter = (key, value) => {
|
const updateFilter = (key, value) => {
|
||||||
setFilters((current) => ({ ...current, [key]: value }));
|
setFilters((current) => ({ ...current, [key]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderFilterField = (label, control, showLabel = false) => (
|
const activeChips = [statusValue !== "all" ? { key: "status", label: selectedStatusLabel } : null].filter(Boolean);
|
||||||
<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-3">
|
|
||||||
{renderFilterField(
|
|
||||||
"Статус",
|
|
||||||
<FilterMenu
|
|
||||||
label="Статус"
|
|
||||||
value={filters.status}
|
|
||||||
options={statusOptions}
|
|
||||||
isOpen={openMenu === "status"}
|
|
||||||
onToggle={() => setOpenMenu((current) => (current === "status" ? null : "status"))}
|
|
||||||
onChange={(value) => updateFilter("status", value)}
|
|
||||||
onClose={() => setOpenMenu(null)}
|
|
||||||
/>,
|
|
||||||
showLabels,
|
|
||||||
)}
|
|
||||||
|
|
||||||
{renderFilterField(
|
|
||||||
"Канал",
|
|
||||||
<FilterMenu
|
|
||||||
label="Канал"
|
|
||||||
value={filters.messenger}
|
|
||||||
options={messengerOptions}
|
|
||||||
isOpen={openMenu === "messenger"}
|
|
||||||
onToggle={() => setOpenMenu((current) => (current === "messenger" ? null : "messenger"))}
|
|
||||||
onChange={(value) => updateFilter("messenger", value)}
|
|
||||||
onClose={() => setOpenMenu(null)}
|
|
||||||
/>,
|
|
||||||
showLabels,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel className="p-4">
|
<Panel className="p-4">
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
<div className="grid gap-3 md:grid-cols-[minmax(0,1.6fr)_minmax(12rem,0.7fr)] md:items-end">
|
||||||
<div className="flex items-center gap-3">
|
<Input
|
||||||
<Input
|
className="h-[46px] py-0"
|
||||||
placeholder="Поиск по заявке, клиенту, телефону"
|
placeholder="Поиск по группе, клиенту или телефону"
|
||||||
value={filters.query}
|
value={filters.query}
|
||||||
onChange={(event) => updateFilter("query", event.target.value)}
|
onChange={(event) => updateFilter("query", event.target.value)}
|
||||||
/>
|
/>
|
||||||
<Button size="sm" variant="secondary" onClick={() => setIsMobileFiltersOpen((current) => !current)}>
|
|
||||||
Фильтры
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{activeChips.length ? (
|
|
||||||
<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.map((chip) => (
|
|
||||||
<Badge key={chip.key}>{chip.label}</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{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 ref={statusMenuRef} className="relative flex min-w-0 flex-col gap-2">
|
||||||
<div
|
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||||
className={[
|
Статус
|
||||||
"grid gap-3 xl:items-start",
|
</span>
|
||||||
activeChips.length ? "xl:grid-cols-[minmax(22rem,1.35fr)_minmax(0,1fr)]" : "",
|
<button
|
||||||
].join(" ")}
|
type="button"
|
||||||
>
|
aria-haspopup="listbox"
|
||||||
<Input
|
aria-expanded={isStatusOpen}
|
||||||
placeholder="Поиск по заявке, клиенту, телефону"
|
className={[
|
||||||
value={filters.query}
|
"flex h-[46px] w-full items-center justify-between rounded-2xl border px-4 text-left text-sm transition",
|
||||||
onChange={(event) => updateFilter("query", event.target.value)}
|
isStatusOpen
|
||||||
/>
|
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||||||
{activeChips.length ? (
|
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)] hover:border-[var(--color-accent)]",
|
||||||
<div className="min-h-[44px] rounded-[20px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] px-4 py-3">
|
].join(" ")}
|
||||||
<div className="text-xs uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
onClick={() => setIsStatusOpen((current) => !current)}
|
||||||
Активные фильтры
|
>
|
||||||
</div>
|
<span className="min-w-0 flex-1 truncate">{selectedStatusLabel}</span>
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
<span aria-hidden="true" className="ml-3 text-[var(--color-text-muted)]">
|
||||||
{activeChips.map((chip) => (
|
▾
|
||||||
<Badge key={chip.key}>{chip.label}</Badge>
|
</span>
|
||||||
))}
|
</button>
|
||||||
</div>
|
|
||||||
|
{isStatusOpen ? (
|
||||||
|
<div
|
||||||
|
role="listbox"
|
||||||
|
className="absolute left-0 right-0 top-full z-20 mt-2 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] shadow-soft"
|
||||||
|
>
|
||||||
|
{statusOptions.map((option) => {
|
||||||
|
const isSelected = option.value === statusValue;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
className={[
|
||||||
|
"flex w-full items-center justify-between px-4 py-3 text-left text-sm transition",
|
||||||
|
isSelected
|
||||||
|
? "bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||||||
|
: "text-[var(--color-text)] hover:bg-[var(--color-surface-strong)]",
|
||||||
|
].join(" ")}
|
||||||
|
onClick={() => {
|
||||||
|
updateFilter("displayStatus", option.value);
|
||||||
|
setIsStatusOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{option.label}</span>
|
||||||
|
{isSelected ? <span className="ml-3 text-[var(--color-accent)]">✓</span> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{renderAdvancedFilters({ showLabels: true })}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeChips.length ? (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{activeChips.map((chip) => (
|
||||||
|
<Badge key={chip.key}>{chip.label}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,44 +4,28 @@ import { describe, expect, it } from "vitest";
|
||||||
import { OrderFilters } from "./OrderFilters";
|
import { OrderFilters } from "./OrderFilters";
|
||||||
|
|
||||||
describe("OrderFilters", () => {
|
describe("OrderFilters", () => {
|
||||||
it("renders only the manager delivery filters", () => {
|
it("renders only the group delivery filters", () => {
|
||||||
const markup = renderToStaticMarkup(
|
const markup = renderToStaticMarkup(
|
||||||
<OrderFilters
|
<OrderFilters
|
||||||
filters={{
|
filters={{
|
||||||
query: "",
|
query: "",
|
||||||
status: "all",
|
displayStatus: "all",
|
||||||
stage: "all",
|
|
||||||
ownerRole: "all",
|
|
||||||
agingState: "all",
|
|
||||||
managerId: "all",
|
|
||||||
logisticianId: "all",
|
|
||||||
messenger: "all",
|
|
||||||
}}
|
}}
|
||||||
setFilters={() => {}}
|
setFilters={() => {}}
|
||||||
|
statusOptions={[
|
||||||
|
{ value: "all", label: "Все статусы" },
|
||||||
|
{ value: "status:ready_for_notification", label: "Готово к уведомлению" },
|
||||||
|
{ value: "delivery:agreed", label: "Согласовано" },
|
||||||
|
]}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(markup).toContain("Поиск по заявке, клиенту, телефону");
|
expect(markup).toContain("Поиск по группе, клиенту или телефону");
|
||||||
expect(markup).not.toContain("Активные фильтры");
|
|
||||||
expect(markup).not.toContain("Нет");
|
|
||||||
expect(markup).toContain("Статус");
|
expect(markup).toContain("Статус");
|
||||||
expect(markup).toContain("Канал");
|
expect(markup).toContain('aria-haspopup="listbox"');
|
||||||
expect(markup).toContain("aria-haspopup=\"listbox\"");
|
|
||||||
expect(markup).not.toContain("<select");
|
expect(markup).not.toContain("<select");
|
||||||
expect(markup).not.toContain("Новый");
|
expect(markup).not.toContain("Канал");
|
||||||
expect(markup).not.toContain("Требует уточнения");
|
expect(markup).not.toContain("Активные фильтры");
|
||||||
expect(markup).not.toContain("Подтверждён менеджером");
|
|
||||||
expect(markup).not.toContain("В очереди производства");
|
|
||||||
expect(markup).not.toContain("В производстве");
|
|
||||||
expect(markup).not.toContain("Отменён");
|
|
||||||
expect(markup).not.toContain("Телеграм");
|
|
||||||
expect(markup).not.toContain("ВКонтакте");
|
|
||||||
expect(markup).not.toContain("Макс");
|
|
||||||
expect(markup).not.toContain("Все этапы");
|
|
||||||
expect(markup).not.toContain("Все зоны ответственности");
|
|
||||||
expect(markup).not.toContain("Без фильтра по SLA");
|
|
||||||
expect(markup).not.toContain("Менеджер");
|
|
||||||
expect(markup).not.toContain("Логист");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows active filter chips only when filters are selected", () => {
|
it("shows active filter chips only when filters are selected", () => {
|
||||||
|
|
@ -49,20 +33,18 @@ describe("OrderFilters", () => {
|
||||||
<OrderFilters
|
<OrderFilters
|
||||||
filters={{
|
filters={{
|
||||||
query: "",
|
query: "",
|
||||||
status: "Доставка согласована",
|
displayStatus: "delivery:agreed",
|
||||||
stage: "all",
|
|
||||||
ownerRole: "all",
|
|
||||||
agingState: "all",
|
|
||||||
managerId: "all",
|
|
||||||
logisticianId: "all",
|
|
||||||
messenger: "СМС",
|
|
||||||
}}
|
}}
|
||||||
setFilters={() => {}}
|
setFilters={() => {}}
|
||||||
|
statusOptions={[
|
||||||
|
{ value: "all", label: "Все статусы" },
|
||||||
|
{ value: "status:ready_for_notification", label: "Готово к уведомлению" },
|
||||||
|
{ value: "delivery:agreed", label: "Согласовано" },
|
||||||
|
]}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(markup).toContain("Активные фильтры");
|
expect(markup).toContain("Согласовано");
|
||||||
expect(markup).toContain("Доставка согласована");
|
expect(markup).not.toContain("Активные фильтры");
|
||||||
expect(markup).toContain("СМС");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,110 +1,146 @@
|
||||||
import React from "react";
|
|
||||||
import { getStatusTone } from "../../constants/deliveryWorkflow";
|
|
||||||
import { demoUsers } from "../../data/mockAppData";
|
|
||||||
import { formatDateTime } from "../../utils/formatters";
|
import { formatDateTime } from "../../utils/formatters";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
import { OrderFilters } from "./OrderFilters";
|
import { OrderFilters } from "./OrderFilters";
|
||||||
|
import {
|
||||||
|
getOrderGroupDisplayStatusLabel,
|
||||||
|
getOrderGroupStatusTone,
|
||||||
|
} from "../../services/orderGroupViews";
|
||||||
|
|
||||||
const getUsers = (users) => (Array.isArray(users) && users.length ? users : demoUsers);
|
const buildGroupSummary = (group) => {
|
||||||
const resolveUserName = (users, userId) => getUsers(users).find((user) => user.id === userId)?.name || "Не назначен";
|
const orderCountLabel = `${group.ordersCount || 0} ${group.ordersCount === 1 ? "заказ" : group.ordersCount < 5 ? "заказа" : "заказов"}`;
|
||||||
const buildOrderSummary = (order) => {
|
const readyCountLabel = `${group.readyCount || 0} готовы`;
|
||||||
const leadItem = order.items?.[0] || "Состав не указан";
|
|
||||||
const leadComment = order.orderNotes?.[0]?.text || order.comments?.[0] || "Без уточнений";
|
return `${orderCountLabel} · ${readyCountLabel}`;
|
||||||
return `${leadItem}. ${leadComment}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const OrdersTable = ({ orders, selectedOrderId, onOpenOrder, users, filters, setFilters }) => {
|
const renderOrderNumbers = (group) => {
|
||||||
|
if (!Array.isArray(group.orderNumbers) || !group.orderNumbers.length) {
|
||||||
|
return "Номера не указаны";
|
||||||
|
}
|
||||||
|
|
||||||
|
return group.orderNumbers.slice(0, 3).join(" · ");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrdersTable = ({
|
||||||
|
orderGroups = [],
|
||||||
|
selectedOrderGroupId,
|
||||||
|
onOpenOrder,
|
||||||
|
filters,
|
||||||
|
setFilters,
|
||||||
|
statusOptions,
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Panel className="p-0">
|
<Panel className="p-0">
|
||||||
<div className="space-y-4 border-b border-[var(--color-border)] px-5 py-4">
|
<div className="space-y-4 border-b border-[var(--color-border)] px-5 py-4">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold">Реестр заказов</h2>
|
<h2 className="text-lg font-semibold">Группы доставки</h2>
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
Поиск по номеру, клиенту и телефону.
|
Поиск по группе, клиенту, телефону и дате доставки.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge tone="neutral">{orders.length}</Badge>
|
<Badge tone="neutral">{orderGroups.length}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filters && setFilters ? <OrderFilters filters={filters} setFilters={setFilters} /> : null}
|
{filters && setFilters ? (
|
||||||
|
<OrderFilters filters={filters} setFilters={setFilters} statusOptions={statusOptions} />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 p-4 md:hidden">
|
<div className="space-y-3 p-4 md:hidden">
|
||||||
{orders.map((order) => (
|
{!orderGroups.length ? (
|
||||||
|
<Panel className="border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
|
||||||
|
Группы не найдены. Попробуйте изменить поиск или статус.
|
||||||
|
</Panel>
|
||||||
|
) : null}
|
||||||
|
{orderGroups.map((group) => (
|
||||||
<button
|
<button
|
||||||
key={order.id}
|
key={group.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpenOrder(order.id)}
|
onClick={() => onOpenOrder(group.id)}
|
||||||
className={[
|
className={[
|
||||||
"w-full rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-left transition",
|
"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)]" : "",
|
selectedOrderGroupId === group.id ? "bg-[var(--color-accent-soft)]" : "",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="space-y-1">
|
||||||
<div>
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3">
|
||||||
<div className="font-medium">{order.orderNumber}</div>
|
<div className="min-w-0 truncate font-medium">
|
||||||
<div className="mt-1 text-sm text-[var(--color-text-muted)]">{order.customer.name}</div>
|
{group.displayTitle || group.customerName || group.groupKey}
|
||||||
|
</div>
|
||||||
|
<Badge className="self-start" tone={getOrderGroupStatusTone(group)}>
|
||||||
|
{getOrderGroupDisplayStatusLabel(group)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
{group.displaySubtitle || [group.customerPhone, group.customerDate].filter(Boolean).join(" · ")}
|
||||||
</div>
|
</div>
|
||||||
<Badge tone={getStatusTone(order.status)}>{order.status}</Badge>
|
|
||||||
</div>
|
</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)]">
|
<div className="mt-3 text-sm text-[var(--color-text-muted)]">{buildGroupSummary(group)}</div>
|
||||||
<span>{order.customer.phone}</span>
|
<div className="mt-2 text-sm text-[var(--color-text-muted)]">{renderOrderNumbers(group)}</div>
|
||||||
<span>{resolveUserName(users, order.managerId)}</span>
|
<div className="mt-3 text-xs text-[var(--color-text-muted)]">
|
||||||
<span>{formatDateTime(order.updatedAt)}</span>
|
{formatDateTime(group.updatedAt)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden overflow-x-auto md:block">
|
<div className="hidden overflow-x-auto md:block">
|
||||||
<table className="min-w-full border-collapse">
|
{!orderGroups.length ? (
|
||||||
|
<div className="px-5 py-6 text-sm text-[var(--color-text-muted)]">
|
||||||
|
Группы не найдены. Попробуйте изменить поиск или статус.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="min-w-full border-collapse">
|
||||||
<thead className="bg-[var(--color-surface-strong)] text-left text-xs uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
|
<thead className="bg-[var(--color-surface-strong)] text-left text-xs uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-5 py-4 font-medium">Заказ</th>
|
<th className="px-5 py-4 font-medium">Группа</th>
|
||||||
<th className="px-5 py-4 font-medium">Клиент</th>
|
<th className="px-5 py-4 font-medium">Клиент</th>
|
||||||
<th className="px-5 py-4 font-medium">Кратко</th>
|
<th className="px-5 py-4 font-medium">Номера</th>
|
||||||
<th className="px-5 py-4 font-medium">Статус</th>
|
<th className="px-5 py-4 font-medium">Статус</th>
|
||||||
<th className="px-5 py-4 font-medium">Менеджер</th>
|
<th className="px-5 py-4 font-medium">Готовность</th>
|
||||||
<th className="px-5 py-4 font-medium">Обновлён</th>
|
<th className="px-5 py-4 font-medium">Обновлён</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{orders.map((order) => (
|
{orderGroups.map((group) => (
|
||||||
<tr
|
<tr
|
||||||
key={order.id}
|
key={group.id}
|
||||||
className={[
|
className={[
|
||||||
"cursor-pointer border-t border-[var(--color-border)] transition hover:bg-[var(--color-accent-soft)]",
|
"cursor-pointer border-t border-[var(--color-border)] transition hover:bg-[var(--color-accent-soft)]",
|
||||||
selectedOrderId === order.id ? "bg-[var(--color-accent-soft)]" : "",
|
selectedOrderGroupId === group.id ? "bg-[var(--color-accent-soft)]" : "",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
onClick={() => onOpenOrder(order.id)}
|
onClick={() => onOpenOrder(group.id)}
|
||||||
>
|
>
|
||||||
<td className="px-5 py-4">
|
<td className="px-5 py-4">
|
||||||
<div className="font-medium">{order.orderNumber}</div>
|
<div className="font-medium">{group.displayTitle || group.customerName || group.groupKey}</div>
|
||||||
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
|
<div className="mt-1 text-sm text-[var(--color-text-muted)]">{group.groupKey}</div>
|
||||||
{order.customer.messenger}
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-4 text-sm">
|
<td className="px-5 py-4 text-sm">
|
||||||
<div>{order.customer.name}</div>
|
<div>{group.customerName}</div>
|
||||||
<div className="mt-1 text-[var(--color-text-muted)]">{order.customer.phone}</div>
|
<div className="mt-1 text-[var(--color-text-muted)]">
|
||||||
|
{group.customerPhone} · {group.customerDate}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="max-w-[340px] px-5 py-4 text-sm text-[var(--color-text-muted)]">
|
<td className="max-w-[340px] px-5 py-4 text-sm text-[var(--color-text-muted)]">
|
||||||
{buildOrderSummary(order)}
|
{renderOrderNumbers(group)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-4">
|
<td className="px-5 py-4">
|
||||||
<Badge tone={getStatusTone(order.status)}>{order.status}</Badge>
|
<Badge tone={getOrderGroupStatusTone(group)}>{getOrderGroupDisplayStatusLabel(group)}</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-4 text-sm">{resolveUserName(users, order.managerId)}</td>
|
|
||||||
<td className="px-5 py-4 text-sm text-[var(--color-text-muted)]">
|
<td className="px-5 py-4 text-sm text-[var(--color-text-muted)]">
|
||||||
{formatDateTime(order.updatedAt)}
|
{group.readyCount || 0}/{group.ordersCount || 0}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 text-sm text-[var(--color-text-muted)]">
|
||||||
|
{formatDateTime(group.updatedAt)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,20 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { OrdersTable } from "./OrdersTable";
|
import { OrdersTable } from "./OrdersTable";
|
||||||
|
|
||||||
const orders = [
|
const orderGroups = [
|
||||||
{
|
{
|
||||||
id: "o-1",
|
id: "o-1",
|
||||||
orderNumber: "CD-240031",
|
groupKey: "9780001231|16.04.26",
|
||||||
customer: {
|
displayTitle: "Мария Волкова",
|
||||||
name: "Мария Волкова",
|
displaySubtitle: "+7 978 000-12-31 · 16.04.26",
|
||||||
phone: "+7 978 000-12-31",
|
customerName: "Мария Волкова",
|
||||||
messenger: "СМС",
|
customerPhone: "+7 978 000-12-31",
|
||||||
},
|
customerDate: "16.04.26",
|
||||||
items: ["Кухня | 1 шт"],
|
orderNumbers: ["CD-240031"],
|
||||||
orderNotes: [{ text: "Подъезд узкий" }],
|
ordersCount: 1,
|
||||||
comments: ["Нужен созвон"],
|
readyCount: 1,
|
||||||
status: "Ожидает согласования доставки",
|
notReadyCount: 0,
|
||||||
managerId: "u-manager",
|
status: "ready_for_notification",
|
||||||
updatedAt: "2026-03-15T08:00:00Z",
|
updatedAt: "2026-03-15T08:00:00Z",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -25,19 +25,23 @@ describe("OrdersTable", () => {
|
||||||
it("renders desktop table and mobile card list", () => {
|
it("renders desktop table and mobile card list", () => {
|
||||||
const markup = renderToStaticMarkup(
|
const markup = renderToStaticMarkup(
|
||||||
<OrdersTable
|
<OrdersTable
|
||||||
orders={orders}
|
orderGroups={orderGroups}
|
||||||
selectedOrderId={null}
|
selectedOrderGroupId={null}
|
||||||
onOpenOrder={() => {}}
|
onOpenOrder={() => {}}
|
||||||
filters={{ search: "", status: "all", messenger: "all" }}
|
filters={{ query: "", status: "all" }}
|
||||||
setFilters={() => {}}
|
setFilters={() => {}}
|
||||||
|
statusOptions={[
|
||||||
|
{ value: "all", label: "Все статусы" },
|
||||||
|
{ value: "status:ready_for_notification", label: "Готово к уведомлению" },
|
||||||
|
{ value: "delivery:agreed", label: "Согласовано" },
|
||||||
|
]}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(markup).toContain("hidden overflow-x-auto md:block");
|
expect(markup).toContain("hidden overflow-x-auto md:block");
|
||||||
expect(markup).toContain("md:hidden");
|
expect(markup).toContain("md:hidden");
|
||||||
expect(markup).toContain("Поиск по номеру, клиенту и телефону.");
|
expect(markup).toContain("Поиск по группе, клиенту, телефону");
|
||||||
expect(markup).toContain("Поиск по заявке, клиенту, телефону");
|
expect(markup).toContain("Группы доставки");
|
||||||
expect(markup).toContain("CD-240031");
|
|
||||||
expect(markup).toContain("Мария Волкова");
|
expect(markup).toContain("Мария Волкова");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@ const UNKNOWN_EMAIL_ERROR_PATTERNS = [
|
||||||
/sign up is disabled/i,
|
/sign up is disabled/i,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const STALE_REFRESH_TOKEN_PATTERNS = [
|
||||||
|
/invalid refresh token/i,
|
||||||
|
/refresh token not found/i,
|
||||||
|
];
|
||||||
|
|
||||||
export const normalizeOtpError = (error) => {
|
export const normalizeOtpError = (error) => {
|
||||||
const message = error instanceof Error ? error.message : String(error || "");
|
const message = error instanceof Error ? error.message : String(error || "");
|
||||||
if (UNKNOWN_EMAIL_ERROR_PATTERNS.some((pattern) => pattern.test(message))) {
|
if (UNKNOWN_EMAIL_ERROR_PATTERNS.some((pattern) => pattern.test(message))) {
|
||||||
|
|
@ -25,6 +30,11 @@ export const normalizeOtpError = (error) => {
|
||||||
return error instanceof Error ? error : new Error(message || PROFILE_LOAD_ERROR);
|
return error instanceof Error ? error : new Error(message || PROFILE_LOAD_ERROR);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isStaleRefreshTokenError = (error) => {
|
||||||
|
const message = error instanceof Error ? error.message : String(error || "");
|
||||||
|
return STALE_REFRESH_TOKEN_PATTERNS.some((pattern) => pattern.test(message));
|
||||||
|
};
|
||||||
|
|
||||||
export const buildOtpRequestPayload = (email) => ({
|
export const buildOtpRequestPayload = (email) => ({
|
||||||
email,
|
email,
|
||||||
options: {
|
options: {
|
||||||
|
|
@ -102,6 +112,19 @@ export const AuthProvider = ({ children }) => {
|
||||||
setAuthError("");
|
setAuthError("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
supabase.auth.getSession().then(({ data, error }) => {
|
||||||
|
if (error && isStaleRefreshTokenError(error)) {
|
||||||
|
setUser(null);
|
||||||
|
setAuthError("Сессия истекла. Войдите заново.");
|
||||||
|
void supabase.auth.signOut({ scope: "local" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.session?.user) {
|
||||||
|
setUser(mapSessionUserToAuthUser(data.session.user));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return () => subscription.unsubscribe();
|
return () => subscription.unsubscribe();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -209,6 +232,10 @@ export const AuthProvider = ({ children }) => {
|
||||||
requestOtp,
|
requestOtp,
|
||||||
verifyOtp,
|
verifyOtp,
|
||||||
signOut,
|
signOut,
|
||||||
|
loginAsDemoUser: (demoUser) => {
|
||||||
|
setUser(demoUser);
|
||||||
|
setAuthError("");
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
UNKNOWN_EMAIL_ERROR,
|
UNKNOWN_EMAIL_ERROR,
|
||||||
buildOtpRequestPayload,
|
buildOtpRequestPayload,
|
||||||
|
isStaleRefreshTokenError,
|
||||||
mapProfileToAuthUser,
|
mapProfileToAuthUser,
|
||||||
mapSessionUserToAuthUser,
|
mapSessionUserToAuthUser,
|
||||||
normalizeOtpError,
|
normalizeOtpError,
|
||||||
|
|
@ -95,3 +96,13 @@ describe("normalizeOtpError", () => {
|
||||||
expect(normalizeOtpError(new Error("Network error")).message).toBe("Network error");
|
expect(normalizeOtpError(new Error("Network error")).message).toBe("Network error");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isStaleRefreshTokenError", () => {
|
||||||
|
it("detects missing refresh tokens from Supabase", () => {
|
||||||
|
expect(isStaleRefreshTokenError(new Error("Invalid Refresh Token: Refresh Token Not Found"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores unrelated errors", () => {
|
||||||
|
expect(isStaleRefreshTokenError(new Error("Network error"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -774,6 +774,143 @@ const extraDemoOrders = extraOrderSeeds.map(buildExtraDemoOrder);
|
||||||
|
|
||||||
export const demoOrders = [...baseDemoOrders, ...extraDemoOrders];
|
export const demoOrders = [...baseDemoOrders, ...extraDemoOrders];
|
||||||
|
|
||||||
|
export const demoOrderGroups = [
|
||||||
|
{
|
||||||
|
id: "953c5bda-7e77-47af-9b7f-9d2c2cf3e7c5",
|
||||||
|
groupKey: "3939375462|14.04.26",
|
||||||
|
customerName: "Калинина Дарья Егоровна",
|
||||||
|
customerPhone: "3939375462",
|
||||||
|
customerDate: "14.04.26",
|
||||||
|
ordersCount: 1,
|
||||||
|
readyCount: 1,
|
||||||
|
notReadyCount: 0,
|
||||||
|
orderNumbers: ["СФ Т\\ЕА-23094"],
|
||||||
|
status: "ready_for_notification",
|
||||||
|
deliveryStatus: "agreed",
|
||||||
|
deliveryHalfDay: "Первая половина дня",
|
||||||
|
smsSentAt: null,
|
||||||
|
createdFromExchangeAt: null,
|
||||||
|
sourceKey: null,
|
||||||
|
legacyCustomerName: null,
|
||||||
|
legacyCustomerPhone: null,
|
||||||
|
legacyCustomerPhoneNormalized: null,
|
||||||
|
legacyCustomerDate: null,
|
||||||
|
legacyOrdersTotal: null,
|
||||||
|
legacyOrdersReady: null,
|
||||||
|
legacyOrdersNotReady: null,
|
||||||
|
sourceOrders: null,
|
||||||
|
createdAt: "2026-05-05T09:43:53.750061+00:00",
|
||||||
|
updatedAt: "2026-05-05T09:43:53.750061+00:00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "6420ea0d-7a4d-4a18-94cc-7d6d0a4a22ac",
|
||||||
|
groupKey: "2263561168|17.04.26",
|
||||||
|
customerName: "Петров Константин Владимирович",
|
||||||
|
customerPhone: "2263561168",
|
||||||
|
customerDate: "17.04.26",
|
||||||
|
ordersCount: 2,
|
||||||
|
readyCount: 2,
|
||||||
|
notReadyCount: 0,
|
||||||
|
orderNumbers: ["СФ Т\\ЕА-21974", "СФ Т\\ЕА-21975"],
|
||||||
|
status: "ready_for_notification",
|
||||||
|
deliveryStatus: "driver_assigned",
|
||||||
|
deliveryHalfDay: "Вторая половина дня",
|
||||||
|
smsSentAt: "2026-05-05T11:10:00+00:00",
|
||||||
|
createdFromExchangeAt: "2026-05-05T09:20:00+00:00",
|
||||||
|
sourceKey: "1c-21974",
|
||||||
|
legacyCustomerName: null,
|
||||||
|
legacyCustomerPhone: null,
|
||||||
|
legacyCustomerPhoneNormalized: null,
|
||||||
|
legacyCustomerDate: null,
|
||||||
|
legacyOrdersTotal: null,
|
||||||
|
legacyOrdersReady: null,
|
||||||
|
legacyOrdersNotReady: null,
|
||||||
|
sourceOrders: null,
|
||||||
|
createdAt: "2026-05-05T09:43:53.750061+00:00",
|
||||||
|
updatedAt: "2026-05-05T11:10:00+00:00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2e5c0ca6-dbd9-4dfd-95ca-f449b8d12a24",
|
||||||
|
groupKey: "8926690125|17.03.26",
|
||||||
|
customerName: "Иванов Степан Дмитриевич",
|
||||||
|
customerPhone: "8926690125",
|
||||||
|
customerDate: "17.03.26",
|
||||||
|
ordersCount: 1,
|
||||||
|
readyCount: 0,
|
||||||
|
notReadyCount: 1,
|
||||||
|
orderNumbers: ["СФ Т\\ЕА-16477"],
|
||||||
|
status: "manual_work",
|
||||||
|
deliveryStatus: "pending_confirmation",
|
||||||
|
smsSentAt: null,
|
||||||
|
createdFromExchangeAt: null,
|
||||||
|
sourceKey: "1c-16477",
|
||||||
|
legacyCustomerName: null,
|
||||||
|
legacyCustomerPhone: null,
|
||||||
|
legacyCustomerPhoneNormalized: null,
|
||||||
|
legacyCustomerDate: null,
|
||||||
|
legacyOrdersTotal: null,
|
||||||
|
legacyOrdersReady: null,
|
||||||
|
legacyOrdersNotReady: null,
|
||||||
|
sourceOrders: null,
|
||||||
|
createdAt: "2026-05-05T09:43:53.750061+00:00",
|
||||||
|
updatedAt: "2026-05-05T09:43:53.750061+00:00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "30108722-e37b-424e-8307-328f7d80706e",
|
||||||
|
groupKey: "4227515073|11.04.26",
|
||||||
|
customerName: "Романов Кирилл Викторович",
|
||||||
|
customerPhone: "4227515073",
|
||||||
|
customerDate: "11.04.26",
|
||||||
|
ordersCount: 3,
|
||||||
|
readyCount: 3,
|
||||||
|
notReadyCount: 0,
|
||||||
|
orderNumbers: ["СФ Т\\ЕА-23120", "СФ Т\\ЕА-23123", "СФ Т\\ЕА-23129"],
|
||||||
|
status: "ready_for_notification",
|
||||||
|
deliveryStatus: "loaded",
|
||||||
|
deliveryHalfDay: "Первая половина дня",
|
||||||
|
smsSentAt: null,
|
||||||
|
createdFromExchangeAt: null,
|
||||||
|
sourceKey: "1c-23120",
|
||||||
|
legacyCustomerName: null,
|
||||||
|
legacyCustomerPhone: null,
|
||||||
|
legacyCustomerPhoneNormalized: null,
|
||||||
|
legacyCustomerDate: null,
|
||||||
|
legacyOrdersTotal: null,
|
||||||
|
legacyOrdersReady: null,
|
||||||
|
legacyOrdersNotReady: null,
|
||||||
|
sourceOrders: null,
|
||||||
|
createdAt: "2026-05-05T09:43:53.750061+00:00",
|
||||||
|
updatedAt: "2026-05-05T09:43:53.750061+00:00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "78a5db18-c603-4317-bfdb-989a69979e9a",
|
||||||
|
groupKey: "6206926364|20.04.26",
|
||||||
|
customerName: "Антонов Ярослав",
|
||||||
|
customerPhone: "6206926364",
|
||||||
|
customerDate: "20.04.26",
|
||||||
|
ordersCount: 1,
|
||||||
|
readyCount: 1,
|
||||||
|
notReadyCount: 0,
|
||||||
|
orderNumbers: ["СФ Т\\ЕА-24508"],
|
||||||
|
status: "sms_sent",
|
||||||
|
deliveryStatus: "on_route",
|
||||||
|
deliveryHalfDay: "Вторая половина дня",
|
||||||
|
smsSentAt: "2026-05-05T12:45:00+00:00",
|
||||||
|
createdFromExchangeAt: null,
|
||||||
|
sourceKey: null,
|
||||||
|
legacyCustomerName: null,
|
||||||
|
legacyCustomerPhone: null,
|
||||||
|
legacyCustomerPhoneNormalized: null,
|
||||||
|
legacyCustomerDate: null,
|
||||||
|
legacyOrdersTotal: null,
|
||||||
|
legacyOrdersReady: null,
|
||||||
|
legacyOrdersNotReady: null,
|
||||||
|
sourceOrders: null,
|
||||||
|
createdAt: "2026-05-05T09:43:53.750061+00:00",
|
||||||
|
updatedAt: "2026-05-05T12:45:00+00:00",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const demoNotifications = [
|
export const demoNotifications = [
|
||||||
{
|
{
|
||||||
id: "n-1",
|
id: "n-1",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,184 @@
|
||||||
|
import React from "react";
|
||||||
|
import { demoOrderGroups } from "../data/mockAppData";
|
||||||
|
import { fetchOrderGroups, updateOrderGroupDeliveryChoice } from "../services/supabase/orderGroupRepository";
|
||||||
|
import {
|
||||||
|
buildOrderGroupBuckets,
|
||||||
|
filterOrderGroups,
|
||||||
|
groupOrderGroupsByDate,
|
||||||
|
ORDER_GROUP_DISPLAY_STATUS_OPTIONS,
|
||||||
|
} from "../services/orderGroupViews";
|
||||||
|
import { hasSupabaseConfig } from "../supabaseClient";
|
||||||
|
|
||||||
|
const cloneLiveGroups = (groups) => (Array.isArray(groups) ? groups.map((group) => ({ ...group })) : []);
|
||||||
|
|
||||||
|
const getErrorMessage = (error, fallbackMessage) => {
|
||||||
|
if (!error) {
|
||||||
|
return fallbackMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message || fallbackMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error === "string") {
|
||||||
|
return error || fallbackMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return error?.message || fallbackMessage;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useOrderGroups = () => {
|
||||||
|
const [orderGroups, setOrderGroups] = React.useState(() =>
|
||||||
|
hasSupabaseConfig ? [] : cloneLiveGroups(demoOrderGroups),
|
||||||
|
);
|
||||||
|
const [filters, setFilters] = React.useState({
|
||||||
|
query: "",
|
||||||
|
displayStatus: "all",
|
||||||
|
});
|
||||||
|
const [selectedOrderGroupId, setSelectedOrderGroupId] = React.useState(() =>
|
||||||
|
hasSupabaseConfig ? null : demoOrderGroups[0]?.id ?? null,
|
||||||
|
);
|
||||||
|
const [isLoading, setIsLoading] = React.useState(hasSupabaseConfig);
|
||||||
|
const [loadError, setLoadError] = React.useState("");
|
||||||
|
const [isSavingDeliveryChoice, setIsSavingDeliveryChoice] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const loadLiveData = async () => {
|
||||||
|
if (!hasSupabaseConfig) {
|
||||||
|
setOrderGroups(cloneLiveGroups(demoOrderGroups));
|
||||||
|
setIsLoading(false);
|
||||||
|
setLoadError("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setLoadError("");
|
||||||
|
|
||||||
|
const groupsResult = await fetchOrderGroups();
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupsResult.error) {
|
||||||
|
setLoadError(groupsResult.error?.message || "Не удалось загрузить группы доставки");
|
||||||
|
setOrderGroups([]);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOrderGroups(groupsResult.data || []);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadLiveData();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!orderGroups.length) {
|
||||||
|
setSelectedOrderGroupId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedOrderGroupId || !orderGroups.some((group) => group.id === selectedOrderGroupId)) {
|
||||||
|
setSelectedOrderGroupId(orderGroups[0].id);
|
||||||
|
}
|
||||||
|
}, [orderGroups, selectedOrderGroupId]);
|
||||||
|
|
||||||
|
const statusOptions = ORDER_GROUP_DISPLAY_STATUS_OPTIONS;
|
||||||
|
|
||||||
|
const filteredOrderGroups = React.useMemo(
|
||||||
|
() => filterOrderGroups(orderGroups, filters),
|
||||||
|
[filters, orderGroups],
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibleOrderGroups = filteredOrderGroups;
|
||||||
|
const selectedOrderGroup =
|
||||||
|
visibleOrderGroups.find((group) => group.id === selectedOrderGroupId) ||
|
||||||
|
orderGroups.find((group) => group.id === selectedOrderGroupId) ||
|
||||||
|
visibleOrderGroups[0] ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
const orderGroupsByDate = React.useMemo(() => groupOrderGroupsByDate(orderGroups), [orderGroups]);
|
||||||
|
const deliveryGroupBuckets = React.useMemo(() => buildOrderGroupBuckets(orderGroups), [orderGroups]);
|
||||||
|
|
||||||
|
const saveManualDeliveryChoice = React.useCallback(async ({
|
||||||
|
orderGroupId,
|
||||||
|
deliveryDate,
|
||||||
|
deliveryTime,
|
||||||
|
}) => {
|
||||||
|
setIsSavingDeliveryChoice(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!hasSupabaseConfig) {
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
setOrderGroups((currentGroups) =>
|
||||||
|
currentGroups.map((group) =>
|
||||||
|
group.id === orderGroupId
|
||||||
|
? {
|
||||||
|
...group,
|
||||||
|
deliveryStatus: "agreed",
|
||||||
|
delivery_status: "agreed",
|
||||||
|
deliveryDate,
|
||||||
|
deliveryTime,
|
||||||
|
deliveryHalfDay: deliveryTime,
|
||||||
|
updatedAt,
|
||||||
|
}
|
||||||
|
: group,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await updateOrderGroupDeliveryChoice({
|
||||||
|
orderGroupId,
|
||||||
|
deliveryDate,
|
||||||
|
deliveryTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(result.error, "Не удалось сохранить согласование доставки"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setOrderGroups((currentGroups) =>
|
||||||
|
currentGroups.map((group) => (group.id === orderGroupId ? result.data : group)),
|
||||||
|
);
|
||||||
|
return { success: true, data: result.data };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error, "Не удалось сохранить согласование доставки"),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
setIsSavingDeliveryChoice(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
orderGroups,
|
||||||
|
allOrderGroups: orderGroups,
|
||||||
|
filteredOrderGroups,
|
||||||
|
visibleOrderGroups,
|
||||||
|
selectedOrderGroup,
|
||||||
|
selectedOrderGroupId,
|
||||||
|
setSelectedOrderGroupId,
|
||||||
|
filters,
|
||||||
|
setFilters,
|
||||||
|
statusOptions,
|
||||||
|
orderGroupsByDate,
|
||||||
|
deliveryGroupBuckets,
|
||||||
|
saveManualDeliveryChoice,
|
||||||
|
isSavingDeliveryChoice,
|
||||||
|
isLoading,
|
||||||
|
loadError,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -62,17 +62,19 @@ export const AppShell = ({
|
||||||
|
|
||||||
<div className="min-w-0 space-y-5 pb-24 xl:space-y-8 xl:pb-0">
|
<div className="min-w-0 space-y-5 pb-24 xl:space-y-8 xl:pb-0">
|
||||||
<Panel className="p-4 xl:hidden">
|
<Panel className="p-4 xl:hidden">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
||||||
Рабочая область
|
Рабочая область
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-2 truncate text-xl font-semibold">{sectionMeta?.label || "Панель"}</h2>
|
<h2 className="text-lg font-semibold leading-tight sm:text-xl md:text-2xl">
|
||||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
{sectionMeta?.label || "Панель"}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
{user.name} · {ROLE_LABELS[user.role]}
|
{user.name} · {ROLE_LABELS[user.role]}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex flex-wrap items-center justify-end gap-2 md:flex-shrink-0">
|
||||||
{onOpenGuide ? (
|
{onOpenGuide ? (
|
||||||
<Button size="sm" variant="ghost" onClick={onOpenGuide} aria-label="Справка">
|
<Button size="sm" variant="ghost" onClick={onOpenGuide} aria-label="Справка">
|
||||||
{isGuideOpen ? "Назад" : "?"}
|
{isGuideOpen ? "Назад" : "?"}
|
||||||
|
|
@ -119,26 +121,28 @@ export const AppShell = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{shouldShowMobileNav ? (
|
{shouldShowMobileNav ? (
|
||||||
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 backdrop-blur xl:hidden">
|
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 backdrop-blur xl:hidden">
|
||||||
<div className="mx-auto flex max-w-[1540px] gap-2 overflow-x-auto">
|
<div className="mx-auto flex max-w-[1540px] gap-2 overflow-x-auto">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.key}
|
key={item.key}
|
||||||
className={[
|
className={[
|
||||||
"flex min-w-[120px] flex-1 items-center justify-center gap-2 rounded-[18px] px-3 py-3 text-sm transition",
|
"flex min-w-[120px] flex-1 items-center justify-center gap-2 rounded-[18px] px-3 py-3 text-sm transition",
|
||||||
activeSection === item.key
|
activeSection === item.key
|
||||||
? "bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
|
? "bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
|
||||||
: "bg-[var(--color-surface)] text-[var(--color-text-muted)]",
|
: "bg-[var(--color-surface)] text-[var(--color-text-muted)]",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
onClick={() => onSectionChange(item.key)}
|
onClick={() => onSectionChange(item.key)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span className="truncate font-medium">{item.label}</span>
|
<span className="truncate font-medium">{item.label}</span>
|
||||||
{item.badge ? <Badge tone={activeSection === item.key ? "neutral" : "accent"}>{item.badge}</Badge> : null}
|
{item.badge ? (
|
||||||
</button>
|
<Badge tone={activeSection === item.key ? "neutral" : "accent"}>{item.badge}</Badge>
|
||||||
))}
|
) : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ describe("AppShell", () => {
|
||||||
expect(markup).toContain("xl:hidden");
|
expect(markup).toContain("xl:hidden");
|
||||||
expect(markup).toContain("fixed inset-x-0 bottom-0");
|
expect(markup).toContain("fixed inset-x-0 bottom-0");
|
||||||
expect(markup).toContain("min-w-0");
|
expect(markup).toContain("min-w-0");
|
||||||
|
expect(markup).toContain("flex flex-col gap-3 md:flex-row md:items-start md:justify-between");
|
||||||
expect(markup).toContain("Рабочая область");
|
expect(markup).toContain("Рабочая область");
|
||||||
expect(markup).toContain("Заказы");
|
expect(markup).toContain("Заказы");
|
||||||
expect(markup).toContain("aria-label=\"Справка\"");
|
expect(markup).toContain("aria-label=\"Справка\"");
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { DeliveryChoiceFlow } from "../components/client/DeliveryChoiceFlow";
|
import { DeliveryChoiceFlow } from "../components/client/DeliveryChoiceFlow";
|
||||||
import { DeliverySlotsPicker } from "../components/client/DeliverySlotsPicker";
|
import { DeliverySlotsPicker } from "../components/client/DeliverySlotsPicker";
|
||||||
|
import { getInvitationReferenceLabel } from "../components/client/invitationReference";
|
||||||
import { DeliveryStateNotice } from "../components/client/DeliveryStateNotice";
|
import { DeliveryStateNotice } from "../components/client/DeliveryStateNotice";
|
||||||
import { Panel } from "../components/UI/Panel";
|
import { Panel } from "../components/UI/Panel";
|
||||||
import { formatDeliveryDate } from "../components/client/deliveryDateFormatting";
|
import { formatDeliveryDate } from "../components/client/deliveryDateFormatting";
|
||||||
|
|
@ -10,12 +11,52 @@ import {
|
||||||
fetchDeliveryInvitation,
|
fetchDeliveryInvitation,
|
||||||
} from "../services/deliveryInvitationApi";
|
} from "../services/deliveryInvitationApi";
|
||||||
|
|
||||||
export const groupSlotsFromInvitation = (invitation) => {
|
const DELIVERY_TIMEZONE = "Europe/Simferopol";
|
||||||
|
|
||||||
|
const getBusinessTodayKey = (referenceDate = new Date()) => {
|
||||||
|
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: DELIVERY_TIMEZONE,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
}).formatToParts(referenceDate);
|
||||||
|
|
||||||
|
const year = parts.find((part) => part.type === "year")?.value || "";
|
||||||
|
const month = parts.find((part) => part.type === "month")?.value || "";
|
||||||
|
const day = parts.find((part) => part.type === "day")?.value || "";
|
||||||
|
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addDaysToDateKey = (dateKey, amount) => {
|
||||||
|
const baseDate = new Date(`${dateKey}T12:00:00Z`);
|
||||||
|
if (Number.isNaN(baseDate.getTime())) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
baseDate.setUTCDate(baseDate.getUTCDate() + amount);
|
||||||
|
return baseDate.toISOString().slice(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAllowedDeliveryDateKeys = (referenceDate = new Date()) => {
|
||||||
|
const todayKey = getBusinessTodayKey(referenceDate);
|
||||||
|
return new Set([addDaysToDateKey(todayKey, 1), addDaysToDateKey(todayKey, 2)].filter(Boolean));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAllowedDeliverySlotDate = (dateKey, referenceDate = new Date()) => {
|
||||||
|
if (!dateKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getAllowedDeliveryDateKeys(referenceDate).has(dateKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const groupSlotsFromInvitation = (invitation, referenceDate = new Date()) => {
|
||||||
if (!invitation) {
|
if (!invitation) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawSlots = invitation.availableSlots || [];
|
const rawSlots = Array.isArray(invitation.availableSlots) ? invitation.availableSlots : [];
|
||||||
const deliveryDate = invitation.deliveryDate;
|
const deliveryDate = invitation.deliveryDate;
|
||||||
const deliveryTime = invitation.deliveryTime;
|
const deliveryTime = invitation.deliveryTime;
|
||||||
|
|
||||||
|
|
@ -33,21 +74,55 @@ export const groupSlotsFromInvitation = (invitation) => {
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return rawSlots.map((raw, index) => {
|
return rawSlots
|
||||||
const parts = raw.split(",");
|
.map((raw, index) => {
|
||||||
const datePart = parts[0]?.trim() || "";
|
if (typeof raw === "string") {
|
||||||
const timePart = parts.slice(1).join(",").trim() || "";
|
const parts = raw.split(",");
|
||||||
|
const datePart = parts[0]?.trim() || "";
|
||||||
|
const timePart = parts.slice(1).join(",").trim() || "";
|
||||||
|
|
||||||
const parsedDate = datePart.replace(/[а-яё]+/gi, "").trim()
|
const parsedDate = datePart.replace(/[а-яё]+/gi, "").trim()
|
||||||
|| deliveryDate
|
|| deliveryDate
|
||||||
|| "";
|
|| "";
|
||||||
|
|
||||||
return {
|
if (!isAllowedDeliverySlotDate(parsedDate, referenceDate)) {
|
||||||
id: `slot-${index}-${raw}`,
|
return null;
|
||||||
date: parsedDate || deliveryDate || "",
|
}
|
||||||
time: timePart || deliveryTime || raw,
|
|
||||||
};
|
return {
|
||||||
});
|
id: `slot-${index}-${raw}`,
|
||||||
|
date: parsedDate || deliveryDate || "",
|
||||||
|
time: timePart || deliveryTime || raw,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw && typeof raw === "object") {
|
||||||
|
const slotId = typeof raw.id === "string" ? raw.id : `slot-${index}-${deliveryDate || "custom"}`;
|
||||||
|
const slotDate = typeof raw.date === "string" ? raw.date : deliveryDate || "";
|
||||||
|
const slotTime = typeof raw.time === "string"
|
||||||
|
? raw.time
|
||||||
|
: typeof raw.label === "string"
|
||||||
|
? raw.label
|
||||||
|
: deliveryTime || "";
|
||||||
|
|
||||||
|
if (!slotDate && !slotTime) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAllowedDeliverySlotDate(slotDate, referenceDate)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: slotId,
|
||||||
|
date: slotDate,
|
||||||
|
time: slotTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildDeliveryConfirmationPayload = ({
|
export const buildDeliveryConfirmationPayload = ({
|
||||||
|
|
@ -77,6 +152,16 @@ export const buildSelectedSlotFromInvitation = (invitation, slots = []) => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getClientDeliveryHeroDescription = (isActiveState, isChoiceSaved) => {
|
||||||
|
if (isChoiceSaved) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return isActiveState
|
||||||
|
? "Вам предложены варианты доставки. Выберите удобную дату и время."
|
||||||
|
: "По этому заказу согласование доставки завершено или передано логисту.";
|
||||||
|
};
|
||||||
|
|
||||||
export const ClientDeliveryPage = () => {
|
export const ClientDeliveryPage = () => {
|
||||||
const { token } = useParams();
|
const { token } = useParams();
|
||||||
const [invitation, setInvitation] = React.useState(null);
|
const [invitation, setInvitation] = React.useState(null);
|
||||||
|
|
@ -86,6 +171,7 @@ export const ClientDeliveryPage = () => {
|
||||||
const [selectedSlotId, setSelectedSlotId] = React.useState(null);
|
const [selectedSlotId, setSelectedSlotId] = React.useState(null);
|
||||||
const [selectedSlot, setSelectedSlot] = React.useState(null);
|
const [selectedSlot, setSelectedSlot] = React.useState(null);
|
||||||
const [choiceSaved, setChoiceSaved] = React.useState(false);
|
const [choiceSaved, setChoiceSaved] = React.useState(false);
|
||||||
|
const referenceDate = React.useMemo(() => new Date(), [token]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -128,75 +214,65 @@ export const ClientDeliveryPage = () => {
|
||||||
};
|
};
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
const slots = React.useMemo(
|
const slots = groupSlotsFromInvitation(invitation, referenceDate);
|
||||||
() => groupSlotsFromInvitation(invitation),
|
|
||||||
[invitation],
|
|
||||||
);
|
|
||||||
|
|
||||||
const invitationState = invitation?.state || "awaiting_choice";
|
const invitationState = invitation?.state || "awaiting_choice";
|
||||||
const isActiveState = ["awaiting_choice", "opened", "reminder_sent"].includes(invitationState);
|
const isActiveState = ["awaiting_choice", "opened", "reminder_sent"].includes(invitationState);
|
||||||
|
|
||||||
const invitationSelectedSlot = React.useMemo(
|
const invitationSelectedSlot = isActiveState ? null : buildSelectedSlotFromInvitation(invitation, slots);
|
||||||
() => (isActiveState ? null : buildSelectedSlotFromInvitation(invitation, slots)),
|
|
||||||
[invitation, slots, isActiveState],
|
|
||||||
);
|
|
||||||
|
|
||||||
const effectiveSelectedSlot = selectedSlot || invitationSelectedSlot;
|
const effectiveSelectedSlot = selectedSlot || invitationSelectedSlot;
|
||||||
const isChoiceSaved = choiceSaved || (!isActiveState && Boolean(invitationSelectedSlot));
|
const isChoiceSaved = choiceSaved || (!isActiveState && Boolean(invitationSelectedSlot));
|
||||||
const savedChoiceLabel = effectiveSelectedSlot
|
const savedChoiceLabel = effectiveSelectedSlot
|
||||||
? `${formatDeliveryDate(effectiveSelectedSlot.date)} / ${effectiveSelectedSlot.time}`
|
? `${formatDeliveryDate(effectiveSelectedSlot.date)} / ${effectiveSelectedSlot.time}`
|
||||||
: "";
|
: "";
|
||||||
|
const heroDescription = getClientDeliveryHeroDescription(isActiveState, isChoiceSaved);
|
||||||
|
|
||||||
const handleSaveChoice = React.useCallback(
|
const handleSaveChoice = async () => {
|
||||||
async () => {
|
if (!token) {
|
||||||
if (!token) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!effectiveSelectedSlot) {
|
if (!effectiveSelectedSlot) {
|
||||||
setError("Сначала выберите дату и половину дня.");
|
setError("Сначала выберите дату и половину дня.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setActionMessage("Сохраняем выбор...");
|
setActionMessage("Сохраняем выбор...");
|
||||||
setChoiceSaved(false);
|
setChoiceSaved(false);
|
||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await confirmDeliveryChoice({
|
await confirmDeliveryChoice({
|
||||||
token,
|
token,
|
||||||
deliveryTime: effectiveSelectedSlot.time,
|
deliveryTime: effectiveSelectedSlot.time,
|
||||||
deliveryDate: effectiveSelectedSlot.date,
|
deliveryDate: effectiveSelectedSlot.date,
|
||||||
});
|
});
|
||||||
const loadedInvitation = await fetchDeliveryInvitation(token);
|
const loadedInvitation = await fetchDeliveryInvitation(token);
|
||||||
setInvitation(loadedInvitation);
|
setInvitation(loadedInvitation);
|
||||||
setSelectedSlot(buildSelectedSlotFromInvitation(loadedInvitation, groupSlotsFromInvitation(loadedInvitation)) || effectiveSelectedSlot);
|
setSelectedSlot(
|
||||||
setChoiceSaved(true);
|
buildSelectedSlotFromInvitation(
|
||||||
setActionMessage("Выбор сохранен, спасибо.");
|
loadedInvitation,
|
||||||
} catch (confirmError) {
|
groupSlotsFromInvitation(loadedInvitation, referenceDate),
|
||||||
setActionMessage("");
|
) || effectiveSelectedSlot,
|
||||||
setError(confirmError instanceof Error ? confirmError.message : "Не удалось сохранить выбор");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[effectiveSelectedSlot, token],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSlotSelect = React.useCallback(
|
|
||||||
(slot) => {
|
|
||||||
setSelectedSlotId(slot.id);
|
|
||||||
setSelectedSlot(slot);
|
|
||||||
setChoiceSaved(false);
|
|
||||||
setActionMessage(
|
|
||||||
`Выбрано: ${slot.date ? `${formatDeliveryDate(slot.date)} / ${slot.time}` : slot.time}`,
|
|
||||||
);
|
);
|
||||||
setError("");
|
setChoiceSaved(true);
|
||||||
},
|
setActionMessage("Выбор сохранен, спасибо.");
|
||||||
[],
|
} catch (confirmError) {
|
||||||
);
|
setActionMessage("");
|
||||||
|
setError(confirmError instanceof Error ? confirmError.message : "Не удалось сохранить выбор");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRequestNewLink = React.useCallback(() => {
|
const handleSlotSelect = (slot) => {
|
||||||
setActionMessage("Если ссылка больше не работает, логист передаст новую ссылку вручную.");
|
setSelectedSlotId(slot.id);
|
||||||
}, []);
|
setSelectedSlot(slot);
|
||||||
|
setChoiceSaved(false);
|
||||||
|
setActionMessage(
|
||||||
|
`Выбрано: ${slot.date ? `${formatDeliveryDate(slot.date)} / ${slot.time}` : slot.time}`,
|
||||||
|
);
|
||||||
|
setError("");
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -232,17 +308,20 @@ export const ClientDeliveryPage = () => {
|
||||||
<Panel className="space-y-3 p-5 sm:p-6">
|
<Panel className="space-y-3 p-5 sm:p-6">
|
||||||
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Доставка заказа</p>
|
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Доставка заказа</p>
|
||||||
<h1 className="text-2xl font-semibold leading-tight sm:text-3xl">Согласование доставки</h1>
|
<h1 className="text-2xl font-semibold leading-tight sm:text-3xl">Согласование доставки</h1>
|
||||||
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
{heroDescription ? (
|
||||||
{isActiveState
|
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
? "Вам предложены варианты доставки. Выберите удобную дату и время."
|
{heroDescription}
|
||||||
: "По этому заказу согласование доставки завершено или передано логисту."}
|
</p>
|
||||||
</p>
|
) : null}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{isChoiceSaved && savedChoiceLabel ? (
|
{isChoiceSaved && savedChoiceLabel ? (
|
||||||
<Panel className="space-y-2 p-5 sm:p-6">
|
<Panel className="space-y-2 p-5 sm:p-6">
|
||||||
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Ваш выбор</p>
|
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Ваш выбор</p>
|
||||||
<h2 className="text-xl font-semibold leading-tight">Сохранено: {savedChoiceLabel}</h2>
|
<h2 className="text-xl font-semibold leading-tight">Сохранено: {savedChoiceLabel}</h2>
|
||||||
|
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
|
{getInvitationReferenceLabel(invitation)}
|
||||||
|
</p>
|
||||||
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
Статус: доставка уже согласована. При повторном открытии этой ссылки будет показан тот же выбор.
|
Статус: доставка уже согласована. При повторном открытии этой ссылки будет показан тот же выбор.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -262,7 +341,6 @@ export const ClientDeliveryPage = () => {
|
||||||
invitation={invitation}
|
invitation={invitation}
|
||||||
selectedSlot={effectiveSelectedSlot}
|
selectedSlot={effectiveSelectedSlot}
|
||||||
onConfirmChoice={handleSaveChoice}
|
onConfirmChoice={handleSaveChoice}
|
||||||
onRequestNewLink={handleRequestNewLink}
|
|
||||||
/>
|
/>
|
||||||
) : !isChoiceSaved ? (
|
) : !isChoiceSaved ? (
|
||||||
<DeliveryStateNotice state={invitationState} />
|
<DeliveryStateNotice state={invitationState} />
|
||||||
|
|
@ -272,7 +350,17 @@ export const ClientDeliveryPage = () => {
|
||||||
<Panel className="p-5 text-sm leading-6 text-[var(--color-text-muted)] sm:p-6">{actionMessage}</Panel>
|
<Panel className="p-5 text-sm leading-6 text-[var(--color-text-muted)] sm:p-6">{actionMessage}</Panel>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!loading && error && invitation ? <DeliveryStateNotice state="default" /> : null}
|
{!loading && error && invitation ? (
|
||||||
|
<Panel className="space-y-2 border-[rgba(204,112,0,0.28)] bg-[var(--color-surface)] p-5 sm:p-6">
|
||||||
|
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">
|
||||||
|
Не удалось сохранить
|
||||||
|
</p>
|
||||||
|
<h2 className="text-2xl font-semibold leading-tight">
|
||||||
|
Проверьте выбор еще раз
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm leading-6 text-[var(--color-text-muted)]">{error}</p>
|
||||||
|
</Panel>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { getInvitationReferenceLabel } from "../components/client/invitationReference";
|
||||||
import {
|
import {
|
||||||
buildDeliveryConfirmationPayload,
|
buildDeliveryConfirmationPayload,
|
||||||
buildSelectedSlotFromInvitation,
|
buildSelectedSlotFromInvitation,
|
||||||
|
getClientDeliveryHeroDescription,
|
||||||
groupSlotsFromInvitation,
|
groupSlotsFromInvitation,
|
||||||
} from "./ClientDeliveryPage";
|
} from "./ClientDeliveryPage";
|
||||||
|
|
||||||
|
|
@ -13,7 +15,7 @@ describe("ClientDeliveryPage helpers", () => {
|
||||||
"2026-04-15, До обеда",
|
"2026-04-15, До обеда",
|
||||||
"2026-04-15, После обеда",
|
"2026-04-15, После обеда",
|
||||||
],
|
],
|
||||||
}),
|
}, new Date("2026-04-14T09:00:00Z")),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{
|
{
|
||||||
id: "slot-0-2026-04-15, До обеда",
|
id: "slot-0-2026-04-15, До обеда",
|
||||||
|
|
@ -58,7 +60,7 @@ describe("ClientDeliveryPage helpers", () => {
|
||||||
"2026-04-15, До обеда",
|
"2026-04-15, До обеда",
|
||||||
"2026-04-15, После обеда",
|
"2026-04-15, После обеда",
|
||||||
],
|
],
|
||||||
}),
|
}, new Date("2026-04-13T09:00:00Z")),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{
|
{
|
||||||
id: "slot-0-2026-04-14, До обеда",
|
id: "slot-0-2026-04-14, До обеда",
|
||||||
|
|
@ -104,4 +106,85 @@ describe("ClientDeliveryPage helpers", () => {
|
||||||
time: "После обеда",
|
time: "После обеда",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ignores malformed slots and keeps object-based slots usable", () => {
|
||||||
|
expect(
|
||||||
|
groupSlotsFromInvitation({
|
||||||
|
deliveryDate: "2026-04-15",
|
||||||
|
availableSlots: [
|
||||||
|
null,
|
||||||
|
{ id: "slot-object", date: "2026-04-15", time: "Первая половина дня" },
|
||||||
|
42,
|
||||||
|
],
|
||||||
|
}, new Date("2026-04-14T09:00:00Z")),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
id: "slot-object",
|
||||||
|
date: "2026-04-15",
|
||||||
|
time: "Первая половина дня",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps only tomorrow and the day after tomorrow", () => {
|
||||||
|
expect(
|
||||||
|
groupSlotsFromInvitation(
|
||||||
|
{
|
||||||
|
availableSlots: [
|
||||||
|
"2026-04-15, Первая половина дня",
|
||||||
|
"2026-04-15, Вторая половина дня",
|
||||||
|
"2026-04-16, Первая половина дня",
|
||||||
|
"2026-04-16, Вторая половина дня",
|
||||||
|
"2026-04-17, Первая половина дня",
|
||||||
|
"2026-04-17, Вторая половина дня",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
new Date("2026-04-14T09:00:00Z"),
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
id: "slot-0-2026-04-15, Первая половина дня",
|
||||||
|
date: "2026-04-15",
|
||||||
|
time: "Первая половина дня",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "slot-1-2026-04-15, Вторая половина дня",
|
||||||
|
date: "2026-04-15",
|
||||||
|
time: "Вторая половина дня",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "slot-2-2026-04-16, Первая половина дня",
|
||||||
|
date: "2026-04-16",
|
||||||
|
time: "Первая половина дня",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "slot-3-2026-04-16, Вторая половина дня",
|
||||||
|
date: "2026-04-16",
|
||||||
|
time: "Вторая половина дня",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the invoice number from order items over the customer name", () => {
|
||||||
|
expect(
|
||||||
|
getInvitationReferenceLabel({
|
||||||
|
orderNumber: "9787464846|04.05.26",
|
||||||
|
customerName: "Конаков Сергей Алексеевич (ДАРТС)",
|
||||||
|
orderItems: [
|
||||||
|
{ name: "СФ Т\\ЕА-28687", quantity: "" },
|
||||||
|
{ name: "СФ Т\\ЕА-28700", quantity: "" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toBe("Счета: СФ Т\\ЕА-28687, СФ Т\\ЕА-28700");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the hero helper text after the client saves the choice", () => {
|
||||||
|
expect(getClientDeliveryHeroDescription(true, true)).toBe("");
|
||||||
|
expect(getClientDeliveryHeroDescription(true, false)).toBe(
|
||||||
|
"Вам предложены варианты доставки. Выберите удобную дату и время.",
|
||||||
|
);
|
||||||
|
expect(getClientDeliveryHeroDescription(false, false)).toBe(
|
||||||
|
"По этому заказу согласование доставки завершено или передано логисту.",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
import { DRIVER_STATUSES } from "../constants/deliveryWorkflow";
|
|
||||||
import { DriverDeliveryDetail } from "../components/driver/DriverDeliveryDetail";
|
|
||||||
import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
|
import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
|
||||||
import { DeliverySetDetailPanel } from "../components/logistics/DeliverySetDetailPanel";
|
|
||||||
import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard";
|
import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard";
|
||||||
import { OrderDetailPanel } from "../components/orders/OrderDetailPanel";
|
import { OrderDetailPanel } from "../components/orders/OrderDetailPanel";
|
||||||
import { OrdersTable } from "../components/orders/OrdersTable";
|
import { OrdersTable } from "../components/orders/OrdersTable";
|
||||||
|
|
@ -12,24 +9,24 @@ import { Modal } from "../components/UI/Modal";
|
||||||
import { Panel } from "../components/UI/Panel";
|
import { Panel } from "../components/UI/Panel";
|
||||||
import { ProductGuidePanel } from "../components/dashboard/ProductGuidePanel";
|
import { ProductGuidePanel } from "../components/dashboard/ProductGuidePanel";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { useOrders } from "../hooks/useOrders";
|
import { useOrderGroups } from "../hooks/useOrderGroups";
|
||||||
import { AppShell } from "../layouts/AppShell";
|
import { AppShell } from "../layouts/AppShell";
|
||||||
|
|
||||||
const ROLE_SECTION = {
|
const ROLE_SECTION = {
|
||||||
manager: {
|
manager: {
|
||||||
key: "orders",
|
key: "orders",
|
||||||
label: "Заказы",
|
label: "Группы",
|
||||||
description: "Реестр заказов доставки, поиск и просмотр карточки.",
|
description: "Реестр групп доставки, поиск и просмотр карточки.",
|
||||||
},
|
},
|
||||||
logistician: {
|
logistician: {
|
||||||
key: "logistics",
|
key: "logistics",
|
||||||
label: "Логистика",
|
label: "Логистика",
|
||||||
description: "Готовые заказы на сегодня и ближайшие слоты доставки.",
|
description: "Группы доставки по готовности к уведомлению.",
|
||||||
},
|
},
|
||||||
driver: {
|
driver: {
|
||||||
key: "deliveries",
|
key: "deliveries",
|
||||||
label: "Мои доставки",
|
label: "Мои доставки",
|
||||||
description: "Список доставок, адреса и состав заказа.",
|
description: "Группы доставки по датам и статусам.",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -38,38 +35,31 @@ export const DashboardPage = () => {
|
||||||
const userRole = user?.role;
|
const userRole = user?.role;
|
||||||
const section = ROLE_SECTION[userRole] || ROLE_SECTION.manager;
|
const section = ROLE_SECTION[userRole] || ROLE_SECTION.manager;
|
||||||
const [activeSection, setActiveSection] = React.useState(section.key);
|
const [activeSection, setActiveSection] = React.useState(section.key);
|
||||||
const [isOrderModalOpen, setIsOrderModalOpen] = React.useState(false);
|
const [isGroupModalOpen, setIsGroupModalOpen] = React.useState(false);
|
||||||
const [isDeliverySetModalOpen, setIsDeliverySetModalOpen] = React.useState(false);
|
|
||||||
const [selectedDeliverySet, setSelectedDeliverySet] = React.useState(null);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
orders,
|
orderGroups,
|
||||||
allOrders,
|
allOrderGroups,
|
||||||
selectedOrder,
|
filteredOrderGroups,
|
||||||
selectedOrderId,
|
selectedOrderGroup,
|
||||||
setSelectedOrderId,
|
selectedOrderGroupId,
|
||||||
|
setSelectedOrderGroupId,
|
||||||
filters,
|
filters,
|
||||||
setFilters,
|
setFilters,
|
||||||
updateStatus,
|
statusOptions,
|
||||||
users,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
loadError,
|
loadError,
|
||||||
deliverySetBuckets,
|
saveManualDeliveryChoice,
|
||||||
} = useOrders(user);
|
isSavingDeliveryChoice,
|
||||||
|
} = useOrderGroups();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setActiveSection(section.key);
|
setActiveSection(section.key);
|
||||||
}, [section.key]);
|
}, [section.key]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
const openGroupModal = React.useCallback((groupId) => {
|
||||||
if (!selectedOrderId && allOrders[0]?.id) {
|
setSelectedOrderGroupId(groupId);
|
||||||
setSelectedOrderId(allOrders[0].id);
|
setIsGroupModalOpen(true);
|
||||||
}
|
|
||||||
}, [allOrders, selectedOrderId, setSelectedOrderId]);
|
|
||||||
|
|
||||||
const openDeliverySetModal = React.useCallback((deliverySet) => {
|
|
||||||
setSelectedDeliverySet(deliverySet);
|
|
||||||
setIsDeliverySetModalOpen(true);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
|
|
@ -77,7 +67,7 @@ export const DashboardPage = () => {
|
||||||
key: section.key,
|
key: section.key,
|
||||||
label: section.label,
|
label: section.label,
|
||||||
description: section.description,
|
description: section.description,
|
||||||
badge: String(allOrders.length || orders.length || 0),
|
badge: String(allOrderGroups.length || orderGroups.length || 0),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const guideSectionMeta = {
|
const guideSectionMeta = {
|
||||||
|
|
@ -88,15 +78,6 @@ export const DashboardPage = () => {
|
||||||
const activeSectionMeta = activeSection === "guide" ? guideSectionMeta : navItems[0];
|
const activeSectionMeta = activeSection === "guide" ? guideSectionMeta : navItems[0];
|
||||||
const isGuideOpen = activeSection === "guide";
|
const isGuideOpen = activeSection === "guide";
|
||||||
|
|
||||||
const openOrderModal = (orderId) => {
|
|
||||||
setSelectedOrderId(orderId);
|
|
||||||
setIsOrderModalOpen(true);
|
|
||||||
};
|
|
||||||
const driverOrders = React.useMemo(
|
|
||||||
() => allOrders.filter((order) => DRIVER_STATUSES.includes(order.status)),
|
|
||||||
[allOrders],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
@ -104,28 +85,27 @@ export const DashboardPage = () => {
|
||||||
const renderManagerWorkspace = () => (
|
const renderManagerWorkspace = () => (
|
||||||
<div className="space-y-6 xl:space-y-8">
|
<div className="space-y-6 xl:space-y-8">
|
||||||
<OrdersTable
|
<OrdersTable
|
||||||
orders={orders}
|
orderGroups={filteredOrderGroups}
|
||||||
selectedOrderId={selectedOrderId}
|
selectedOrderGroupId={selectedOrderGroupId}
|
||||||
onOpenOrder={openOrderModal}
|
onOpenOrder={openGroupModal}
|
||||||
users={users}
|
|
||||||
filters={filters}
|
filters={filters}
|
||||||
setFilters={setFilters}
|
setFilters={setFilters}
|
||||||
|
statusOptions={statusOptions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderLogisticsWorkspace = () => (
|
const renderLogisticsWorkspace = () => (
|
||||||
<div className="space-y-6 xl:space-y-8">
|
<div className="space-y-6 xl:space-y-8">
|
||||||
<LogisticsReadinessBoard deliverySetBuckets={deliverySetBuckets} onSelectSet={openDeliverySetModal} />
|
<LogisticsReadinessBoard orderGroups={allOrderGroups} onSelectSet={openGroupModal} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderDriverWorkspace = () => (
|
const renderDriverWorkspace = () => (
|
||||||
<div className="space-y-6 xl:space-y-8">
|
<div className="space-y-6 xl:space-y-8">
|
||||||
<DriverDeliveryPlanner
|
<DriverDeliveryPlanner
|
||||||
orders={driverOrders}
|
orderGroups={allOrderGroups}
|
||||||
onOpenOrder={openOrderModal}
|
onOpenOrder={openGroupModal}
|
||||||
onStatusChange={(orderId, nextStatus) => updateStatus(orderId, nextStatus, user.name)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -170,76 +150,26 @@ export const DashboardPage = () => {
|
||||||
|
|
||||||
{renderActiveSection()}
|
{renderActiveSection()}
|
||||||
|
|
||||||
<Modal isOpen={isOrderModalOpen} onClose={() => setIsOrderModalOpen(false)}>
|
<Modal isOpen={isGroupModalOpen} onClose={() => setIsGroupModalOpen(false)}>
|
||||||
{user.role === "driver" ? (
|
|
||||||
<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={() => setIsOrderModalOpen(false)}>
|
|
||||||
Закрыть
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<DriverDeliveryDetail
|
|
||||||
order={selectedOrder}
|
|
||||||
onStatusChange={(nextStatus) =>
|
|
||||||
selectedOrder && updateStatus(selectedOrder.id, nextStatus, user.name)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<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={() => setIsOrderModalOpen(false)}>
|
|
||||||
Закрыть
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<OrderDetailPanel order={selectedOrder} users={users} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
isOpen={isDeliverySetModalOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setIsDeliverySetModalOpen(false);
|
|
||||||
setSelectedDeliverySet(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-semibold">Карточка набора доставки</h3>
|
<h3 className="text-xl font-semibold">Карточка группы доставки</h3>
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">
|
|
||||||
Все связанные заказы, их производственные шаги и статус согласования.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDeliverySetModalOpen(false);
|
setIsGroupModalOpen(false);
|
||||||
setSelectedDeliverySet(null);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Закрыть
|
Закрыть
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<DeliverySetDetailPanel
|
<OrderDetailPanel
|
||||||
deliverySet={selectedDeliverySet}
|
order={selectedOrderGroup}
|
||||||
onClose={() => {
|
canManageDelivery={["manager", "logistician", "admin"].includes(userRole)}
|
||||||
setIsDeliverySetModalOpen(false);
|
onSaveManualDeliveryChoice={saveManualDeliveryChoice}
|
||||||
setSelectedDeliverySet(null);
|
isSavingDeliveryChoice={isSavingDeliveryChoice}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,17 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { DashboardPage } from "./DashboardPage";
|
import { DashboardPage } from "./DashboardPage";
|
||||||
|
|
||||||
const { useAuthMock, useOrdersMock } = vi.hoisted(() => ({
|
const { useAuthMock, useOrderGroupsMock } = vi.hoisted(() => ({
|
||||||
useAuthMock: vi.fn(),
|
useAuthMock: vi.fn(),
|
||||||
useOrdersMock: vi.fn(),
|
useOrderGroupsMock: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../context/AuthContext", () => ({
|
vi.mock("../context/AuthContext", () => ({
|
||||||
useAuth: useAuthMock,
|
useAuth: useAuthMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../hooks/useOrders", () => ({
|
vi.mock("../hooks/useOrderGroups", () => ({
|
||||||
useOrders: useOrdersMock,
|
useOrderGroups: useOrderGroupsMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../layouts/AppShell", () => ({
|
vi.mock("../layouts/AppShell", () => ({
|
||||||
|
|
@ -29,102 +29,63 @@ vi.mock("../layouts/AppShell", () => ({
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const baseOrder = {
|
const baseGroup = {
|
||||||
id: "order-1",
|
id: "group-1",
|
||||||
orderNumber: "CD-240031",
|
groupKey: "9780001231|16.04.26",
|
||||||
status: "Ожидает согласования доставки",
|
displayTitle: "Мария Волкова",
|
||||||
|
displaySubtitle: "+7 978 000-12-31 · 16.04.26",
|
||||||
|
customerName: "Мария Волкова",
|
||||||
|
customerPhone: "+7 978 000-12-31",
|
||||||
|
customerDate: "16.04.26",
|
||||||
|
ordersCount: 1,
|
||||||
|
readyCount: 1,
|
||||||
|
notReadyCount: 0,
|
||||||
|
orderNumbers: ["CD-240031"],
|
||||||
|
status: "ready_for_notification",
|
||||||
|
deliveryStatus: "agreed",
|
||||||
|
delivery_status: "agreed",
|
||||||
|
deliveryDate: "2026-04-16",
|
||||||
|
deliveryTime: "Первая половина дня",
|
||||||
updatedAt: "2026-04-15T09:00:00Z",
|
updatedAt: "2026-04-15T09:00:00Z",
|
||||||
createdAt: "2026-04-14T09:00:00Z",
|
|
||||||
scheduledDelivery: "2026-04-16T12:00:00Z",
|
|
||||||
customer: {
|
|
||||||
name: "Мария Волкова",
|
|
||||||
phone: "+7 978 000-12-31",
|
|
||||||
address: "Симферополь, ул. Ленина, 10",
|
|
||||||
messenger: "СМС",
|
|
||||||
items: ["Кухонный гарнитур | 1 комплект"],
|
|
||||||
},
|
|
||||||
items: ["Кухонный гарнитур | 1 комплект"],
|
|
||||||
comments: ["Нужен звонок за час"],
|
|
||||||
orderNotes: [{ id: "note-1", text: "Подъезд узкий" }],
|
|
||||||
history: [],
|
|
||||||
chatMessages: [],
|
|
||||||
deliverySlots: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseDeliverySet = {
|
const mockOrderGroupsState = {
|
||||||
key: "set-1",
|
orderGroups: [baseGroup],
|
||||||
name: "Набор Марии Волковой",
|
allOrderGroups: [baseGroup],
|
||||||
status: "ready_to_launch",
|
filteredOrderGroups: [baseGroup],
|
||||||
readyAt: "2026-04-15T08:00:00Z",
|
visibleOrderGroups: [baseGroup],
|
||||||
readyReason: "all_accepted",
|
selectedOrderGroup: baseGroup,
|
||||||
sourceCustomerCity: "Симферополь",
|
selectedOrderGroupId: baseGroup.id,
|
||||||
orderCount: 1,
|
setSelectedOrderGroupId: vi.fn(),
|
||||||
linkedBillTexts: "УН-00031",
|
|
||||||
orders: [baseOrder],
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockOrdersState = {
|
|
||||||
orders: [baseOrder],
|
|
||||||
allOrders: [baseOrder],
|
|
||||||
selectedOrder: baseOrder,
|
|
||||||
selectedOrderId: baseOrder.id,
|
|
||||||
setSelectedOrderId: vi.fn(),
|
|
||||||
filters: {
|
filters: {
|
||||||
query: "",
|
query: "",
|
||||||
status: "all",
|
displayStatus: "all",
|
||||||
stage: "all",
|
|
||||||
ownerRole: "all",
|
|
||||||
agingState: "all",
|
|
||||||
managerId: "all",
|
|
||||||
logisticianId: "all",
|
|
||||||
messenger: "all",
|
|
||||||
},
|
},
|
||||||
setFilters: vi.fn(),
|
setFilters: vi.fn(),
|
||||||
notifications: [],
|
|
||||||
pushNotification: vi.fn(),
|
|
||||||
updateStatus: vi.fn(),
|
|
||||||
addChatMessage: vi.fn(),
|
|
||||||
addInternalMessage: vi.fn(),
|
|
||||||
addOrderNote: vi.fn(),
|
|
||||||
assignDriver: vi.fn(),
|
|
||||||
reassignDelivery: vi.fn(),
|
|
||||||
autoAssignLogisticians: vi.fn(),
|
|
||||||
saveDriverRouteOrder: vi.fn(),
|
|
||||||
metrics: {
|
|
||||||
total: 1,
|
|
||||||
readyToShip: 1,
|
|
||||||
awaitingDeliveryCoordination: 1,
|
|
||||||
exceptions: 0,
|
|
||||||
inLogistics: 1,
|
|
||||||
},
|
|
||||||
agingAlerts: [],
|
|
||||||
agingSummary: { warning: 0, critical: 0 },
|
|
||||||
deliverySetBuckets: {
|
deliverySetBuckets: {
|
||||||
approaching: [],
|
ready_to_launch: [baseGroup],
|
||||||
ready_to_launch: [baseDeliverySet],
|
sms_sent: [],
|
||||||
awaiting_client: [],
|
|
||||||
manual_work: [],
|
manual_work: [],
|
||||||
agreed: [],
|
|
||||||
completed: [],
|
|
||||||
},
|
},
|
||||||
users: [
|
statusOptions: [
|
||||||
{ id: "u-manager", name: "Анна", role: "manager" },
|
{ value: "all", label: "Все статусы" },
|
||||||
{ id: "u-logistics", name: "Ольга", role: "logistician" },
|
{ value: "status:ready_for_notification", label: "Готово к уведомлению" },
|
||||||
{ id: "u-driver", name: "Иван", role: "driver" },
|
{ value: "delivery:agreed", label: "Согласовано" },
|
||||||
],
|
],
|
||||||
isSupabaseBacked: true,
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
loadError: "",
|
loadError: "",
|
||||||
|
saveManualDeliveryChoice: vi.fn(),
|
||||||
|
isSavingDeliveryChoice: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("DashboardPage", () => {
|
describe("DashboardPage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date("2026-04-15T09:00:00Z"));
|
vi.setSystemTime(new Date("2026-04-15T09:00:00Z"));
|
||||||
useOrdersMock.mockReturnValue(mockOrdersState);
|
useOrderGroupsMock.mockReturnValue(mockOrderGroupsState);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the manager dashboard on the delivery registry only", () => {
|
it("keeps the manager dashboard on the group registry only", () => {
|
||||||
useAuthMock.mockReturnValue({
|
useAuthMock.mockReturnValue({
|
||||||
user: { id: "u-manager", name: "Анна", role: "manager" },
|
user: { id: "u-manager", name: "Анна", role: "manager" },
|
||||||
signOut: vi.fn(),
|
signOut: vi.fn(),
|
||||||
|
|
@ -136,8 +97,8 @@ describe("DashboardPage", () => {
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(markup).toContain("Реестр заказов");
|
expect(markup).toContain("Группы доставки");
|
||||||
expect(markup).toContain("Поиск по номеру, клиенту и телефону.");
|
expect(markup).toContain("Поиск по группе, клиенту, телефону и дате доставки.");
|
||||||
expect(markup).toContain("aria-label=\"Справка\"");
|
expect(markup).toContain("aria-label=\"Справка\"");
|
||||||
expect(markup).not.toContain("<span>Справка</span>");
|
expect(markup).not.toContain("<span>Справка</span>");
|
||||||
expect(markup).not.toContain("доставочный контур");
|
expect(markup).not.toContain("доставочный контур");
|
||||||
|
|
@ -169,7 +130,7 @@ describe("DashboardPage", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(markup).toContain("Наборы доставки");
|
expect(markup).toContain("Наборы доставки");
|
||||||
expect(markup).toContain("Готово к запуску");
|
expect(markup).toContain("Готовы к уведомлению");
|
||||||
expect(markup).not.toContain("Управление ботами");
|
expect(markup).not.toContain("Управление ботами");
|
||||||
expect(markup).not.toContain("рабочая панель");
|
expect(markup).not.toContain("рабочая панель");
|
||||||
expect(markup).not.toContain("Сегодня");
|
expect(markup).not.toContain("Сегодня");
|
||||||
|
|
@ -179,34 +140,14 @@ describe("DashboardPage", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the driver dashboard on the deliveries list only", () => {
|
it("keeps the driver dashboard on the deliveries list only", () => {
|
||||||
useOrdersMock.mockReturnValue({
|
useOrderGroupsMock.mockReturnValue({
|
||||||
...mockOrdersState,
|
...mockOrderGroupsState,
|
||||||
orders: [
|
orderGroups: [baseGroup],
|
||||||
{
|
allOrderGroups: [baseGroup],
|
||||||
...baseOrder,
|
filteredOrderGroups: [baseGroup],
|
||||||
id: "driver-order-1",
|
visibleOrderGroups: [baseGroup],
|
||||||
status: "Назначен водитель",
|
selectedOrderGroup: baseGroup,
|
||||||
scheduledDelivery: "2026-04-15T12:00:00Z",
|
selectedOrderGroupId: baseGroup.id,
|
||||||
deliverySlots: [{ id: "slot-1", date: "2026-04-15", time: "До обеда" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
allOrders: [
|
|
||||||
{
|
|
||||||
...baseOrder,
|
|
||||||
id: "driver-order-1",
|
|
||||||
status: "Назначен водитель",
|
|
||||||
scheduledDelivery: "2026-04-15T12:00:00Z",
|
|
||||||
deliverySlots: [{ id: "slot-1", date: "2026-04-15", time: "До обеда" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
selectedOrder: {
|
|
||||||
...baseOrder,
|
|
||||||
id: "driver-order-1",
|
|
||||||
status: "Назначен водитель",
|
|
||||||
scheduledDelivery: "2026-04-15T12:00:00Z",
|
|
||||||
deliverySlots: [{ id: "slot-1", date: "2026-04-15", time: "До обеда" }],
|
|
||||||
},
|
|
||||||
selectedOrderId: "driver-order-1",
|
|
||||||
});
|
});
|
||||||
useAuthMock.mockReturnValue({
|
useAuthMock.mockReturnValue({
|
||||||
user: { id: "u-driver", name: "Иван", role: "driver" },
|
user: { id: "u-driver", name: "Иван", role: "driver" },
|
||||||
|
|
@ -220,8 +161,8 @@ describe("DashboardPage", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(markup).toContain("Мои доставки");
|
expect(markup).toContain("Мои доставки");
|
||||||
expect(markup).toContain("CD-240031");
|
|
||||||
expect(markup).toContain("Мария Волкова");
|
expect(markup).toContain("Мария Волкова");
|
||||||
|
expect(markup).toContain("CD-240031");
|
||||||
expect(markup).not.toContain("Канбан");
|
expect(markup).not.toContain("Канбан");
|
||||||
expect(markup).not.toContain("Календарь");
|
expect(markup).not.toContain("Календарь");
|
||||||
expect(markup).not.toContain("История");
|
expect(markup).not.toContain("История");
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
import { OtpLoginForm } from "../components/auth/OtpLoginForm";
|
import { ROLE_LABELS } from "../constants/roles";
|
||||||
import { useAuth } from "../context/AuthContext";
|
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 = () => {
|
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 [email, setEmail] = React.useState("");
|
||||||
const [otp, setOtp] = React.useState("");
|
const [otp, setOtp] = React.useState("");
|
||||||
const [error, setError] = React.useState("");
|
const [error, setError] = React.useState("");
|
||||||
|
|
@ -29,12 +34,37 @@ export const LoginPage = () => {
|
||||||
setError("");
|
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) {
|
if (user) {
|
||||||
return <Navigate to="/dashboard" replace />;
|
return <Navigate to="/dashboard" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<OtpLoginForm
|
||||||
email={email}
|
email={email}
|
||||||
setEmail={setEmail}
|
setEmail={setEmail}
|
||||||
|
|
@ -46,6 +76,29 @@ export const LoginPage = () => {
|
||||||
onVerifyOtp={handleVerifyOtp}
|
onVerifyOtp={handleVerifyOtp}
|
||||||
error={displayError}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import { supabase, hasSupabaseConfig } from "../supabaseClient";
|
import {
|
||||||
|
supabase,
|
||||||
|
hasSupabaseConfig,
|
||||||
|
} from "../supabaseClient";
|
||||||
|
|
||||||
const SHOWCASE_TOKEN = "showcase";
|
const SHOWCASE_TOKEN = "showcase";
|
||||||
const LOCAL_CLIENT_FLOW_TOKEN_PREFIX = "client-flow-";
|
const LOCAL_CLIENT_FLOW_TOKEN_PREFIX = "client-flow-";
|
||||||
|
|
@ -158,6 +161,24 @@ const invokeDeliveryFunction = async (functionName, body) => {
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const callDeliveryRpc = async (functionName, params) => {
|
||||||
|
if (!hasSupabaseConfig || !supabase?.rpc) {
|
||||||
|
throw new Error("Supabase is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase.rpc(functionName, params);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data && data.ok === false) {
|
||||||
|
throw new Error(data.error || "Request failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
export const __resetLocalDeliveryInvitationCache = () => {
|
export const __resetLocalDeliveryInvitationCache = () => {
|
||||||
localDeliveryInvitationCache.clear();
|
localDeliveryInvitationCache.clear();
|
||||||
};
|
};
|
||||||
|
|
@ -183,7 +204,9 @@ export const fetchDeliveryInvitation = async (token) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await invokeDeliveryFunction("get-delivery-invitation", { token });
|
const response = await callDeliveryRpc("get_delivery_invitation_by_token", {
|
||||||
|
p_token: token,
|
||||||
|
});
|
||||||
if (response?.invitation) {
|
if (response?.invitation) {
|
||||||
return cacheInvitation(response.invitation);
|
return cacheInvitation(response.invitation);
|
||||||
}
|
}
|
||||||
|
|
@ -215,15 +238,16 @@ export const confirmDeliveryChoice = async ({ token, deliveryDate, deliveryTime
|
||||||
return { ok: true, invitation };
|
return { ok: true, invitation };
|
||||||
}
|
}
|
||||||
|
|
||||||
return invokeDeliveryFunction("confirm-delivery-choice", {
|
return callDeliveryRpc("confirm_delivery_choice_by_token", {
|
||||||
token,
|
p_token: token,
|
||||||
deliveryDate,
|
p_delivery_date: deliveryDate,
|
||||||
deliveryTime,
|
p_delivery_time: deliveryTime,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const requestDeliveryLink = async ({
|
export const requestDeliveryLink = async ({
|
||||||
orderId,
|
orderId,
|
||||||
|
orderGroupId,
|
||||||
orderNumber,
|
orderNumber,
|
||||||
customerName,
|
customerName,
|
||||||
customerPhone,
|
customerPhone,
|
||||||
|
|
@ -232,6 +256,7 @@ export const requestDeliveryLink = async ({
|
||||||
}) =>
|
}) =>
|
||||||
invokeDeliveryFunction("create-delivery-invitation", {
|
invokeDeliveryFunction("create-delivery-invitation", {
|
||||||
orderId,
|
orderId,
|
||||||
|
orderGroupId,
|
||||||
orderNumber,
|
orderNumber,
|
||||||
customerName,
|
customerName,
|
||||||
customerPhone,
|
customerPhone,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const { invoke } = vi.hoisted(() => ({
|
const { fetchMock, invoke, rpc } = vi.hoisted(() => ({
|
||||||
|
fetchMock: vi.fn(),
|
||||||
invoke: vi.fn(),
|
invoke: vi.fn(),
|
||||||
|
rpc: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../supabaseClient", () => ({
|
vi.mock("../supabaseClient", () => ({
|
||||||
hasSupabaseConfig: true,
|
hasSupabaseConfig: true,
|
||||||
|
supabaseAnonKey: "anon-key",
|
||||||
|
supabaseUrl: "https://supa.example.test",
|
||||||
supabase: {
|
supabase: {
|
||||||
|
rpc,
|
||||||
functions: {
|
functions: {
|
||||||
invoke,
|
invoke,
|
||||||
},
|
},
|
||||||
|
|
@ -25,7 +30,10 @@ import {
|
||||||
|
|
||||||
describe("deliveryInvitationApi", () => {
|
describe("deliveryInvitationApi", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
fetchMock.mockReset();
|
||||||
invoke.mockReset();
|
invoke.mockReset();
|
||||||
|
rpc.mockReset();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
__resetLocalDeliveryInvitationCache();
|
__resetLocalDeliveryInvitationCache();
|
||||||
const storage = new Map();
|
const storage = new Map();
|
||||||
vi.stubGlobal("localStorage", {
|
vi.stubGlobal("localStorage", {
|
||||||
|
|
@ -43,7 +51,7 @@ describe("deliveryInvitationApi", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("loads a delivery invitation by token", async () => {
|
it("loads a delivery invitation by token", async () => {
|
||||||
invoke.mockResolvedValueOnce({
|
rpc.mockResolvedValueOnce({
|
||||||
data: {
|
data: {
|
||||||
ok: true,
|
ok: true,
|
||||||
invitation: {
|
invitation: {
|
||||||
|
|
@ -59,11 +67,20 @@ describe("deliveryInvitationApi", () => {
|
||||||
token: "token-1",
|
token: "token-1",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(invoke).toHaveBeenCalledWith("get-delivery-invitation", {
|
expect(rpc).toHaveBeenCalledWith("get_delivery_invitation_by_token", {
|
||||||
body: {
|
p_token: "token-1",
|
||||||
token: "token-1",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(invoke).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws a readable error when loading invitation fails", async () => {
|
||||||
|
rpc.mockResolvedValueOnce({
|
||||||
|
data: null,
|
||||||
|
error: new Error("Invitation not found"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(fetchDeliveryInvitation("token-1")).rejects.toThrow("Invitation not found");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns a local showcase invitation for the preview token", async () => {
|
it("returns a local showcase invitation for the preview token", async () => {
|
||||||
|
|
@ -129,7 +146,7 @@ describe("deliveryInvitationApi", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("confirms a delivery choice with the chosen slot", async () => {
|
it("confirms a delivery choice with the chosen slot", async () => {
|
||||||
invoke.mockResolvedValueOnce({
|
rpc.mockResolvedValueOnce({
|
||||||
data: {
|
data: {
|
||||||
ok: true,
|
ok: true,
|
||||||
orderId: "order-1",
|
orderId: "order-1",
|
||||||
|
|
@ -148,13 +165,12 @@ describe("deliveryInvitationApi", () => {
|
||||||
orderId: "order-1",
|
orderId: "order-1",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(invoke).toHaveBeenCalledWith("confirm-delivery-choice", {
|
expect(rpc).toHaveBeenCalledWith("confirm_delivery_choice_by_token", {
|
||||||
body: {
|
p_token: "token-1",
|
||||||
token: "token-1",
|
p_delivery_date: "2026-04-01",
|
||||||
deliveryDate: "2026-04-01",
|
p_delivery_time: "Первая половина дня",
|
||||||
deliveryTime: "Первая половина дня",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
expect(invoke).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates the local client invitation when confirmation falls back to cache", async () => {
|
it("updates the local client invitation when confirmation falls back to cache", async () => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,409 @@
|
||||||
|
const normalizeDate = (value) => (value ? String(value) : "");
|
||||||
|
|
||||||
|
const getDeliveryDate = (group) => normalizeDate(group.deliveryDate || group.customerDate || "");
|
||||||
|
|
||||||
|
export const DELIVERY_GROUP_STATUS_LABELS = {
|
||||||
|
pending_confirmation: "Ожидает согласования",
|
||||||
|
agreed: "Согласовано",
|
||||||
|
driver_assigned: "Назначен водитель",
|
||||||
|
loaded: "Загружено",
|
||||||
|
on_route: "В пути",
|
||||||
|
delivered: "Доставлено",
|
||||||
|
problem: "Проблема",
|
||||||
|
cancelled: "Отменено",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DRIVER_VISIBLE_DELIVERY_STATUSES = [
|
||||||
|
"agreed",
|
||||||
|
"driver_assigned",
|
||||||
|
"loaded",
|
||||||
|
"on_route",
|
||||||
|
"problem",
|
||||||
|
"delivered",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DRIVER_ACTIVE_DELIVERY_STATUSES = ["agreed", "driver_assigned", "loaded", "on_route", "problem"];
|
||||||
|
|
||||||
|
const HALF_DAY_LABELS = {
|
||||||
|
morning: "Первая половина дня",
|
||||||
|
afternoon: "Вторая половина дня",
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeDeliveryHalfDayLabel = (value) => {
|
||||||
|
const normalized = normalizeDate(value).trim();
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const lower = normalized.toLowerCase();
|
||||||
|
|
||||||
|
if (lower.includes("до обеда") || lower.includes("первая половина дня") || lower.includes("утро")) {
|
||||||
|
return HALF_DAY_LABELS.morning;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.includes("после обеда") || lower.includes("вторая половина дня") || lower.includes("вечер")) {
|
||||||
|
return HALF_DAY_LABELS.afternoon;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseJsonIfNeeded = (value) => {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const findDeliveryHalfDayInValue = (value) => {
|
||||||
|
const parsedValue = parseJsonIfNeeded(value);
|
||||||
|
|
||||||
|
if (Array.isArray(parsedValue)) {
|
||||||
|
for (const item of parsedValue) {
|
||||||
|
const match = findDeliveryHalfDayInValue(item);
|
||||||
|
if (match) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedValue && typeof parsedValue === "object") {
|
||||||
|
const candidates = [
|
||||||
|
parsedValue.deliveryTime,
|
||||||
|
parsedValue.delivery_time,
|
||||||
|
parsedValue.time,
|
||||||
|
parsedValue.deliveryHalfDay,
|
||||||
|
parsedValue.delivery_half_day,
|
||||||
|
parsedValue.window,
|
||||||
|
parsedValue.deliveryWindow,
|
||||||
|
parsedValue.delivery_window,
|
||||||
|
parsedValue.slot?.time,
|
||||||
|
parsedValue.deliverySlot?.time,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const match = normalizeDeliveryHalfDayLabel(candidate);
|
||||||
|
if (match) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const nestedValue of Object.values(parsedValue)) {
|
||||||
|
const match = findDeliveryHalfDayInValue(nestedValue);
|
||||||
|
if (match) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeDeliveryHalfDayLabel(parsedValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getOrderGroupDeliveryHalfDay = (group) =>
|
||||||
|
normalizeDeliveryHalfDayLabel(
|
||||||
|
group?.deliveryHalfDay ||
|
||||||
|
group?.deliveryTime ||
|
||||||
|
group?.deliveryWindow ||
|
||||||
|
findDeliveryHalfDayInValue(group?.sourceOrders),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const isOrderGroupAgreedForDelivery = (group) => {
|
||||||
|
if (!group) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isOrderGroupVisibleToDriver(group);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getOrderGroupDeliveryStatusLabel = (status) =>
|
||||||
|
DELIVERY_GROUP_STATUS_LABELS[status] || status || "Неизвестно";
|
||||||
|
|
||||||
|
export const getOrderGroupDisplayStatusLabel = (group) => {
|
||||||
|
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
|
||||||
|
|
||||||
|
if (deliveryStatus && deliveryStatus !== "pending_confirmation") {
|
||||||
|
return getOrderGroupDeliveryStatusLabel(deliveryStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getOrderGroupStatusLabel(group?.status);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getOrderGroupDisplayStatusValue = (group) => {
|
||||||
|
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
|
||||||
|
|
||||||
|
if (deliveryStatus && deliveryStatus !== "pending_confirmation") {
|
||||||
|
return `delivery:${deliveryStatus}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `status:${group?.status || "unknown"}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isOrderGroupVisibleToDriver = (group) => {
|
||||||
|
const deliveryStatus = group?.deliveryStatus || group?.delivery_status || "pending_confirmation";
|
||||||
|
return DRIVER_VISIBLE_DELIVERY_STATUSES.includes(deliveryStatus);
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseGroupDate = (value) => {
|
||||||
|
const normalized = normalizeDate(value);
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isoDateMatch = normalized.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
|
if (isoDateMatch) {
|
||||||
|
return new Date(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortDateMatch = normalized.match(/^(\d{2})\.(\d{2})\.(\d{2})$/);
|
||||||
|
if (shortDateMatch) {
|
||||||
|
const [, day, month, year] = shortDateMatch;
|
||||||
|
return new Date(Date.UTC(Number(`20${year}`), Number(month) - 1, Number(day)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new Date(normalized);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const filterOrderGroups = (groups, filters = {}) => {
|
||||||
|
const query = normalizeDate(filters.query).trim().toLowerCase();
|
||||||
|
const status = filters.status || "all";
|
||||||
|
const displayStatus = normalizeDate(filters.displayStatus || "all");
|
||||||
|
const deliveryStatus = normalizeDate(filters.deliveryStatus || "all");
|
||||||
|
const dateFrom = normalizeDate(filters.dateFrom);
|
||||||
|
const dateTo = normalizeDate(filters.dateTo);
|
||||||
|
const deliveryHalfDay = normalizeDate(filters.deliveryHalfDay || filters.timeSlot || "all");
|
||||||
|
|
||||||
|
const isWithinDateRange = (group) => {
|
||||||
|
const deliveryDate = parseGroupDate(getDeliveryDate(group));
|
||||||
|
|
||||||
|
if (!deliveryDate) {
|
||||||
|
return !dateFrom && !dateTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateFrom) {
|
||||||
|
const fromDate = parseGroupDate(dateFrom);
|
||||||
|
if (fromDate && deliveryDate < fromDate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateTo) {
|
||||||
|
const toDate = parseGroupDate(dateTo);
|
||||||
|
if (toDate && deliveryDate > toDate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSearchHaystack = (group) =>
|
||||||
|
(group.searchText ||
|
||||||
|
[
|
||||||
|
group.groupKey,
|
||||||
|
group.displayTitle,
|
||||||
|
group.customerName,
|
||||||
|
group.customerPhone,
|
||||||
|
group.customerDate,
|
||||||
|
Array.isArray(group.orderNumbers) ? group.orderNumbers.join(" ") : "",
|
||||||
|
group.status,
|
||||||
|
getOrderGroupStatusLabel(group.status),
|
||||||
|
group.deliveryStatus,
|
||||||
|
getOrderGroupDeliveryStatusLabel(group.deliveryStatus),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" "))
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
return (groups || []).filter((group) => {
|
||||||
|
if (status !== "all" && group.status !== status) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayStatus !== "all" && getOrderGroupDisplayStatusValue(group) !== displayStatus) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deliveryStatus !== "all") {
|
||||||
|
const groupDeliveryStatus = group.deliveryStatus || group.delivery_status || "pending_confirmation";
|
||||||
|
|
||||||
|
if (groupDeliveryStatus !== deliveryStatus) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isWithinDateRange(group)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deliveryHalfDay !== "all") {
|
||||||
|
const groupDeliveryHalfDay = getOrderGroupDeliveryHalfDay(group);
|
||||||
|
|
||||||
|
if (deliveryHalfDay === "unknown") {
|
||||||
|
if (groupDeliveryHalfDay) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if (groupDeliveryHalfDay !== HALF_DAY_LABELS[deliveryHalfDay]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getSearchHaystack(group).includes(query);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ORDER_GROUP_STATUS_LABELS = {
|
||||||
|
ready_for_notification: "Готово к уведомлению",
|
||||||
|
sms_sent: "SMS отправлены",
|
||||||
|
manual_work: "Нужна ручная работа",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ORDER_GROUP_DISPLAY_STATUS_OPTIONS = [
|
||||||
|
{ value: "all", label: "Все статусы" },
|
||||||
|
{ value: "status:ready_for_notification", label: ORDER_GROUP_STATUS_LABELS.ready_for_notification },
|
||||||
|
{ value: "delivery:agreed", label: DELIVERY_GROUP_STATUS_LABELS.agreed },
|
||||||
|
{ value: "delivery:driver_assigned", label: DELIVERY_GROUP_STATUS_LABELS.driver_assigned },
|
||||||
|
{ value: "delivery:loaded", label: DELIVERY_GROUP_STATUS_LABELS.loaded },
|
||||||
|
{ value: "delivery:on_route", label: DELIVERY_GROUP_STATUS_LABELS.on_route },
|
||||||
|
{ value: "delivery:delivered", label: DELIVERY_GROUP_STATUS_LABELS.delivered },
|
||||||
|
{ value: "delivery:problem", label: DELIVERY_GROUP_STATUS_LABELS.problem },
|
||||||
|
{ value: "delivery:cancelled", label: DELIVERY_GROUP_STATUS_LABELS.cancelled },
|
||||||
|
{ value: "status:sms_sent", label: ORDER_GROUP_STATUS_LABELS.sms_sent },
|
||||||
|
{ value: "status:manual_work", label: ORDER_GROUP_STATUS_LABELS.manual_work },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const getOrderGroupStatusLabel = (status) =>
|
||||||
|
ORDER_GROUP_STATUS_LABELS[status] || status || "Неизвестно";
|
||||||
|
|
||||||
|
export const getOrderGroupDeliveryStatusTone = (status) => {
|
||||||
|
if (status === "agreed") {
|
||||||
|
return "accent";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "problem") {
|
||||||
|
return "warning";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "delivered") {
|
||||||
|
return "accent";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "cancelled") {
|
||||||
|
return "danger";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "neutral";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const groupOrderGroupsByDate = (groups) => {
|
||||||
|
const buckets = (groups || []).reduce((accumulator, group) => {
|
||||||
|
const date = getDeliveryDate(group) || "Без даты";
|
||||||
|
accumulator[date] = accumulator[date] || [];
|
||||||
|
accumulator[date].push(group);
|
||||||
|
return accumulator;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return Object.entries(buckets)
|
||||||
|
.sort(([leftDate], [rightDate]) => {
|
||||||
|
const leftTime = parseGroupDate(leftDate)?.getTime();
|
||||||
|
const rightTime = parseGroupDate(rightDate)?.getTime();
|
||||||
|
|
||||||
|
if (leftTime != null && rightTime != null && leftTime !== rightTime) {
|
||||||
|
return leftTime - rightTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
return leftDate.localeCompare(rightDate);
|
||||||
|
})
|
||||||
|
.map(([date, items]) => ({
|
||||||
|
date,
|
||||||
|
items: [...items].sort((left, right) => {
|
||||||
|
const leftCount = Number(left.ordersCount || 0);
|
||||||
|
const rightCount = Number(right.ordersCount || 0);
|
||||||
|
|
||||||
|
if (leftCount !== rightCount) {
|
||||||
|
return rightCount - leftCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (right.updatedAt || "").localeCompare(left.updatedAt || "");
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBucketKey = (group) => {
|
||||||
|
if (group.smsSentAt) {
|
||||||
|
return "sms_sent";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((group.readyCount || 0) > 0 && (group.notReadyCount || 0) > 0) {
|
||||||
|
return "manual_work";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((group.notReadyCount || 0) > 0) {
|
||||||
|
return "manual_work";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.status === "ready_for_notification" || (group.readyCount || 0) >= (group.ordersCount || 0)) {
|
||||||
|
return "ready_to_launch";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "manual_work";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ORDER_GROUP_BUCKET_LABELS = {
|
||||||
|
ready_to_launch: "Готовы к уведомлению",
|
||||||
|
sms_sent: "Уведомления отправлены",
|
||||||
|
manual_work: "Нужна ручная работа",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ORDER_GROUP_DELIVERY_HALF_DAY_OPTIONS = [
|
||||||
|
{ value: "all", label: "Все интервалы" },
|
||||||
|
{ value: "morning", label: HALF_DAY_LABELS.morning },
|
||||||
|
{ value: "afternoon", label: HALF_DAY_LABELS.afternoon },
|
||||||
|
{ value: "unknown", label: "Без времени" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const buildOrderGroupBuckets = (groups) => {
|
||||||
|
const buckets = {
|
||||||
|
ready_to_launch: [],
|
||||||
|
sms_sent: [],
|
||||||
|
manual_work: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const group of groups || []) {
|
||||||
|
const bucketKey = getBucketKey(group);
|
||||||
|
buckets[bucketKey].push(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buckets;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getOrderGroupStatusTone = (group) => {
|
||||||
|
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
|
||||||
|
|
||||||
|
if (deliveryStatus && deliveryStatus !== "pending_confirmation") {
|
||||||
|
return getOrderGroupDeliveryStatusTone(deliveryStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.smsSentAt) {
|
||||||
|
return "accent";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((group.notReadyCount || 0) > 0) {
|
||||||
|
return "warning";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "neutral";
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildOrderGroupBuckets,
|
||||||
|
filterOrderGroups,
|
||||||
|
getOrderGroupDisplayStatusLabel,
|
||||||
|
getOrderGroupDisplayStatusValue,
|
||||||
|
getOrderGroupDeliveryStatusLabel,
|
||||||
|
getOrderGroupDeliveryHalfDay,
|
||||||
|
getOrderGroupStatusTone,
|
||||||
|
ORDER_GROUP_DISPLAY_STATUS_OPTIONS,
|
||||||
|
groupOrderGroupsByDate,
|
||||||
|
isOrderGroupAgreedForDelivery,
|
||||||
|
isOrderGroupVisibleToDriver,
|
||||||
|
} from "./orderGroupViews";
|
||||||
|
|
||||||
|
describe("orderGroupViews", () => {
|
||||||
|
const groups = [
|
||||||
|
{
|
||||||
|
id: "g-1",
|
||||||
|
customerDate: "14.04.26",
|
||||||
|
customerName: "А",
|
||||||
|
customerPhone: "1",
|
||||||
|
deliveryTime: "До обеда",
|
||||||
|
orderNumbers: ["A-1"],
|
||||||
|
status: "ready_for_notification",
|
||||||
|
deliveryStatus: "agreed",
|
||||||
|
smsSentAt: null,
|
||||||
|
ordersCount: 1,
|
||||||
|
readyCount: 1,
|
||||||
|
notReadyCount: 0,
|
||||||
|
createdAt: "2026-05-05T10:00:00Z",
|
||||||
|
updatedAt: "2026-05-05T10:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "g-2",
|
||||||
|
customerDate: "14.04.26",
|
||||||
|
customerName: "B",
|
||||||
|
customerPhone: "2",
|
||||||
|
deliveryStatus: "delivered",
|
||||||
|
sourceOrders: [{ delivery_time: "После обеда" }],
|
||||||
|
orderNumbers: ["B-1", "B-2"],
|
||||||
|
status: "ready_for_notification",
|
||||||
|
smsSentAt: "2026-05-05T12:00:00Z",
|
||||||
|
ordersCount: 2,
|
||||||
|
readyCount: 2,
|
||||||
|
notReadyCount: 0,
|
||||||
|
createdAt: "2026-05-05T11:00:00Z",
|
||||||
|
updatedAt: "2026-05-05T11:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "g-3",
|
||||||
|
customerDate: "15.04.26",
|
||||||
|
customerName: "C",
|
||||||
|
customerPhone: "3",
|
||||||
|
deliveryStatus: "pending_confirmation",
|
||||||
|
orderNumbers: ["C-1"],
|
||||||
|
status: "draft",
|
||||||
|
smsSentAt: null,
|
||||||
|
ordersCount: 1,
|
||||||
|
readyCount: 0,
|
||||||
|
notReadyCount: 1,
|
||||||
|
createdAt: "2026-05-05T13:00:00Z",
|
||||||
|
updatedAt: "2026-05-05T13:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it("groups delivery groups by customer date", () => {
|
||||||
|
const grouped = groupOrderGroupsByDate(groups);
|
||||||
|
|
||||||
|
expect(grouped).toHaveLength(2);
|
||||||
|
expect(grouped[0].date).toBe("14.04.26");
|
||||||
|
expect(grouped[0].items).toHaveLength(2);
|
||||||
|
expect(grouped[1].date).toBe("15.04.26");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds readiness buckets from group status and sms timestamp", () => {
|
||||||
|
const buckets = buildOrderGroupBuckets(groups);
|
||||||
|
|
||||||
|
expect(buckets.ready_to_launch).toHaveLength(1);
|
||||||
|
expect(buckets.sms_sent).toHaveLength(1);
|
||||||
|
expect(buckets.manual_work).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes delivery half day and filters by date and time", () => {
|
||||||
|
expect(getOrderGroupDeliveryHalfDay(groups[0])).toBe("Первая половина дня");
|
||||||
|
expect(getOrderGroupDeliveryHalfDay(groups[1])).toBe("Вторая половина дня");
|
||||||
|
expect(getOrderGroupDeliveryStatusLabel(groups[0].deliveryStatus)).toBe("Согласовано");
|
||||||
|
expect(isOrderGroupVisibleToDriver(groups[0])).toBe(true);
|
||||||
|
expect(isOrderGroupAgreedForDelivery(groups[0])).toBe(true);
|
||||||
|
expect(isOrderGroupVisibleToDriver(groups[2])).toBe(false);
|
||||||
|
|
||||||
|
const filtered = filterOrderGroups(groups, {
|
||||||
|
dateFrom: "2026-04-14",
|
||||||
|
dateTo: "2026-04-14",
|
||||||
|
deliveryHalfDay: "morning",
|
||||||
|
deliveryStatus: "agreed",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(filtered).toHaveLength(1);
|
||||||
|
expect(filtered[0].id).toBe("g-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows agreed delivery status as the visible card status", () => {
|
||||||
|
expect(getOrderGroupDisplayStatusLabel(groups[0])).toBe("Согласовано");
|
||||||
|
expect(getOrderGroupDisplayStatusValue(groups[0])).toBe("delivery:agreed");
|
||||||
|
expect(getOrderGroupStatusTone(groups[0])).toBe("accent");
|
||||||
|
expect(getOrderGroupDisplayStatusLabel(groups[2])).toBe("draft");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by the visible card status", () => {
|
||||||
|
const agreedGroups = filterOrderGroups(groups, { displayStatus: "delivery:agreed" });
|
||||||
|
const deliveredGroups = filterOrderGroups(groups, { displayStatus: "delivery:delivered" });
|
||||||
|
|
||||||
|
expect(ORDER_GROUP_DISPLAY_STATUS_OPTIONS.map((option) => option.label)).toContain("Согласовано");
|
||||||
|
expect(agreedGroups.map((group) => group.id)).toEqual(["g-1"]);
|
||||||
|
expect(deliveredGroups.map((group) => group.id)).toEqual(["g-2"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
import { safeSupabaseCall } from "../safeSupabaseCall";
|
||||||
|
import { hasSupabaseConfig, supabase } from "../../supabaseClient";
|
||||||
|
import {
|
||||||
|
getOrderGroupDeliveryHalfDay,
|
||||||
|
getOrderGroupDeliveryStatusLabel,
|
||||||
|
getOrderGroupStatusLabel,
|
||||||
|
} from "../orderGroupViews";
|
||||||
|
|
||||||
|
const requireSupabase = () => {
|
||||||
|
if (!hasSupabaseConfig || !supabase) {
|
||||||
|
throw new Error("Supabase не сконфигурирован");
|
||||||
|
}
|
||||||
|
|
||||||
|
return supabase;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeText = (value) => (value == null ? "" : String(value)).trim();
|
||||||
|
const ALLOWED_DELIVERY_TIMES = new Set(["Первая половина дня", "Вторая половина дня"]);
|
||||||
|
|
||||||
|
const normalizePhone = (value) => normalizeText(value).replace(/[\s\-()]/g, "");
|
||||||
|
|
||||||
|
const toNumber = (value, fallback = 0) => {
|
||||||
|
const nextValue = Number(value);
|
||||||
|
return Number.isFinite(nextValue) ? nextValue : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toStringArray = (value) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.filter((item) => item != null && String(item).trim() !== "").map(String);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value == null || value === "") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [String(value)];
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseGroupKey = (groupKey) => {
|
||||||
|
if (!groupKey || typeof groupKey !== "string") {
|
||||||
|
return { phone: "", date: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [phone = "", date = ""] = groupKey.split("|");
|
||||||
|
return {
|
||||||
|
phone: normalizePhone(phone),
|
||||||
|
date: normalizeText(date),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapOrderGroupRowToDeliveryGroup = (row) => {
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedKey = parseGroupKey(row.group_key);
|
||||||
|
const customerName = normalizeText(row.customer_name || row.legacy_customer_name);
|
||||||
|
const customerPhone = normalizeText(row.customer_phone || row.legacy_customer_phone || parsedKey.phone);
|
||||||
|
const customerDate = normalizeText(row.customer_date || row.legacy_customer_date || parsedKey.date);
|
||||||
|
const orderNumbers = toStringArray(row.order_numbers);
|
||||||
|
const inferredOrderCount = orderNumbers.length;
|
||||||
|
const ordersCount = toNumber(row.orders_count ?? row.orders_total ?? row.legacy_orders_total, inferredOrderCount);
|
||||||
|
const readyCount = toNumber(
|
||||||
|
row.ready_count ?? row.orders_ready ?? row.legacy_orders_ready,
|
||||||
|
row.status === "ready_for_notification" ? ordersCount : 0,
|
||||||
|
);
|
||||||
|
const notReadyCount = toNumber(
|
||||||
|
row.not_ready_count ?? row.orders_not_ready ?? row.legacy_orders_not_ready,
|
||||||
|
Math.max(ordersCount - readyCount, 0),
|
||||||
|
);
|
||||||
|
const deliveryStatus = normalizeText(row.delivery_status) || "pending_confirmation";
|
||||||
|
const deliveryDate = normalizeText(row.delivery_date);
|
||||||
|
const rawDeliveryTime = normalizeText(row.delivery_time);
|
||||||
|
const rawDeliveryHalfDay = normalizeText(row.delivery_half_day);
|
||||||
|
const deliveryTime = ALLOWED_DELIVERY_TIMES.has(rawDeliveryTime)
|
||||||
|
? rawDeliveryTime
|
||||||
|
: ALLOWED_DELIVERY_TIMES.has(rawDeliveryHalfDay)
|
||||||
|
? rawDeliveryHalfDay
|
||||||
|
: "";
|
||||||
|
const deliveryAddress = normalizeText(row.delivery_address);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
groupKey: row.group_key,
|
||||||
|
customer: {
|
||||||
|
name: customerName,
|
||||||
|
phone: customerPhone,
|
||||||
|
phoneNormalized: parsedKey.phone || normalizePhone(customerPhone),
|
||||||
|
date: customerDate,
|
||||||
|
ordersCount,
|
||||||
|
readyCount,
|
||||||
|
notReadyCount,
|
||||||
|
},
|
||||||
|
customerName,
|
||||||
|
customerPhone,
|
||||||
|
customerPhoneNormalized: parsedKey.phone || normalizePhone(customerPhone),
|
||||||
|
customerDate,
|
||||||
|
deliveryAddress,
|
||||||
|
ordersCount,
|
||||||
|
readyCount,
|
||||||
|
notReadyCount,
|
||||||
|
orderNumbers,
|
||||||
|
status: row.status || "draft",
|
||||||
|
smsSentAt: row.sms_sent_at || null,
|
||||||
|
createdFromExchangeAt: row.created_from_exchange_at || null,
|
||||||
|
sourceKey: row.source_key || null,
|
||||||
|
legacyCustomerName: row.legacy_customer_name || null,
|
||||||
|
legacyCustomerPhone: row.legacy_customer_phone || null,
|
||||||
|
legacyCustomerPhoneNormalized: row.legacy_customer_phone_normalized || null,
|
||||||
|
legacyCustomerDate: row.legacy_customer_date || null,
|
||||||
|
legacyOrdersTotal: row.legacy_orders_total ?? null,
|
||||||
|
legacyOrdersReady: row.legacy_orders_ready ?? null,
|
||||||
|
legacyOrdersNotReady: row.legacy_orders_not_ready ?? null,
|
||||||
|
sourceOrders: row.source_orders || null,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
deliveryStatus,
|
||||||
|
delivery_status: deliveryStatus,
|
||||||
|
displayTitle: customerName || `Группа ${row.group_key || row.id}`,
|
||||||
|
displaySubtitle: [customerPhone, customerDate].filter(Boolean).join(" · ") || row.group_key || row.id,
|
||||||
|
deliveryDate,
|
||||||
|
deliveryTime,
|
||||||
|
deliveryHalfDay: getOrderGroupDeliveryHalfDay({
|
||||||
|
deliveryHalfDay: rawDeliveryHalfDay,
|
||||||
|
deliveryTime: rawDeliveryTime,
|
||||||
|
deliveryWindow: row.delivery_window,
|
||||||
|
sourceOrders: row.source_orders,
|
||||||
|
}),
|
||||||
|
orderNumberSummary: orderNumbers.length ? orderNumbers.join(", ") : "Номера не указаны",
|
||||||
|
searchText: [
|
||||||
|
row.group_key,
|
||||||
|
customerName,
|
||||||
|
customerPhone,
|
||||||
|
customerDate,
|
||||||
|
deliveryAddress,
|
||||||
|
rawDeliveryHalfDay,
|
||||||
|
rawDeliveryTime,
|
||||||
|
row.delivery_window,
|
||||||
|
deliveryStatus,
|
||||||
|
getOrderGroupDeliveryStatusLabel(deliveryStatus),
|
||||||
|
orderNumbers.join(" "),
|
||||||
|
row.status,
|
||||||
|
getOrderGroupStatusLabel(row.status),
|
||||||
|
getOrderGroupDeliveryHalfDay({
|
||||||
|
deliveryHalfDay: rawDeliveryHalfDay,
|
||||||
|
deliveryTime: rawDeliveryTime,
|
||||||
|
deliveryWindow: row.delivery_window,
|
||||||
|
sourceOrders: row.source_orders,
|
||||||
|
}),
|
||||||
|
getOrderGroupDeliveryStatusLabel(deliveryStatus),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateOrderGroupDeliveryChoice = async ({
|
||||||
|
orderGroupId,
|
||||||
|
deliveryDate,
|
||||||
|
deliveryTime,
|
||||||
|
}) => {
|
||||||
|
return safeSupabaseCall(async () => {
|
||||||
|
const client = requireSupabase();
|
||||||
|
const { data, error } = await client
|
||||||
|
.from("order_groups")
|
||||||
|
.update({
|
||||||
|
delivery_status: "agreed",
|
||||||
|
delivery_date: deliveryDate,
|
||||||
|
delivery_time: deliveryTime,
|
||||||
|
notification_status: "confirmed",
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq("id", orderGroupId)
|
||||||
|
.select("*")
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapOrderGroupRowToDeliveryGroup(data);
|
||||||
|
}, "Ошибка сохранения согласования доставки");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchOrderGroups = async () => {
|
||||||
|
return safeSupabaseCall(async () => {
|
||||||
|
const client = requireSupabase();
|
||||||
|
const { data, error } = await client
|
||||||
|
.from("order_groups")
|
||||||
|
.select("*")
|
||||||
|
.order("updated_at", { ascending: false });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (data || []).map(mapOrderGroupRowToDeliveryGroup).filter(Boolean);
|
||||||
|
}, "Ошибка загрузки групп доставки");
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const { fromMock, updateMock, eqMock, selectMock, singleMock } = vi.hoisted(() => ({
|
||||||
|
fromMock: vi.fn(),
|
||||||
|
updateMock: vi.fn(),
|
||||||
|
eqMock: vi.fn(),
|
||||||
|
selectMock: vi.fn(),
|
||||||
|
singleMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../supabaseClient", () => ({
|
||||||
|
hasSupabaseConfig: true,
|
||||||
|
supabase: {
|
||||||
|
from: fromMock,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
mapOrderGroupRowToDeliveryGroup,
|
||||||
|
updateOrderGroupDeliveryChoice,
|
||||||
|
} from "./orderGroupRepository";
|
||||||
|
|
||||||
|
describe("mapOrderGroupRowToDeliveryGroup", () => {
|
||||||
|
it("normalizes the order_groups row into a delivery-group view model", () => {
|
||||||
|
const group = mapOrderGroupRowToDeliveryGroup({
|
||||||
|
id: "953c5bda-7e77-47af-9b7f-9d2c2cf3e7c5",
|
||||||
|
group_key: "3939375462|14.04.26",
|
||||||
|
customer_name: "Калинина Дарья Егоровна",
|
||||||
|
customer_phone: "3939375462",
|
||||||
|
customer_date: "14.04.26",
|
||||||
|
delivery_address: "Симферополь, ул. Ленина, 10",
|
||||||
|
orders_count: 1,
|
||||||
|
ready_count: 1,
|
||||||
|
not_ready_count: 0,
|
||||||
|
order_numbers: ["СФ Т\\ЕА-23094"],
|
||||||
|
status: "ready_for_notification",
|
||||||
|
delivery_status: "agreed",
|
||||||
|
delivery_date: "2026-04-16",
|
||||||
|
sms_sent_at: null,
|
||||||
|
delivery_time: "До обеда",
|
||||||
|
created_from_exchange_at: null,
|
||||||
|
source_key: null,
|
||||||
|
legacy_customer_name: null,
|
||||||
|
legacy_customer_phone: null,
|
||||||
|
legacy_customer_phone_normalized: null,
|
||||||
|
legacy_customer_date: null,
|
||||||
|
legacy_orders_total: null,
|
||||||
|
legacy_orders_ready: null,
|
||||||
|
legacy_orders_not_ready: null,
|
||||||
|
source_orders: null,
|
||||||
|
created_at: "2026-05-05 09:43:53.750061+00",
|
||||||
|
updated_at: "2026-05-05 09:43:53.750061+00",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(group.id).toBe("953c5bda-7e77-47af-9b7f-9d2c2cf3e7c5");
|
||||||
|
expect(group.groupKey).toBe("3939375462|14.04.26");
|
||||||
|
expect(group.customer.name).toBe("Калинина Дарья Егоровна");
|
||||||
|
expect(group.customer.phone).toBe("3939375462");
|
||||||
|
expect(group.customer.date).toBe("14.04.26");
|
||||||
|
expect(group.deliveryAddress).toBe("Симферополь, ул. Ленина, 10");
|
||||||
|
expect(group.ordersCount).toBe(1);
|
||||||
|
expect(group.readyCount).toBe(1);
|
||||||
|
expect(group.notReadyCount).toBe(0);
|
||||||
|
expect(group.orderNumbers).toEqual(["СФ Т\\ЕА-23094"]);
|
||||||
|
expect(group.displayTitle).toBe("Калинина Дарья Егоровна");
|
||||||
|
expect(group.displaySubtitle).toBe("3939375462 · 14.04.26");
|
||||||
|
expect(group.deliveryDate).toBe("2026-04-16");
|
||||||
|
expect(group.deliveryHalfDay).toBe("Первая половина дня");
|
||||||
|
expect(group.deliveryStatus).toBe("agreed");
|
||||||
|
expect(group.searchText).toContain("симферополь, ул. ленина, 10");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("infers readiness counters from order numbers for ready groups when counters are empty", () => {
|
||||||
|
const group = mapOrderGroupRowToDeliveryGroup({
|
||||||
|
id: "group-without-counters",
|
||||||
|
group_key: "9781632663|28.04.26",
|
||||||
|
order_numbers: ["СФ Т\\ЕА-26979"],
|
||||||
|
status: "ready_for_notification",
|
||||||
|
delivery_status: "pending_confirmation",
|
||||||
|
created_at: "2026-05-05 09:43:53.750061+00",
|
||||||
|
updated_at: "2026-05-05 09:43:53.750061+00",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(group.ordersCount).toBe(1);
|
||||||
|
expect(group.readyCount).toBe(1);
|
||||||
|
expect(group.notReadyCount).toBe(0);
|
||||||
|
expect(group.deliveryDate).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops invalid delivery time values instead of showing them", () => {
|
||||||
|
const group = mapOrderGroupRowToDeliveryGroup({
|
||||||
|
id: "group-with-bad-time",
|
||||||
|
group_key: "9781632663|08.05.26",
|
||||||
|
customer_name: "Зиновьев Алексей Гаврилович",
|
||||||
|
customer_phone: "9781632663",
|
||||||
|
customer_date: "08.05.26",
|
||||||
|
delivery_time: "Зиновьев Алексей Гаврилович",
|
||||||
|
delivery_half_day: null,
|
||||||
|
order_numbers: ["СФ Т\\ЕА-26979"],
|
||||||
|
status: "ready_for_notification",
|
||||||
|
delivery_status: "pending_confirmation",
|
||||||
|
created_at: "2026-05-05 09:43:53.750061+00",
|
||||||
|
updated_at: "2026-05-05 09:43:53.750061+00",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(group.deliveryTime).toBe("");
|
||||||
|
expect(group.searchText).not.toContain("зиновьев алексей гаврилович зиновьев алексей гаврилович");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateOrderGroupDeliveryChoice", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fromMock.mockReset();
|
||||||
|
updateMock.mockReset();
|
||||||
|
eqMock.mockReset();
|
||||||
|
selectMock.mockReset();
|
||||||
|
singleMock.mockReset();
|
||||||
|
|
||||||
|
fromMock.mockReturnValue({ update: updateMock });
|
||||||
|
updateMock.mockReturnValue({ eq: eqMock });
|
||||||
|
eqMock.mockReturnValue({ select: selectMock });
|
||||||
|
selectMock.mockReturnValue({ single: singleMock });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the group directly in order_groups", async () => {
|
||||||
|
singleMock.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
id: "group-id",
|
||||||
|
group_key: "9788151605|18.02.26",
|
||||||
|
status: "ready_for_notification",
|
||||||
|
delivery_status: "agreed",
|
||||||
|
delivery_date: "2026-05-13",
|
||||||
|
delivery_time: "Первая половина дня",
|
||||||
|
notification_status: "confirmed",
|
||||||
|
created_at: "2026-05-12T09:00:00Z",
|
||||||
|
updated_at: "2026-05-12T09:05:00Z",
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
updateOrderGroupDeliveryChoice({
|
||||||
|
orderGroupId: "group-id",
|
||||||
|
deliveryDate: "2026-05-13",
|
||||||
|
deliveryTime: "Первая половина дня",
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
id: "group-id",
|
||||||
|
deliveryStatus: "agreed",
|
||||||
|
deliveryDate: "2026-05-13",
|
||||||
|
deliveryTime: "Первая половина дня",
|
||||||
|
}),
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fromMock).toHaveBeenCalledWith("order_groups");
|
||||||
|
expect(updateMock).toHaveBeenCalledWith({
|
||||||
|
delivery_status: "agreed",
|
||||||
|
delivery_date: "2026-05-13",
|
||||||
|
delivery_time: "Первая половина дня",
|
||||||
|
notification_status: "confirmed",
|
||||||
|
updated_at: expect.any(String),
|
||||||
|
});
|
||||||
|
expect(eqMock).toHaveBeenCalledWith("id", "group-id");
|
||||||
|
expect(selectMock).toHaveBeenCalledWith("*");
|
||||||
|
expect(singleMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { createClient } from "@supabase/supabase-js";
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
export const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
export const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
export const hasSupabaseConfig = Boolean(supabaseUrl && supabaseAnonKey);
|
export const hasSupabaseConfig = Boolean(supabaseUrl && supabaseAnonKey);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,10 @@ curl -X POST \
|
||||||
Создает или обновляет активное приглашение для публичной клиентской ссылки, сохраняет
|
Создает или обновляет активное приглашение для публичной клиентской ссылки, сохраняет
|
||||||
`delivery_invitations`, обновляет заказ в статус `Ожидает ответа клиента` и возвращает публичный URL.
|
`delivery_invitations`, обновляет заказ в статус `Ожидает ответа клиента` и возвращает публичный URL.
|
||||||
|
|
||||||
|
Обязательная переменная окружения:
|
||||||
|
|
||||||
|
- `PUBLIC_APP_URL`
|
||||||
|
|
||||||
## `get-delivery-invitation`
|
## `get-delivery-invitation`
|
||||||
|
|
||||||
Возвращает публичное состояние приглашения по токену. Используется страницей клиента для показа
|
Возвращает публичное состояние приглашения по токену. Используется страницей клиента для показа
|
||||||
|
|
@ -65,6 +69,11 @@ curl -X POST \
|
||||||
Фиксирует выбор времени доставки клиентом, переводит заказ в `Доставка согласована` и создает
|
Фиксирует выбор времени доставки клиентом, переводит заказ в `Доставка согласована` и создает
|
||||||
историю события.
|
историю события.
|
||||||
|
|
||||||
|
## `update-order-group-delivery-choice`
|
||||||
|
|
||||||
|
Фиксирует ручное согласование доставки по группе `order_groups`.
|
||||||
|
Используется менеджером или логистом, когда клиент согласовал дату и половину дня напрямую.
|
||||||
|
|
||||||
## `transfer-to-logistics`
|
## `transfer-to-logistics`
|
||||||
|
|
||||||
Используется для ручной передачи заказа логисту или перевода в `Платное хранение`.
|
Используется для ручной передачи заказа логисту или перевода в `Платное хранение`.
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,25 @@ export const getClientInvitationStateFromOrderStatus = (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getClientInvitationStateFromOrderGroupStatus = (
|
||||||
|
deliveryStatus: string | null | undefined,
|
||||||
|
invitationState: string | null | undefined,
|
||||||
|
): DeliveryInvitationPublicState => {
|
||||||
|
if (deliveryStatus === "agreed") {
|
||||||
|
return "agreed";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deliveryStatus === "delivered") {
|
||||||
|
return "delivered";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (["awaiting_choice", "opened", "reminder_sent"].includes(String(invitationState || ""))) {
|
||||||
|
return invitationState as DeliveryInvitationPublicState;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "default";
|
||||||
|
};
|
||||||
|
|
||||||
export const isActiveInvitationState = (state: DeliveryInvitationPublicState) =>
|
export const isActiveInvitationState = (state: DeliveryInvitationPublicState) =>
|
||||||
state === "awaiting_choice" || state === "opened" || state === "reminder_sent";
|
state === "awaiting_choice" || state === "opened" || state === "reminder_sent";
|
||||||
|
|
||||||
|
|
@ -100,6 +119,25 @@ export const normalizeAvailableSlots = (availableSlots?: string[] | null) => {
|
||||||
return slots.length > 0 ? Array.from(new Set(slots)) : [...DEFAULT_AVAILABLE_SLOTS];
|
return slots.length > 0 ? Array.from(new Set(slots)) : [...DEFAULT_AVAILABLE_SLOTS];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const buildDefaultDatedAvailableSlots = (now = new Date()) => {
|
||||||
|
const formatIsoDate = (date: Date) => date.toISOString().slice(0, 10);
|
||||||
|
const addDays = (date: Date, days: number) => {
|
||||||
|
const next = new Date(date);
|
||||||
|
next.setUTCDate(next.getUTCDate() + days);
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstDay = formatIsoDate(addDays(now, 1));
|
||||||
|
const secondDay = formatIsoDate(addDays(now, 2));
|
||||||
|
|
||||||
|
return [
|
||||||
|
`${firstDay}, Первая половина дня`,
|
||||||
|
`${firstDay}, Вторая половина дня`,
|
||||||
|
`${secondDay}, Первая половина дня`,
|
||||||
|
`${secondDay}, Вторая половина дня`,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
export const resolvePublicAppUrl = (
|
export const resolvePublicAppUrl = (
|
||||||
request: Request,
|
request: Request,
|
||||||
fallbackEnv?: string,
|
fallbackEnv?: string,
|
||||||
|
|
@ -116,7 +154,8 @@ export const buildInvitationUrl = (baseUrl: string, token: string) =>
|
||||||
|
|
||||||
export type DeliveryInvitationRecord = {
|
export type DeliveryInvitationRecord = {
|
||||||
id?: string;
|
id?: string;
|
||||||
order_id: string;
|
order_id?: string | null;
|
||||||
|
order_group_id?: string | null;
|
||||||
token_hash: string;
|
token_hash: string;
|
||||||
state: string;
|
state: string;
|
||||||
order_number?: string | null;
|
order_number?: string | null;
|
||||||
|
|
@ -137,6 +176,22 @@ export type DeliveryInvitationRecord = {
|
||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OrderGroupInvitationSource = {
|
||||||
|
id: string;
|
||||||
|
group_key?: string | null;
|
||||||
|
customer?: {
|
||||||
|
name?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
date?: string | null;
|
||||||
|
} | null;
|
||||||
|
customer_name?: string | null;
|
||||||
|
customer_phone?: string | null;
|
||||||
|
customer_date?: string | null;
|
||||||
|
order_numbers?: string[] | null;
|
||||||
|
delivery_status?: string | null;
|
||||||
|
delivery_link?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export const isInvitationExpired = (invitation: DeliveryInvitationRecord, now = new Date()) => {
|
export const isInvitationExpired = (invitation: DeliveryInvitationRecord, now = new Date()) => {
|
||||||
if (invitation.revoked_at) {
|
if (invitation.revoked_at) {
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -149,6 +204,40 @@ export const isInvitationExpired = (invitation: DeliveryInvitationRecord, now =
|
||||||
return new Date(invitation.expires_at).getTime() <= now.getTime();
|
return new Date(invitation.expires_at).getTime() <= now.getTime();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parseGroupKey = (groupKey?: string | null) => {
|
||||||
|
const [phone = "", date = ""] = String(groupKey || "").split("|");
|
||||||
|
return {
|
||||||
|
phone: phone.trim(),
|
||||||
|
date: date.trim(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildPublicOrderGroupInvitationView = (
|
||||||
|
invitation: DeliveryInvitationRecord,
|
||||||
|
group: OrderGroupInvitationSource,
|
||||||
|
) => {
|
||||||
|
const parsedKey = parseGroupKey(group.group_key);
|
||||||
|
const customerName = group.customer_name || group.customer?.name || invitation.customer_name || null;
|
||||||
|
const customerPhone = group.customer_phone || group.customer?.phone || invitation.customer_phone || parsedKey.phone || null;
|
||||||
|
const orderNumbers = Array.isArray(group.order_numbers) ? group.order_numbers : [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
orderId: invitation.order_group_id || group.id,
|
||||||
|
orderGroupId: invitation.order_group_id || group.id,
|
||||||
|
state: invitation.state,
|
||||||
|
token: "",
|
||||||
|
orderNumber: invitation.order_number || orderNumbers[0] || group.group_key || null,
|
||||||
|
customerName: maskCustomerName(customerName),
|
||||||
|
customerPhone: maskPhoneNumber(customerPhone),
|
||||||
|
orderItems: orderNumbers.map((number) => ({ name: number, quantity: "" })),
|
||||||
|
availableSlots: invitation.available_slots || [],
|
||||||
|
deliveryDate: invitation.delivery_date || null,
|
||||||
|
deliveryTime: invitation.delivery_time || null,
|
||||||
|
orderStatus: null,
|
||||||
|
deliveryAgreementStatus: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const buildPublicInvitationView = (
|
export const buildPublicInvitationView = (
|
||||||
invitation: DeliveryInvitationRecord,
|
invitation: DeliveryInvitationRecord,
|
||||||
order: {
|
order: {
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,95 @@ Deno.serve(async (request) => {
|
||||||
return jsonResponse({ ok: false, error: "Invitation expired" }, 410, corsHeaders);
|
return jsonResponse({ ok: false, error: "Invitation expired" }, 410, corsHeaders);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (invitation.order_group_id) {
|
||||||
|
const { data: currentGroup, error: groupError } = await supabase
|
||||||
|
.from("order_groups")
|
||||||
|
.select("id, delivery_status")
|
||||||
|
.eq("id", invitation.order_group_id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (groupError) {
|
||||||
|
throw groupError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isActiveInvitationState(invitation.state) || currentGroup.delivery_status !== "pending_confirmation") {
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: "Invitation is no longer active",
|
||||||
|
},
|
||||||
|
409,
|
||||||
|
corsHeaders,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedSlot = resolveRequestedSlot(invitation, body);
|
||||||
|
if (!requestedSlot) {
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: "Selected slot is not available",
|
||||||
|
},
|
||||||
|
422,
|
||||||
|
corsHeaders,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: invitationUpdateError } = await supabase
|
||||||
|
.from("delivery_invitations")
|
||||||
|
.update({
|
||||||
|
state: "agreed",
|
||||||
|
delivery_date: requestedSlot.deliveryDate,
|
||||||
|
delivery_time: requestedSlot.deliveryTime,
|
||||||
|
confirmed_at: new Date().toISOString(),
|
||||||
|
access_count: (invitation.access_count || 0) + 1,
|
||||||
|
last_accessed_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq("id", invitation.id);
|
||||||
|
|
||||||
|
if (invitationUpdateError) {
|
||||||
|
throw invitationUpdateError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: groupUpdateError } = await supabase
|
||||||
|
.from("order_groups")
|
||||||
|
.update({
|
||||||
|
delivery_status: "agreed",
|
||||||
|
delivery_date: requestedSlot.deliveryDate,
|
||||||
|
delivery_time: requestedSlot.deliveryTime,
|
||||||
|
notification_status: "confirmed",
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq("id", invitation.order_group_id);
|
||||||
|
|
||||||
|
if (groupUpdateError) {
|
||||||
|
throw groupUpdateError;
|
||||||
|
}
|
||||||
|
|
||||||
|
await insertIntegrationEvent(supabase, {
|
||||||
|
order_id: null,
|
||||||
|
event_type: "delivery_choice_confirmed",
|
||||||
|
direction: "inbound",
|
||||||
|
status: "success",
|
||||||
|
payload: {
|
||||||
|
order_group_id: invitation.order_group_id,
|
||||||
|
delivery_invitation_id: invitation.id,
|
||||||
|
delivery_date: requestedSlot.deliveryDate,
|
||||||
|
delivery_time: requestedSlot.deliveryTime,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
orderGroupId: invitation.order_group_id,
|
||||||
|
deliveryStatus: "agreed",
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
corsHeaders,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { data: currentOrder, error: orderError } = await supabase
|
const { data: currentOrder, error: orderError } = await supabase
|
||||||
.from("orders")
|
.from("orders")
|
||||||
.select("id, status, delivery_agreement_status")
|
.select("id, status, delivery_agreement_status")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import {
|
import {
|
||||||
|
buildDefaultDatedAvailableSlots,
|
||||||
buildInvitationUrl,
|
buildInvitationUrl,
|
||||||
generateInvitationToken,
|
generateInvitationToken,
|
||||||
getOrderUpdateForDeliveryInvitationAction,
|
getOrderUpdateForDeliveryInvitationAction,
|
||||||
|
|
@ -21,6 +22,7 @@ const MAX_BODY_BYTES = 16 * 1024;
|
||||||
|
|
||||||
type CreateInvitationBody = {
|
type CreateInvitationBody = {
|
||||||
orderId?: string;
|
orderId?: string;
|
||||||
|
orderGroupId?: string;
|
||||||
orderNumber?: string;
|
orderNumber?: string;
|
||||||
customerName?: string;
|
customerName?: string;
|
||||||
customerPhone?: string;
|
customerPhone?: string;
|
||||||
|
|
@ -29,6 +31,197 @@ type CreateInvitationBody = {
|
||||||
source?: string;
|
source?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parseGroupKey = (groupKey?: string | null) => {
|
||||||
|
const [phone = "", date = ""] = String(groupKey || "").split("|");
|
||||||
|
return {
|
||||||
|
phone: phone.trim(),
|
||||||
|
date: date.trim(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveRequiredPublicAppUrl = (request: Request) => {
|
||||||
|
const publicBaseUrl = resolvePublicAppUrl(request);
|
||||||
|
if (!publicBaseUrl) {
|
||||||
|
throw new Error("PUBLIC_APP_URL is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
return publicBaseUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createOrderGroupInvitation = async ({
|
||||||
|
body,
|
||||||
|
request,
|
||||||
|
corsHeaders,
|
||||||
|
}: {
|
||||||
|
body: CreateInvitationBody;
|
||||||
|
request: Request;
|
||||||
|
corsHeaders: HeadersInit;
|
||||||
|
}) => {
|
||||||
|
const supabase = createServiceClient();
|
||||||
|
const orderGroupId = String(body.orderGroupId || "").trim();
|
||||||
|
|
||||||
|
await requireRateLimit(supabase, {
|
||||||
|
scope: "delivery-invitation-create",
|
||||||
|
key: orderGroupId,
|
||||||
|
maxCount: 10,
|
||||||
|
windowSeconds: 600,
|
||||||
|
blockSeconds: 1800,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: group, error: groupError } = await supabase
|
||||||
|
.from("order_groups")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", orderGroupId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (groupError) {
|
||||||
|
throw groupError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedKey = parseGroupKey(group.group_key);
|
||||||
|
const customerName = body.customerName || group.customer_name || group.customer?.name || null;
|
||||||
|
const customerPhone = body.customerPhone || group.customer_phone || group.customer?.phone || parsedKey.phone || null;
|
||||||
|
const orderNumbers = Array.isArray(group.order_numbers) ? group.order_numbers : [];
|
||||||
|
const orderNumber = body.orderNumber || group.group_key || orderNumbers[0] || null;
|
||||||
|
|
||||||
|
if (!customerPhone) {
|
||||||
|
return jsonResponse({ ok: false, error: "customerPhone is required" }, 400, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: existingInvitation, error: existingInvitationError } = await supabase
|
||||||
|
.from("delivery_invitations")
|
||||||
|
.select("id, state")
|
||||||
|
.eq("order_group_id", orderGroupId)
|
||||||
|
.in("state", ["awaiting_choice", "opened", "reminder_sent"])
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (existingInvitationError) {
|
||||||
|
throw existingInvitationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingInvitation) {
|
||||||
|
if (!group.delivery_link) {
|
||||||
|
const { error: revokeInvitationError } = await supabase
|
||||||
|
.from("delivery_invitations")
|
||||||
|
.update({
|
||||||
|
state: "default",
|
||||||
|
revoked_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq("id", existingInvitation.id);
|
||||||
|
|
||||||
|
if (revokeInvitationError) {
|
||||||
|
throw revokeInvitationError;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
alreadyStarted: true,
|
||||||
|
invitation: {
|
||||||
|
id: existingInvitation.id,
|
||||||
|
orderGroupId,
|
||||||
|
state: existingInvitation.state,
|
||||||
|
url: group.delivery_link || null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
corsHeaders,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingInvitation && !group.delivery_link) {
|
||||||
|
const { error: clearBrokenLinkError } = await supabase
|
||||||
|
.from("order_groups")
|
||||||
|
.update({
|
||||||
|
delivery_invitation_id: null,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq("id", orderGroupId);
|
||||||
|
|
||||||
|
if (clearBrokenLinkError) {
|
||||||
|
throw clearBrokenLinkError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = generateInvitationToken();
|
||||||
|
const tokenHash = await hashInvitationToken(token);
|
||||||
|
const publicBaseUrl = resolveRequiredPublicAppUrl(request);
|
||||||
|
const url = buildInvitationUrl(publicBaseUrl, token);
|
||||||
|
const availableSlots = body.availableSlots?.length
|
||||||
|
? normalizeAvailableSlots(body.availableSlots)
|
||||||
|
: buildDefaultDatedAvailableSlots();
|
||||||
|
|
||||||
|
const invitationPayload = {
|
||||||
|
order_id: null,
|
||||||
|
order_group_id: orderGroupId,
|
||||||
|
token_hash: tokenHash,
|
||||||
|
state: "awaiting_choice",
|
||||||
|
order_number: orderNumber,
|
||||||
|
customer_name: customerName,
|
||||||
|
customer_phone: customerPhone,
|
||||||
|
customer_messenger: body.customerMessenger || null,
|
||||||
|
available_slots: availableSlots,
|
||||||
|
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
sent_at: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: invitation, error: invitationError } = await supabase
|
||||||
|
.from("delivery_invitations")
|
||||||
|
.insert(invitationPayload)
|
||||||
|
.select("id")
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (invitationError) {
|
||||||
|
throw invitationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: groupUpdateError } = await supabase
|
||||||
|
.from("order_groups")
|
||||||
|
.update({
|
||||||
|
delivery_invitation_id: invitation.id,
|
||||||
|
delivery_link: url,
|
||||||
|
notification_status: "link_ready",
|
||||||
|
next_notification_check_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq("id", orderGroupId);
|
||||||
|
|
||||||
|
if (groupUpdateError) {
|
||||||
|
throw groupUpdateError;
|
||||||
|
}
|
||||||
|
|
||||||
|
await insertIntegrationEvent(supabase, {
|
||||||
|
order_id: null,
|
||||||
|
event_type: "delivery_invitation_created",
|
||||||
|
direction: "outbound",
|
||||||
|
status: "success",
|
||||||
|
payload: {
|
||||||
|
order_group_id: orderGroupId,
|
||||||
|
delivery_invitation_id: invitation.id,
|
||||||
|
token_hash: tokenHash,
|
||||||
|
available_slots: availableSlots,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
invitation: {
|
||||||
|
id: invitation.id,
|
||||||
|
orderGroupId,
|
||||||
|
token,
|
||||||
|
url,
|
||||||
|
state: "awaiting_choice",
|
||||||
|
availableSlots,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
corsHeaders,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
Deno.serve(async (request) => {
|
Deno.serve(async (request) => {
|
||||||
if (request.method === "OPTIONS") {
|
if (request.method === "OPTIONS") {
|
||||||
const corsHeaders = getCorsHeaders(request, "integration");
|
const corsHeaders = getCorsHeaders(request, "integration");
|
||||||
|
|
@ -50,14 +243,19 @@ Deno.serve(async (request) => {
|
||||||
allowedClockSkewSeconds: 300,
|
allowedClockSkewSeconds: 300,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!body.orderId) {
|
if (!body.orderId && !body.orderGroupId) {
|
||||||
return jsonResponse({ error: "orderId is required" }, 400, corsHeaders);
|
return jsonResponse({ error: "orderId or orderGroupId is required" }, 400, corsHeaders);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (body.orderGroupId) {
|
||||||
|
return await createOrderGroupInvitation({ body, request, corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderId = body.orderId as string;
|
||||||
const supabase = createServiceClient();
|
const supabase = createServiceClient();
|
||||||
await requireRateLimit(supabase, {
|
await requireRateLimit(supabase, {
|
||||||
scope: "delivery-invitation-create",
|
scope: "delivery-invitation-create",
|
||||||
key: body.orderId,
|
key: orderId,
|
||||||
maxCount: 10,
|
maxCount: 10,
|
||||||
windowSeconds: 600,
|
windowSeconds: 600,
|
||||||
blockSeconds: 1800,
|
blockSeconds: 1800,
|
||||||
|
|
@ -70,7 +268,7 @@ Deno.serve(async (request) => {
|
||||||
const { data: currentOrder, error: orderError } = await supabase
|
const { data: currentOrder, error: orderError } = await supabase
|
||||||
.from("orders")
|
.from("orders")
|
||||||
.select("id, status, delivery_agreement_status, ready_for_delivery_at, delivery_flow_started_at")
|
.select("id, status, delivery_agreement_status, ready_for_delivery_at, delivery_flow_started_at")
|
||||||
.eq("id", body.orderId)
|
.eq("id", orderId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (orderError) {
|
if (orderError) {
|
||||||
|
|
@ -82,7 +280,7 @@ Deno.serve(async (request) => {
|
||||||
.select(
|
.select(
|
||||||
"id, state, available_slots, order_number, customer_name, customer_phone, customer_messenger, delivery_date, delivery_time, sent_at, opened_at, confirmed_at, expires_at, revoked_at",
|
"id, state, available_slots, order_number, customer_name, customer_phone, customer_messenger, delivery_date, delivery_time, sent_at, opened_at, confirmed_at, expires_at, revoked_at",
|
||||||
)
|
)
|
||||||
.eq("order_id", body.orderId)
|
.eq("order_id", orderId)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (existingInvitationError) {
|
if (existingInvitationError) {
|
||||||
|
|
@ -96,7 +294,7 @@ Deno.serve(async (request) => {
|
||||||
alreadyStarted: true,
|
alreadyStarted: true,
|
||||||
invitation: existingInvitation
|
invitation: existingInvitation
|
||||||
? {
|
? {
|
||||||
orderId: body.orderId,
|
orderId,
|
||||||
state: existingInvitation.state,
|
state: existingInvitation.state,
|
||||||
availableSlots: existingInvitation.available_slots || [],
|
availableSlots: existingInvitation.available_slots || [],
|
||||||
orderNumber: existingInvitation.order_number || body.orderNumber || null,
|
orderNumber: existingInvitation.order_number || body.orderNumber || null,
|
||||||
|
|
@ -105,7 +303,7 @@ Deno.serve(async (request) => {
|
||||||
customerMessenger: existingInvitation.customer_messenger || body.customerMessenger || null,
|
customerMessenger: existingInvitation.customer_messenger || body.customerMessenger || null,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
orderId: body.orderId,
|
orderId,
|
||||||
state: "awaiting_choice",
|
state: "awaiting_choice",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -115,7 +313,7 @@ Deno.serve(async (request) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const invitationPayload = {
|
const invitationPayload = {
|
||||||
order_id: body.orderId,
|
order_id: orderId,
|
||||||
token_hash: tokenHash,
|
token_hash: tokenHash,
|
||||||
state: "awaiting_choice",
|
state: "awaiting_choice",
|
||||||
order_number: body.orderNumber || null,
|
order_number: body.orderNumber || null,
|
||||||
|
|
@ -142,14 +340,14 @@ Deno.serve(async (request) => {
|
||||||
delivery_flow_started_at: new Date().toISOString(),
|
delivery_flow_started_at: new Date().toISOString(),
|
||||||
delivery_flow_source: body.source || "n8n",
|
delivery_flow_source: body.source || "n8n",
|
||||||
})
|
})
|
||||||
.eq("id", body.orderId);
|
.eq("id", orderId);
|
||||||
|
|
||||||
if (updateError) {
|
if (updateError) {
|
||||||
throw updateError;
|
throw updateError;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error: historyError } = await supabase.from("order_history").insert({
|
const { error: historyError } = await supabase.from("order_history").insert({
|
||||||
order_id: body.orderId,
|
order_id: orderId,
|
||||||
action: "Создание приглашения доставки",
|
action: "Создание приглашения доставки",
|
||||||
old_status: currentOrder.status,
|
old_status: currentOrder.status,
|
||||||
new_status: orderUpdate?.status,
|
new_status: orderUpdate?.status,
|
||||||
|
|
@ -166,7 +364,7 @@ Deno.serve(async (request) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
await insertIntegrationEvent(supabase, {
|
await insertIntegrationEvent(supabase, {
|
||||||
order_id: body.orderId,
|
order_id: orderId,
|
||||||
event_type: "delivery_invitation_created",
|
event_type: "delivery_invitation_created",
|
||||||
direction: "outbound",
|
direction: "outbound",
|
||||||
status: "success",
|
status: "success",
|
||||||
|
|
@ -176,13 +374,13 @@ Deno.serve(async (request) => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const publicBaseUrl = resolvePublicAppUrl(request);
|
const publicBaseUrl = resolveRequiredPublicAppUrl(request);
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
{
|
{
|
||||||
ok: true,
|
ok: true,
|
||||||
invitation: {
|
invitation: {
|
||||||
orderId: body.orderId,
|
orderId,
|
||||||
token,
|
token,
|
||||||
url: buildInvitationUrl(publicBaseUrl, token),
|
url: buildInvitationUrl(publicBaseUrl, token),
|
||||||
state: "awaiting_choice",
|
state: "awaiting_choice",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import {
|
import {
|
||||||
|
buildPublicOrderGroupInvitationView,
|
||||||
buildPublicInvitationView,
|
buildPublicInvitationView,
|
||||||
|
getClientInvitationStateFromOrderGroupStatus,
|
||||||
getClientInvitationStateFromOrderStatus,
|
getClientInvitationStateFromOrderStatus,
|
||||||
hashInvitationToken,
|
hashInvitationToken,
|
||||||
isActiveInvitationState,
|
isActiveInvitationState,
|
||||||
|
|
@ -82,6 +84,49 @@ Deno.serve(async (request) => {
|
||||||
return jsonResponse({ ok: false, error: "Invitation expired" }, 410, corsHeaders);
|
return jsonResponse({ ok: false, error: "Invitation expired" }, 410, corsHeaders);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (invitation.order_group_id) {
|
||||||
|
const { data: group, error: groupError } = await supabase
|
||||||
|
.from("order_groups")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", invitation.order_group_id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (groupError) {
|
||||||
|
throw groupError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicState = getClientInvitationStateFromOrderGroupStatus(
|
||||||
|
group.delivery_status,
|
||||||
|
invitation.state,
|
||||||
|
);
|
||||||
|
|
||||||
|
await supabase
|
||||||
|
.from("delivery_invitations")
|
||||||
|
.update({
|
||||||
|
opened_at: isActiveInvitationState(publicState) && !invitation.opened_at
|
||||||
|
? new Date().toISOString()
|
||||||
|
: invitation.opened_at,
|
||||||
|
access_count: (invitation.access_count || 0) + 1,
|
||||||
|
last_accessed_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq("id", invitation.id);
|
||||||
|
|
||||||
|
const invitationView = buildPublicOrderGroupInvitationView(invitation, group);
|
||||||
|
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
invitation: {
|
||||||
|
...invitationView,
|
||||||
|
token,
|
||||||
|
state: publicState,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
corsHeaders,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { data: order, error: orderError } = await supabase
|
const { data: order, error: orderError } = await supabase
|
||||||
.from("orders")
|
.from("orders")
|
||||||
.select("id, order_number, status, delivery_agreement_status, customer")
|
.select("id, order_number, status, delivery_agreement_status, customer")
|
||||||
|
|
|
||||||
|
|
@ -79,11 +79,11 @@ Deno.serve(async (request) => {
|
||||||
throw invitationError;
|
throw invitationError;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error: updateError } = await supabase
|
const { error: updateError } = await supabase
|
||||||
.from("orders")
|
.from("orders")
|
||||||
.update({
|
.update({
|
||||||
status: orderUpdate?.status,
|
status: orderUpdate?.status,
|
||||||
delivery_agreement_status: body.note || orderUpdate?.deliveryAgreementStatus,
|
delivery_agreement_status: orderUpdate?.deliveryAgreementStatus,
|
||||||
})
|
})
|
||||||
.eq("id", body.orderId);
|
.eq("id", body.orderId);
|
||||||
|
|
||||||
|
|
@ -91,15 +91,16 @@ Deno.serve(async (request) => {
|
||||||
throw updateError;
|
throw updateError;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error: historyError } = await supabase.from("order_history").insert({
|
const { error: historyError } = await supabase.from("order_history").insert({
|
||||||
order_id: body.orderId,
|
order_id: body.orderId,
|
||||||
action: targetStatus === "Платное хранение" ? "Перевод на платное хранение" : "Передача заказа логисту",
|
action: targetStatus === "Платное хранение" ? "Перевод на платное хранение" : "Передача заказа логисту",
|
||||||
old_status: currentOrder.status,
|
old_status: currentOrder.status,
|
||||||
new_status: orderUpdate?.status,
|
new_status: orderUpdate?.status,
|
||||||
metadata: {
|
metadata: {
|
||||||
old_delivery_agreement_status: currentOrder.delivery_agreement_status,
|
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,
|
reason: body.reason || null,
|
||||||
|
note: body.note || null,
|
||||||
target_status: targetStatus,
|
target_status: targetStatus,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -126,7 +127,7 @@ Deno.serve(async (request) => {
|
||||||
ok: true,
|
ok: true,
|
||||||
orderId: body.orderId,
|
orderId: body.orderId,
|
||||||
status: orderUpdate?.status,
|
status: orderUpdate?.status,
|
||||||
deliveryAgreementStatus: body.note || orderUpdate?.deliveryAgreementStatus,
|
deliveryAgreementStatus: orderUpdate?.deliveryAgreementStatus,
|
||||||
},
|
},
|
||||||
200,
|
200,
|
||||||
corsHeaders,
|
corsHeaders,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,230 @@
|
||||||
|
import { createServiceClient } from "../_shared/chatbot.ts";
|
||||||
|
import { insertIntegrationEvent } from "../_shared/integration-events.ts";
|
||||||
|
import {
|
||||||
|
getClientIp,
|
||||||
|
getCorsHeaders,
|
||||||
|
hashText,
|
||||||
|
jsonResponse,
|
||||||
|
preflightResponse,
|
||||||
|
readJsonBody,
|
||||||
|
requireRateLimit,
|
||||||
|
} from "../_shared/security.ts";
|
||||||
|
|
||||||
|
const MAX_BODY_BYTES = 8 * 1024;
|
||||||
|
const ALLOWED_ROLES = new Set(["manager", "logistician", "admin"]);
|
||||||
|
const ALLOWED_DELIVERY_TIMES = new Set(["Первая половина дня", "Вторая половина дня"]);
|
||||||
|
const DELIVERY_TIME_ALIASES = new Map([
|
||||||
|
["До обеда", "Первая половина дня"],
|
||||||
|
["После обеда", "Вторая половина дня"],
|
||||||
|
]);
|
||||||
|
const DELIVERY_TIMEZONE = "Europe/Simferopol";
|
||||||
|
|
||||||
|
type UpdateDeliveryChoiceBody = {
|
||||||
|
orderGroupId?: string;
|
||||||
|
deliveryDate?: string;
|
||||||
|
deliveryTime?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
|
||||||
|
|
||||||
|
const getTodayKey = () => {
|
||||||
|
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: DELIVERY_TIMEZONE,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
}).formatToParts(new Date());
|
||||||
|
|
||||||
|
const year = parts.find((part) => part.type === "year")?.value || "";
|
||||||
|
const month = parts.find((part) => part.type === "month")?.value || "";
|
||||||
|
const day = parts.find((part) => part.type === "day")?.value || "";
|
||||||
|
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isWeekendDeliveryDate = (value: string) => {
|
||||||
|
if (!isValidDate(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(`${value}T12:00:00Z`);
|
||||||
|
const weekday = date.getUTCDay();
|
||||||
|
return weekday === 0 || weekday === 6;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAllowedDeliveryDate = (value: string) => isValidDate(value) && value > getTodayKey() && !isWeekendDeliveryDate(value);
|
||||||
|
|
||||||
|
const normalizeDeliveryTime = (value: string) => DELIVERY_TIME_ALIASES.get(value) || value;
|
||||||
|
|
||||||
|
const getBearerToken = (request: Request) => {
|
||||||
|
const authorization = request.headers.get("authorization") || "";
|
||||||
|
return authorization.toLowerCase().startsWith("bearer ")
|
||||||
|
? authorization.slice(7).trim()
|
||||||
|
: "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserRole = async (
|
||||||
|
supabase: ReturnType<typeof createServiceClient>,
|
||||||
|
accessToken: string,
|
||||||
|
) => {
|
||||||
|
const { data: authData, error: authError } = await supabase.auth.getUser(accessToken);
|
||||||
|
if (authError || !authData.user?.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: profile, error: profileError } = await supabase
|
||||||
|
.from("users")
|
||||||
|
.select("id, role_info:roles(name)")
|
||||||
|
.eq("id", authData.user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (profileError) {
|
||||||
|
throw profileError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleInfo = Array.isArray(profile.role_info) ? profile.role_info[0] : profile.role_info;
|
||||||
|
return {
|
||||||
|
userId: authData.user.id,
|
||||||
|
role: roleInfo?.name || "",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.serve(async (request) => {
|
||||||
|
if (request.method === "OPTIONS") {
|
||||||
|
return preflightResponse(request, "public");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method !== "POST") {
|
||||||
|
return jsonResponse({ ok: false, error: "Method not allowed" }, 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
const corsHeaders = getCorsHeaders(request, "public");
|
||||||
|
if (!corsHeaders) {
|
||||||
|
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { body } = await readJsonBody<UpdateDeliveryChoiceBody>(request, {
|
||||||
|
maxBytes: MAX_BODY_BYTES,
|
||||||
|
});
|
||||||
|
|
||||||
|
const orderGroupId = String(body.orderGroupId || "").trim();
|
||||||
|
const deliveryDate = String(body.deliveryDate || "").trim();
|
||||||
|
const deliveryTime = normalizeDeliveryTime(String(body.deliveryTime || "").trim());
|
||||||
|
|
||||||
|
if (!orderGroupId) {
|
||||||
|
return jsonResponse({ ok: false, error: "orderGroupId is required" }, 400, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAllowedDeliveryDate(deliveryDate)) {
|
||||||
|
return jsonResponse({ ok: false, error: "Выберите будущий будний день доставки" }, 400, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_DELIVERY_TIMES.has(deliveryTime)) {
|
||||||
|
return jsonResponse({ ok: false, error: "Выберите первую или вторую половину дня доставки" }, 400, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = getBearerToken(request);
|
||||||
|
if (!accessToken) {
|
||||||
|
return jsonResponse({ ok: false, error: "Authentication is required" }, 401, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createServiceClient();
|
||||||
|
const actor = await getUserRole(supabase, accessToken);
|
||||||
|
|
||||||
|
if (!actor || !ALLOWED_ROLES.has(actor.role)) {
|
||||||
|
return jsonResponse({ ok: false, error: "Forbidden" }, 403, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipHash = await hashText(getClientIp(request));
|
||||||
|
await requireRateLimit(supabase, {
|
||||||
|
scope: "order-group-manual-delivery-choice",
|
||||||
|
key: `${actor.userId}:${ipHash}:${orderGroupId}`,
|
||||||
|
maxCount: 20,
|
||||||
|
windowSeconds: 600,
|
||||||
|
blockSeconds: 1800,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: currentGroup, error: currentGroupError } = await supabase
|
||||||
|
.from("order_groups")
|
||||||
|
.select("id, delivery_status, delivery_invitation_id")
|
||||||
|
.eq("id", orderGroupId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (currentGroupError) {
|
||||||
|
throw currentGroupError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: group, error: groupUpdateError } = await supabase
|
||||||
|
.from("order_groups")
|
||||||
|
.update({
|
||||||
|
delivery_status: "agreed",
|
||||||
|
delivery_date: deliveryDate,
|
||||||
|
delivery_time: deliveryTime,
|
||||||
|
notification_status: "confirmed",
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq("id", orderGroupId)
|
||||||
|
.select("*")
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (groupUpdateError) {
|
||||||
|
throw groupUpdateError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentGroup.delivery_invitation_id) {
|
||||||
|
const { error: invitationUpdateError } = await supabase
|
||||||
|
.from("delivery_invitations")
|
||||||
|
.update({
|
||||||
|
state: "agreed",
|
||||||
|
delivery_date: deliveryDate,
|
||||||
|
delivery_time: deliveryTime,
|
||||||
|
confirmed_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq("id", currentGroup.delivery_invitation_id);
|
||||||
|
|
||||||
|
if (invitationUpdateError) {
|
||||||
|
throw invitationUpdateError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await insertIntegrationEvent(supabase, {
|
||||||
|
order_id: null,
|
||||||
|
event_type: "order_group_manual_delivery_choice",
|
||||||
|
direction: "internal",
|
||||||
|
status: "success",
|
||||||
|
payload: {
|
||||||
|
order_group_id: orderGroupId,
|
||||||
|
actor_user_id: actor.userId,
|
||||||
|
actor_role: actor.role,
|
||||||
|
old_delivery_status: currentGroup.delivery_status || null,
|
||||||
|
new_delivery_status: "agreed",
|
||||||
|
delivery_date: deliveryDate,
|
||||||
|
delivery_time: deliveryTime,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
orderGroup: group,
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
corsHeaders,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && "status" in error) {
|
||||||
|
const httpError = error as { status: number; message: string };
|
||||||
|
return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error.message : "Unexpected error",
|
||||||
|
},
|
||||||
|
500,
|
||||||
|
corsHeaders,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,38 @@
|
||||||
create extension if not exists pgcrypto;
|
create extension if not exists pgcrypto;
|
||||||
|
|
||||||
|
create or replace function public.next_order_group_sms_check_at(
|
||||||
|
start_from timestamptz default now(),
|
||||||
|
delay interval default interval '0 minutes'
|
||||||
|
)
|
||||||
|
returns timestamptz
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_timezone text := 'Europe/Simferopol';
|
||||||
|
v_local_time timestamp;
|
||||||
|
v_local_date date;
|
||||||
|
v_work_start timestamp;
|
||||||
|
v_work_end timestamp;
|
||||||
|
v_candidate timestamp;
|
||||||
|
begin
|
||||||
|
v_local_time := (start_from at time zone v_timezone) + delay;
|
||||||
|
v_local_date := v_local_time::date;
|
||||||
|
v_work_start := v_local_date + time '09:00';
|
||||||
|
v_work_end := v_local_date + time '20:00';
|
||||||
|
|
||||||
|
if v_local_time < v_work_start then
|
||||||
|
v_candidate := v_work_start;
|
||||||
|
elsif v_local_time >= v_work_end then
|
||||||
|
v_candidate := (v_local_date + 1) + time '09:00';
|
||||||
|
else
|
||||||
|
v_candidate := v_local_time;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return v_candidate at time zone v_timezone;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
create table if not exists public.roles (
|
create table if not exists public.roles (
|
||||||
id uuid primary key default gen_random_uuid(),
|
id uuid primary key default gen_random_uuid(),
|
||||||
name text not null unique,
|
name text not null unique,
|
||||||
|
|
@ -85,9 +118,42 @@ create table if not exists public.error_logs (
|
||||||
created_at timestamptz not null default timezone('utc', now())
|
created_at timestamptz not null default timezone('utc', now())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
create table if not exists public.order_groups (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
group_key text not null,
|
||||||
|
customer jsonb,
|
||||||
|
order_numbers text[] not null default '{}',
|
||||||
|
status text not null default 'ready_for_notification',
|
||||||
|
delivery_status text not null default 'pending_confirmation',
|
||||||
|
sms_sent_at timestamptz,
|
||||||
|
created_at timestamptz not null default timezone('utc', now()),
|
||||||
|
updated_at timestamptz not null default timezone('utc', now()),
|
||||||
|
created_from_exchange_at timestamptz,
|
||||||
|
source_key text,
|
||||||
|
customer_name text,
|
||||||
|
customer_phone text,
|
||||||
|
customer_phone_normalized text,
|
||||||
|
customer_date text,
|
||||||
|
orders_total integer,
|
||||||
|
orders_ready integer,
|
||||||
|
orders_not_ready integer,
|
||||||
|
source_orders jsonb,
|
||||||
|
delivery_invitation_id uuid,
|
||||||
|
delivery_link text,
|
||||||
|
notification_status text not null default 'not_started',
|
||||||
|
sms_attempts integer not null default 0,
|
||||||
|
first_sms_sent_at timestamptz,
|
||||||
|
second_sms_sent_at timestamptz,
|
||||||
|
last_sms_error text,
|
||||||
|
next_notification_check_at timestamptz,
|
||||||
|
delivery_date date,
|
||||||
|
delivery_time text
|
||||||
|
);
|
||||||
|
|
||||||
create table if not exists public.delivery_invitations (
|
create table if not exists public.delivery_invitations (
|
||||||
id uuid primary key default gen_random_uuid(),
|
id uuid primary key default gen_random_uuid(),
|
||||||
order_id uuid not null references public.orders (id) on delete cascade unique,
|
order_id uuid references public.orders (id) on delete cascade unique,
|
||||||
|
order_group_id uuid references public.order_groups (id) on delete cascade,
|
||||||
token_hash text not null unique,
|
token_hash text not null unique,
|
||||||
state text not null default 'awaiting_choice',
|
state text not null default 'awaiting_choice',
|
||||||
order_number text,
|
order_number text,
|
||||||
|
|
@ -146,6 +212,8 @@ alter table public.chat_messages
|
||||||
check (channel in ('telegram', 'vk', 'messenger_max', 'sms', 'email'));
|
check (channel in ('telegram', 'vk', 'messenger_max', 'sms', 'email'));
|
||||||
|
|
||||||
alter table public.delivery_invitations add column if not exists state text not null default 'awaiting_choice';
|
alter table public.delivery_invitations add column if not exists state text not null default 'awaiting_choice';
|
||||||
|
alter table public.delivery_invitations alter column order_id drop not null;
|
||||||
|
alter table public.delivery_invitations add column if not exists order_group_id uuid references public.order_groups(id) on delete cascade;
|
||||||
alter table public.delivery_invitations add column if not exists expires_at timestamptz;
|
alter table public.delivery_invitations add column if not exists expires_at timestamptz;
|
||||||
alter table public.delivery_invitations add column if not exists revoked_at timestamptz;
|
alter table public.delivery_invitations add column if not exists revoked_at timestamptz;
|
||||||
alter table public.delivery_invitations add column if not exists access_count integer not null default 0;
|
alter table public.delivery_invitations add column if not exists access_count integer not null default 0;
|
||||||
|
|
@ -160,6 +228,17 @@ alter table public.delivery_invitations add column if not exists paid_storage_at
|
||||||
alter table public.delivery_invitations add column if not exists delivered_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.delivery_invitations add column if not exists updated_at timestamptz not null default timezone('utc', now());
|
||||||
|
|
||||||
|
alter table public.order_groups add column if not exists delivery_invitation_id uuid references public.delivery_invitations(id) on delete set null;
|
||||||
|
alter table public.order_groups add column if not exists delivery_link text;
|
||||||
|
alter table public.order_groups add column if not exists notification_status text not null default 'not_started';
|
||||||
|
alter table public.order_groups add column if not exists sms_attempts integer not null default 0;
|
||||||
|
alter table public.order_groups add column if not exists first_sms_sent_at timestamptz;
|
||||||
|
alter table public.order_groups add column if not exists second_sms_sent_at timestamptz;
|
||||||
|
alter table public.order_groups add column if not exists last_sms_error text;
|
||||||
|
alter table public.order_groups add column if not exists next_notification_check_at timestamptz;
|
||||||
|
alter table public.order_groups add column if not exists delivery_date date;
|
||||||
|
alter table public.order_groups add column if not exists delivery_time text;
|
||||||
|
|
||||||
alter table public.orders add column if not exists source_order_number text;
|
alter table public.orders add column if not exists source_order_number text;
|
||||||
alter table public.orders add column if not exists source_order_date date;
|
alter table public.orders add column if not exists source_order_date date;
|
||||||
alter table public.orders add column if not exists source_customer_name text;
|
alter table public.orders add column if not exists source_customer_name text;
|
||||||
|
|
@ -373,9 +452,13 @@ create index if not exists idx_chat_messages_search on public.chat_messages usin
|
||||||
to_tsvector('russian', coalesce(text, ''))
|
to_tsvector('russian', coalesce(text, ''))
|
||||||
);
|
);
|
||||||
create index if not exists idx_delivery_invitations_order_id on public.delivery_invitations (order_id);
|
create index if not exists idx_delivery_invitations_order_id on public.delivery_invitations (order_id);
|
||||||
|
create index if not exists idx_delivery_invitations_order_group_id on public.delivery_invitations (order_group_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_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_delivery_invitations_state on public.delivery_invitations (state);
|
||||||
create index if not exists idx_delivery_invitations_expires_at on public.delivery_invitations (expires_at);
|
create index if not exists idx_delivery_invitations_expires_at on public.delivery_invitations (expires_at);
|
||||||
|
create index if not exists idx_order_groups_status on public.order_groups (status);
|
||||||
|
create index if not exists idx_order_groups_delivery_status on public.order_groups (delivery_status);
|
||||||
|
create index if not exists idx_order_groups_notification_status on public.order_groups (notification_status, next_notification_check_at);
|
||||||
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_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);
|
create index if not exists idx_integration_events_event_type on public.integration_events (event_type);
|
||||||
create index if not exists idx_rate_limits_scope_key_window on public.rate_limits (scope, rate_key, window_start desc);
|
create index if not exists idx_rate_limits_scope_key_window on public.rate_limits (scope, rate_key, window_start desc);
|
||||||
|
|
@ -457,6 +540,394 @@ begin
|
||||||
end;
|
end;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.get_delivery_invitation_by_token(p_token text)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public, extensions
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_invitation public.delivery_invitations%rowtype;
|
||||||
|
v_group public.order_groups%rowtype;
|
||||||
|
v_order record;
|
||||||
|
v_token_hash text;
|
||||||
|
v_state text;
|
||||||
|
v_order_number text;
|
||||||
|
v_customer_name text;
|
||||||
|
v_customer_phone text;
|
||||||
|
v_order_items jsonb;
|
||||||
|
v_order_numbers jsonb;
|
||||||
|
v_now timestamptz := timezone('utc', now());
|
||||||
|
begin
|
||||||
|
if nullif(trim(coalesce(p_token, '')), '') is null then
|
||||||
|
raise exception 'token is required';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_token_hash := encode(digest(p_token, 'sha256'), 'hex');
|
||||||
|
|
||||||
|
select *
|
||||||
|
into v_invitation
|
||||||
|
from public.delivery_invitations
|
||||||
|
where token_hash = v_token_hash;
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
raise exception 'Invitation not found';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_invitation.revoked_at is not null then
|
||||||
|
raise exception 'Invitation expired';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_invitation.expires_at is not null and v_invitation.expires_at <= v_now then
|
||||||
|
raise exception 'Invitation expired';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_invitation.order_group_id is not null then
|
||||||
|
select *
|
||||||
|
into v_group
|
||||||
|
from public.order_groups
|
||||||
|
where id = v_invitation.order_group_id;
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
raise exception 'Order group not found';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_state := case
|
||||||
|
when v_group.delivery_status = 'agreed' then 'agreed'
|
||||||
|
when v_group.delivery_status = 'delivered' then 'delivered'
|
||||||
|
when v_invitation.state in ('awaiting_choice', 'opened', 'reminder_sent') then v_invitation.state
|
||||||
|
else 'default'
|
||||||
|
end;
|
||||||
|
|
||||||
|
update public.delivery_invitations
|
||||||
|
set
|
||||||
|
opened_at = case
|
||||||
|
when v_state in ('awaiting_choice', 'opened', 'reminder_sent') and opened_at is null then v_now
|
||||||
|
else opened_at
|
||||||
|
end,
|
||||||
|
access_count = coalesce(access_count, 0) + 1,
|
||||||
|
last_accessed_at = v_now
|
||||||
|
where id = v_invitation.id
|
||||||
|
returning * into v_invitation;
|
||||||
|
|
||||||
|
v_order_number := coalesce(
|
||||||
|
nullif(v_invitation.order_number, ''),
|
||||||
|
to_jsonb(v_group.order_numbers) ->> 0,
|
||||||
|
nullif(v_group.group_key, '')
|
||||||
|
);
|
||||||
|
v_customer_name := coalesce(
|
||||||
|
nullif(v_group.customer_name, ''),
|
||||||
|
nullif(v_group.customer ->> 'name', ''),
|
||||||
|
nullif(v_invitation.customer_name, '')
|
||||||
|
);
|
||||||
|
v_customer_phone := coalesce(
|
||||||
|
nullif(v_group.customer_phone, ''),
|
||||||
|
nullif(v_group.customer ->> 'phone', ''),
|
||||||
|
nullif(v_invitation.customer_phone, '')
|
||||||
|
);
|
||||||
|
select coalesce(
|
||||||
|
jsonb_agg(jsonb_build_object('name', order_number, 'quantity', '')),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
into v_order_items
|
||||||
|
from jsonb_array_elements_text(
|
||||||
|
case
|
||||||
|
when jsonb_typeof(to_jsonb(v_group.order_numbers)) = 'array' then to_jsonb(v_group.order_numbers)
|
||||||
|
else '[]'::jsonb
|
||||||
|
end
|
||||||
|
) as order_number;
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'ok', true,
|
||||||
|
'invitation', jsonb_build_object(
|
||||||
|
'orderId', coalesce(v_invitation.order_group_id, v_group.id)::text,
|
||||||
|
'orderGroupId', coalesce(v_invitation.order_group_id, v_group.id)::text,
|
||||||
|
'state', v_state,
|
||||||
|
'token', p_token,
|
||||||
|
'orderNumber', v_order_number,
|
||||||
|
'customerName', v_customer_name,
|
||||||
|
'customerPhone', v_customer_phone,
|
||||||
|
'orderItems', v_order_items,
|
||||||
|
'availableSlots', coalesce(to_jsonb(v_invitation.available_slots), '[]'::jsonb),
|
||||||
|
'deliveryDate', v_invitation.delivery_date,
|
||||||
|
'deliveryTime', v_invitation.delivery_time,
|
||||||
|
'orderStatus', null,
|
||||||
|
'deliveryAgreementStatus', null
|
||||||
|
)
|
||||||
|
);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select id, order_number, status, delivery_agreement_status, customer
|
||||||
|
into v_order
|
||||||
|
from public.orders
|
||||||
|
where id = v_invitation.order_id;
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
raise exception 'Order not found';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_state := case v_order.status
|
||||||
|
when 'Ожидает ответа клиента' then 'awaiting_choice'
|
||||||
|
when 'Ожидает согласования доставки' then 'opened'
|
||||||
|
when 'Напоминание отправлено' then 'reminder_sent'
|
||||||
|
when 'Переход отправлен' then 'reminder_sent'
|
||||||
|
when 'Передан логисту' then 'transferred_to_logistics'
|
||||||
|
when 'Платное хранение' then 'paid_storage'
|
||||||
|
when 'Доставлен' then 'delivered'
|
||||||
|
when 'Доставка согласована' then 'agreed'
|
||||||
|
else 'default'
|
||||||
|
end;
|
||||||
|
|
||||||
|
update public.delivery_invitations
|
||||||
|
set
|
||||||
|
opened_at = case
|
||||||
|
when v_state in ('awaiting_choice', 'opened', 'reminder_sent') and opened_at is null then v_now
|
||||||
|
else opened_at
|
||||||
|
end,
|
||||||
|
access_count = coalesce(access_count, 0) + 1,
|
||||||
|
last_accessed_at = v_now
|
||||||
|
where id = v_invitation.id
|
||||||
|
returning * into v_invitation;
|
||||||
|
|
||||||
|
v_order_items := case
|
||||||
|
when jsonb_typeof(v_order.customer -> 'items') = 'array' then v_order.customer -> 'items'
|
||||||
|
else '[]'::jsonb
|
||||||
|
end;
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'ok', true,
|
||||||
|
'invitation', jsonb_build_object(
|
||||||
|
'orderId', v_invitation.order_id::text,
|
||||||
|
'state', v_state,
|
||||||
|
'token', p_token,
|
||||||
|
'orderNumber', coalesce(nullif(v_order.order_number, ''), nullif(v_invitation.order_number, '')),
|
||||||
|
'customerName', coalesce(nullif(v_order.customer ->> 'name', ''), nullif(v_invitation.customer_name, '')),
|
||||||
|
'customerPhone', coalesce(nullif(v_order.customer ->> 'phone', ''), nullif(v_invitation.customer_phone, '')),
|
||||||
|
'orderItems', v_order_items,
|
||||||
|
'availableSlots', coalesce(to_jsonb(v_invitation.available_slots), '[]'::jsonb),
|
||||||
|
'deliveryDate', v_invitation.delivery_date,
|
||||||
|
'deliveryTime', v_invitation.delivery_time,
|
||||||
|
'orderStatus', v_order.status,
|
||||||
|
'deliveryAgreementStatus', v_order.delivery_agreement_status
|
||||||
|
)
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.confirm_delivery_choice_by_token(
|
||||||
|
p_token text,
|
||||||
|
p_delivery_date date,
|
||||||
|
p_delivery_time text
|
||||||
|
)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public, extensions
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_invitation public.delivery_invitations%rowtype;
|
||||||
|
v_group public.order_groups%rowtype;
|
||||||
|
v_order record;
|
||||||
|
v_token_hash text;
|
||||||
|
v_slot_label text;
|
||||||
|
v_now timestamptz := timezone('utc', now());
|
||||||
|
begin
|
||||||
|
if nullif(trim(coalesce(p_token, '')), '') is null then
|
||||||
|
raise exception 'token is required';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if p_delivery_date is null or nullif(trim(coalesce(p_delivery_time, '')), '') is null then
|
||||||
|
raise exception 'Selected slot is not available';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_token_hash := encode(digest(p_token, 'sha256'), 'hex');
|
||||||
|
v_slot_label := concat(p_delivery_date::text, ', ', trim(p_delivery_time));
|
||||||
|
|
||||||
|
select *
|
||||||
|
into v_invitation
|
||||||
|
from public.delivery_invitations
|
||||||
|
where token_hash = v_token_hash
|
||||||
|
for update;
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
raise exception 'Invitation not found';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_invitation.revoked_at is not null then
|
||||||
|
raise exception 'Invitation expired';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_invitation.expires_at is not null and v_invitation.expires_at <= v_now then
|
||||||
|
raise exception 'Invitation expired';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_invitation.state not in ('awaiting_choice', 'opened', 'reminder_sent') then
|
||||||
|
raise exception 'Invitation is no longer active';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if cardinality(v_invitation.available_slots) > 0 and not (v_slot_label = any(v_invitation.available_slots)) then
|
||||||
|
raise exception 'Selected slot is not available';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_invitation.order_group_id is not null then
|
||||||
|
select *
|
||||||
|
into v_group
|
||||||
|
from public.order_groups
|
||||||
|
where id = v_invitation.order_group_id
|
||||||
|
for update;
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
raise exception 'Order group not found';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_group.delivery_status <> 'pending_confirmation' then
|
||||||
|
raise exception 'Invitation is no longer active';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update public.delivery_invitations
|
||||||
|
set
|
||||||
|
state = 'agreed',
|
||||||
|
delivery_date = p_delivery_date,
|
||||||
|
delivery_time = trim(p_delivery_time),
|
||||||
|
confirmed_at = v_now,
|
||||||
|
access_count = coalesce(access_count, 0) + 1,
|
||||||
|
last_accessed_at = v_now
|
||||||
|
where id = v_invitation.id;
|
||||||
|
|
||||||
|
update public.order_groups
|
||||||
|
set
|
||||||
|
delivery_status = 'agreed',
|
||||||
|
delivery_date = p_delivery_date,
|
||||||
|
delivery_time = trim(p_delivery_time),
|
||||||
|
notification_status = 'confirmed',
|
||||||
|
updated_at = v_now
|
||||||
|
where id = v_group.id;
|
||||||
|
|
||||||
|
insert into public.integration_events (
|
||||||
|
order_id,
|
||||||
|
event_type,
|
||||||
|
direction,
|
||||||
|
status,
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
null,
|
||||||
|
'delivery_choice_confirmed',
|
||||||
|
'inbound',
|
||||||
|
'success',
|
||||||
|
jsonb_build_object(
|
||||||
|
'order_group_id', v_group.id,
|
||||||
|
'delivery_invitation_id', v_invitation.id,
|
||||||
|
'delivery_date', p_delivery_date,
|
||||||
|
'delivery_time', trim(p_delivery_time)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'ok', true,
|
||||||
|
'orderGroupId', v_group.id,
|
||||||
|
'deliveryStatus', 'agreed'
|
||||||
|
);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select id, status, delivery_agreement_status
|
||||||
|
into v_order
|
||||||
|
from public.orders
|
||||||
|
where id = v_invitation.order_id
|
||||||
|
for update;
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
raise exception 'Order not found';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_order.status not in ('Ожидает ответа клиента', 'Ожидает согласования доставки') then
|
||||||
|
raise exception 'Invitation is no longer active';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update public.delivery_invitations
|
||||||
|
set
|
||||||
|
state = 'agreed',
|
||||||
|
delivery_date = p_delivery_date,
|
||||||
|
delivery_time = trim(p_delivery_time),
|
||||||
|
confirmed_at = v_now,
|
||||||
|
access_count = coalesce(access_count, 0) + 1,
|
||||||
|
last_accessed_at = v_now
|
||||||
|
where id = v_invitation.id;
|
||||||
|
|
||||||
|
update public.orders
|
||||||
|
set
|
||||||
|
status = 'Доставка согласована',
|
||||||
|
delivery_agreement_status = 'Подтверждено клиентом'
|
||||||
|
where id = v_order.id;
|
||||||
|
|
||||||
|
insert into public.delivery_slots (
|
||||||
|
order_id,
|
||||||
|
delivery_date,
|
||||||
|
delivery_time,
|
||||||
|
logistician_id,
|
||||||
|
status
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
v_order.id,
|
||||||
|
p_delivery_date,
|
||||||
|
trim(p_delivery_time),
|
||||||
|
null,
|
||||||
|
'confirmed_by_client'
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into public.order_history (
|
||||||
|
order_id,
|
||||||
|
action,
|
||||||
|
old_status,
|
||||||
|
new_status,
|
||||||
|
metadata
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
v_order.id,
|
||||||
|
'Подтверждение выбора доставки клиентом',
|
||||||
|
v_order.status,
|
||||||
|
'Доставка согласована',
|
||||||
|
jsonb_build_object(
|
||||||
|
'old_delivery_agreement_status', v_order.delivery_agreement_status,
|
||||||
|
'new_delivery_agreement_status', 'Подтверждено клиентом',
|
||||||
|
'delivery_date', p_delivery_date,
|
||||||
|
'delivery_time', trim(p_delivery_time)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into public.integration_events (
|
||||||
|
order_id,
|
||||||
|
event_type,
|
||||||
|
direction,
|
||||||
|
status,
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
v_order.id,
|
||||||
|
'delivery_choice_confirmed',
|
||||||
|
'inbound',
|
||||||
|
'success',
|
||||||
|
jsonb_build_object(
|
||||||
|
'delivery_date', p_delivery_date,
|
||||||
|
'delivery_time', trim(p_delivery_time)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'ok', true,
|
||||||
|
'orderId', v_order.id,
|
||||||
|
'status', 'Доставка согласована',
|
||||||
|
'deliveryAgreementStatus', 'Подтверждено клиентом'
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function public.get_delivery_invitation_by_token(text) from public;
|
||||||
|
grant execute on function public.get_delivery_invitation_by_token(text) to anon, authenticated;
|
||||||
|
|
||||||
|
revoke all on function public.confirm_delivery_choice_by_token(text, date, text) from public;
|
||||||
|
grant execute on function public.confirm_delivery_choice_by_token(text, date, text) to anon, authenticated;
|
||||||
|
|
||||||
alter table public.roles enable row level security;
|
alter table public.roles enable row level security;
|
||||||
alter table public.users enable row level security;
|
alter table public.users enable row level security;
|
||||||
alter table public.orders enable row level security;
|
alter table public.orders enable row level security;
|
||||||
|
|
@ -465,6 +936,7 @@ alter table public.order_history enable row level security;
|
||||||
alter table public.delivery_slots enable row level security;
|
alter table public.delivery_slots enable row level security;
|
||||||
alter table public.chat_messages enable row level security;
|
alter table public.chat_messages enable row level security;
|
||||||
alter table public.error_logs enable row level security;
|
alter table public.error_logs enable row level security;
|
||||||
|
alter table public.order_groups enable row level security;
|
||||||
alter table public.delivery_invitations enable row level security;
|
alter table public.delivery_invitations enable row level security;
|
||||||
alter table public.integration_events enable row level security;
|
alter table public.integration_events enable row level security;
|
||||||
|
|
||||||
|
|
@ -597,6 +1069,22 @@ create policy "history insert workflow" on public.order_history
|
||||||
for insert
|
for insert
|
||||||
with check (public.current_role_name() in ('manager', 'production_lead', 'logistician', 'driver', 'admin'));
|
with check (public.current_role_name() in ('manager', 'production_lead', 'logistician', 'driver', 'admin'));
|
||||||
|
|
||||||
|
drop policy if exists "order groups select by role" on public.order_groups;
|
||||||
|
create policy "order groups select by role" on public.order_groups
|
||||||
|
for select
|
||||||
|
using (true);
|
||||||
|
|
||||||
|
drop policy if exists "order groups update coordination roles" on public.order_groups;
|
||||||
|
create policy "order groups update coordination roles" on public.order_groups
|
||||||
|
for update
|
||||||
|
using (public.current_role_name() in ('manager', 'logistician', 'admin'))
|
||||||
|
with check (public.current_role_name() in ('manager', 'logistician', 'admin'));
|
||||||
|
|
||||||
|
drop policy if exists "order groups insert service roles" on public.order_groups;
|
||||||
|
create policy "order groups insert service roles" on public.order_groups
|
||||||
|
for insert
|
||||||
|
with check (public.current_role_name() in ('manager', 'logistician', 'admin'));
|
||||||
|
|
||||||
drop policy if exists "slots by order role" on public.delivery_slots;
|
drop policy if exists "slots by order role" on public.delivery_slots;
|
||||||
create policy "slots by order role" on public.delivery_slots
|
create policy "slots by order role" on public.delivery_slots
|
||||||
for all
|
for all
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue