fix(booking): 30-min slot intervals and 1C MCP auth synchronization
6
bridge/Dockerfile
Normal file
@ -0,0 +1,6 @@
|
||||
FROM python:3.11-alpine
|
||||
WORKDIR /app
|
||||
RUN pip install --no-cache-dir requests psycopg2-binary
|
||||
COPY bridge_1c.py /app/bridge_1c.py
|
||||
EXPOSE 8088
|
||||
CMD ["python", "/app/bridge_1c.py"]
|
||||
635
bridge/bridge_1c.py
Normal file
@ -0,0 +1,635 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
1C:Enterprise HTTP Bridge for Dana Medical Website.
|
||||
Translates REST API calls from Go backend to 1C MCP Toolkit /api/execute_code.
|
||||
Runs in Docker container on 195.95.133.47 and talks to 1C at 10.240.70.70:6003.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import urllib.parse
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from datetime import datetime, timezone
|
||||
import requests
|
||||
import psycopg2
|
||||
|
||||
MCP_URL = "http://10.240.70.70:6003/api/execute_code"
|
||||
PG_HOST = "db"
|
||||
PG_PORT = 5432
|
||||
PG_USER = "dana"
|
||||
PG_DB = "dana"
|
||||
PG_PASS = "301ed23c81a8e2317b77f67340f571411ac16a5e5927e4bf17b076e01657f5c0"
|
||||
|
||||
def get_linked_doctor_ids():
|
||||
try:
|
||||
conn = psycopg2.connect(
|
||||
host=PG_HOST, port=PG_PORT, user=PG_USER, password=PG_PASS, dbname=PG_DB, connect_timeout=3
|
||||
)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT onec_id FROM onec_doctors WHERE doctor_id IS NOT NULL")
|
||||
rows = cur.fetchall()
|
||||
conn.close()
|
||||
ids = [r[0] for r in rows if r[0]]
|
||||
if ids:
|
||||
return ids
|
||||
except Exception as e:
|
||||
print(f"[DB Warning] Could not fetch linked doctors from Postgres: {e}", flush=True)
|
||||
|
||||
# Fallback to known linked doctors
|
||||
return [
|
||||
"e27d7269-b659-11f1-8958-00155d46cd22",
|
||||
"f07031a2-21a6-11f0-8712-00155d46cd22",
|
||||
"a748a2aa-8771-11ed-840b-00155d46cd22",
|
||||
"fce19e6d-3d52-11ec-adba-00155d46cd1d",
|
||||
"137a17ba-0af6-11f0-86f5-00155d46cd22",
|
||||
"a748a2a7-8771-11ed-840b-00155d46cd22",
|
||||
]
|
||||
|
||||
AUTH_TOKEN = "747570bf-e7ab-461b-9d30-6f1df275a8df"
|
||||
|
||||
def call_1c(code: str):
|
||||
try:
|
||||
headers = {"Authorization": f"Bearer {AUTH_TOKEN}", "Content-Type": "application/json"}
|
||||
resp = requests.post(MCP_URL, json={"code": code}, headers=headers, timeout=30)
|
||||
if resp.status_code != 200:
|
||||
return None, f"1C MCP returned HTTP {resp.status_code}: {resp.text[:200]}"
|
||||
res_json = resp.json()
|
||||
if not res_json.get("success", False):
|
||||
return None, f"1C execution failed: {res_json.get('error', 'unknown error')}"
|
||||
raw_data = res_json.get("data", "")
|
||||
if isinstance(raw_data, str):
|
||||
try:
|
||||
parsed = json.loads(raw_data)
|
||||
if isinstance(parsed, str) and (parsed.strip().startswith("{") or parsed.strip().startswith("[")):
|
||||
try:
|
||||
return json.loads(parsed), None
|
||||
except Exception:
|
||||
return parsed, None
|
||||
return parsed, None
|
||||
except Exception:
|
||||
pass
|
||||
return raw_data, None
|
||||
except Exception as e:
|
||||
return None, f"Network error connecting to 1C: {e}"
|
||||
|
||||
class BridgeHandler(BaseHTTPRequestHandler):
|
||||
def log_message(self, format, *args):
|
||||
sys.stderr.write("%s - - [%s] %s\n" % (self.client_address[0], self.log_date_time_string(), format % args))
|
||||
|
||||
def send_json(self, status_code: int, data: any):
|
||||
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(status_code)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
path = parsed.path.rstrip("/")
|
||||
query = urllib.parse.parse_qs(parsed.query)
|
||||
|
||||
if path == "/branches":
|
||||
code = """
|
||||
Запрос = Новый Запрос;
|
||||
Запрос.Текст =
|
||||
"ВЫБРАТЬ
|
||||
| Филиалы.Ссылка КАК Ссылка,
|
||||
| Филиалы.Наименование КАК Наименование
|
||||
|ИЗ
|
||||
| Справочник.Филиалы КАК Филиалы
|
||||
|ГДЕ
|
||||
| Филиалы.ПометкаУдаления = ЛОЖЬ
|
||||
| И (Филиалы.Наименование ПОДОБНО ""%Дана%"" ИЛИ Филиалы.Наименование ПОДОБНО ""%Ляхова 22%"")
|
||||
| И НЕ Филиалы.Наименование ПОДОБНО ""%Абилити%""
|
||||
| И НЕ Филиалы.Наименование ПОДОБНО ""%Целитель%""
|
||||
|УПОРЯДОЧИТЬ ПО
|
||||
| Филиалы.Наименование";
|
||||
|
||||
Таблица = Запрос.Выполнить().Выгрузить();
|
||||
Массив = Новый Массив;
|
||||
|
||||
Для Каждого Стр Из Таблица Цикл
|
||||
Элемент = Новый Структура;
|
||||
Элемент.Вставить("id", НРег(XMLСтрока(Стр.Ссылка)));
|
||||
Элемент.Вставить("name", Стр.Наименование);
|
||||
|
||||
Адрес = "г. Махачкала, ул. Ляхова";
|
||||
Если Найти(ВРег(Стр.Наименование), "22") > 0 Тогда
|
||||
Адрес = "г. Махачкала, ул. Ляхова, д. 22";
|
||||
ИначеЕсли Найти(ВРег(Стр.Наименование), "7") > 0 Тогда
|
||||
Адрес = "г. Махачкала, ул. Ляхова, д. 7";
|
||||
ИначеЕсли Найти(ВРег(Стр.Наименование), "ХАСАВЮРТ") > 0 Тогда
|
||||
Адрес = "г. Хасавюрт";
|
||||
ИначеЕсли Найти(ВРег(Стр.Наименование), "ЦОМ") > 0 ИЛИ Найти(ВРег(Стр.Наименование), "ОСТЕО") > 0 Тогда
|
||||
Адрес = "г. Махачкала, ул. Салаватова, д. 53";
|
||||
КонецЕсли;
|
||||
|
||||
Элемент.Вставить("address", Адрес);
|
||||
Элемент.Вставить("phone", "+7 (8722) 55-33-11");
|
||||
Элемент.Вставить("active", Истина);
|
||||
Массив.Добавить(Элемент);
|
||||
КонецЦикла;
|
||||
|
||||
ЗаписьJSON = Новый ЗаписьJSON;
|
||||
ЗаписьJSON.УстановитьСтроку();
|
||||
ЗаписатьJSON(ЗаписьJSON, Массив);
|
||||
Результат = ЗаписьJSON.Закрыть();
|
||||
"""
|
||||
data, err = call_1c(code)
|
||||
if err:
|
||||
self.send_json(502, {"code": "1c_error", "message": err})
|
||||
else:
|
||||
self.send_json(200, data or [])
|
||||
return
|
||||
|
||||
if path == "/doctors":
|
||||
code = """
|
||||
Запрос1С = Новый Структура("ПараметрыЗапроса", Новый Соответствие);
|
||||
Ответ = ДМ_СайтРасписание.ПолучитьВрачей(Запрос1С);
|
||||
Результат = Ответ.ПолучитьТелоКакСтроку();
|
||||
"""
|
||||
data, err = call_1c(code)
|
||||
if err:
|
||||
self.send_json(502, {"code": "1c_error", "message": err})
|
||||
else:
|
||||
doctors = data or []
|
||||
for d in doctors:
|
||||
try:
|
||||
d["kids_from"] = int(d.get("kids_from", 0))
|
||||
except (ValueError, TypeError):
|
||||
d["kids_from"] = 0
|
||||
self.send_json(200, doctors)
|
||||
return
|
||||
|
||||
if path.startswith("/doctors/") and path.endswith("/services"):
|
||||
parts = path.split("/")
|
||||
doctor_id = parts[2]
|
||||
code = f"""
|
||||
ВрачСсылка = Справочники.Сотрудники.ПолучитьСсылку(Новый УникальныйИдентификатор("{doctor_id}"));
|
||||
Запрос = Новый Запрос;
|
||||
Запрос.Текст =
|
||||
"ВЫБРАТЬ ПЕРВЫЕ 100
|
||||
| Цены.Номенклатура.Ссылка КАК Ссылка,
|
||||
| Цены.Номенклатура.Наименование КАК Наименование,
|
||||
| Цены.Цена КАК Цена
|
||||
|ИЗ
|
||||
| РегистрСведений.ЦеныНоменклатуры.СрезПоследних(&Период, ) КАК Цены
|
||||
|ГДЕ
|
||||
| Цены.Номенклатура.ПометкаУдаления = ЛОЖЬ
|
||||
| И Цены.Цена > 0
|
||||
| И (Цены.бит_Сотрудник = &Врач ИЛИ Цены.бит_Сотрудник = ЗНАЧЕНИЕ(Справочник.Сотрудники.ПустаяСсылка))
|
||||
|УПОРЯДОЧИТЬ ПО
|
||||
| Цены.Номенклатура.Наименование";
|
||||
|
||||
Запрос.УстановитьПараметр("Период", ТекущаяДата());
|
||||
Запрос.УстановитьПараметр("Врач", ВрачСсылка);
|
||||
|
||||
Таблица = Запрос.Выполнить().Выгрузить();
|
||||
Массив = Новый Массив;
|
||||
|
||||
Длит = ВрачСсылка.ДлительностьПриема;
|
||||
ДлитМин = ?(ТипЗнч(Длит) = Тип("Дата"), Час(Длит) * 60 + Минута(Длит), 30);
|
||||
Если ДлитМин < 15 ИЛИ ДлитМин > 60 Тогда ДлитМин = 30; КонецЕсли;
|
||||
|
||||
Для Каждого Стр Из Таблица Цикл
|
||||
Услуга = Новый Структура;
|
||||
Услуга.Вставить("id", НРег(XMLСтрока(Стр.Ссылка)));
|
||||
Услуга.Вставить("name", Стр.Наименование);
|
||||
Услуга.Вставить("duration_min", ДлитМин);
|
||||
Услуга.Вставить("price", Стр.Цена);
|
||||
Массив.Добавить(Услуга);
|
||||
КонецЦикла;
|
||||
|
||||
ЗаписьJSON = Новый ЗаписьJSON;
|
||||
ЗаписьJSON.УстановитьСтроку();
|
||||
ЗаписатьJSON(ЗаписьJSON, Массив);
|
||||
Результат = ЗаписьJSON.Закрыть();
|
||||
"""
|
||||
data, err = call_1c(code)
|
||||
if err:
|
||||
self.send_json(502, {"code": "1c_error", "message": err})
|
||||
else:
|
||||
services = data or []
|
||||
# Guarantee types for Go unmarshaling
|
||||
for s in services:
|
||||
try:
|
||||
s["duration_min"] = int(s.get("duration_min", 30))
|
||||
except (ValueError, TypeError):
|
||||
s["duration_min"] = 30
|
||||
try:
|
||||
s["price"] = float(s.get("price", 0.0))
|
||||
except (ValueError, TypeError):
|
||||
s["price"] = 0.0
|
||||
self.send_json(200, services)
|
||||
return
|
||||
|
||||
if path == "/schedule":
|
||||
doctor_id = query.get("doctor_id", [""])[0]
|
||||
from_str = query.get("from", [""])[0]
|
||||
to_str = query.get("to", [""])[0]
|
||||
branch_id = query.get("branch_id", [""])[0]
|
||||
|
||||
now = datetime.now()
|
||||
dt_from = now
|
||||
if from_str:
|
||||
try:
|
||||
dt_from = datetime.fromisoformat(from_str.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
dt_to = dt_from
|
||||
if to_str:
|
||||
try:
|
||||
dt_to = datetime.fromisoformat(to_str.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
pass
|
||||
if dt_to <= dt_from:
|
||||
from datetime import timedelta
|
||||
dt_to = dt_from + timedelta(days=14)
|
||||
|
||||
# Determine target doctors
|
||||
if doctor_id:
|
||||
target_ids = [doctor_id]
|
||||
else:
|
||||
target_ids = get_linked_doctor_ids()
|
||||
|
||||
ids_1c_array = ", ".join([f'Новый УникальныйИдентификатор("{x}")' for x in target_ids])
|
||||
|
||||
if branch_id:
|
||||
branch_clause = f'И Графики.Филиал = Справочники.Филиалы.ПолучитьСсылку(Новый УникальныйИдентификатор("{branch_id}"))'
|
||||
else:
|
||||
branch_clause = ""
|
||||
|
||||
code = f"""
|
||||
МассивИД = Новый Массив;
|
||||
"""
|
||||
for x in target_ids:
|
||||
code += f'МассивИД.Добавить(Новый УникальныйИдентификатор("{x}"));\n'
|
||||
|
||||
code += f"""
|
||||
МассивСсылок = Новый Массив;
|
||||
Для Каждого ИД Из МассивИД Цикл
|
||||
Ссылка = Справочники.Сотрудники.ПолучитьСсылку(ИД);
|
||||
МассивСсылок.Добавить(Ссылка);
|
||||
КонецЦикла;
|
||||
|
||||
ДатаС = Дата({dt_from.year}, {dt_from.month}, {dt_from.day}, {dt_from.hour}, {dt_from.minute}, {dt_from.second});
|
||||
ДатаПо = Дата({dt_to.year}, {dt_to.month}, {dt_to.day}, {dt_to.hour}, {dt_to.minute}, {dt_to.second});
|
||||
|
||||
Запрос = Новый Запрос;
|
||||
Запрос.Текст =
|
||||
"ВЫБРАТЬ
|
||||
| Графики.Сотрудник КАК Сотрудник,
|
||||
| Графики.Сотрудник.ДлительностьПриема КАК ДлительностьПриема,
|
||||
| Графики.Дата КАК Дата,
|
||||
| Графики.ВремяНачала КАК ВремяНачала,
|
||||
| Графики.ВремяОкончания КАК ВремяОкончания,
|
||||
| Графики.Кабинет КАК Кабинет,
|
||||
| Графики.Кабинет.Наименование КАК КабинетИмя,
|
||||
| Графики.Филиал КАК Филиал
|
||||
|ИЗ
|
||||
| РегистрСведений.ГрафикиРаботы КАК Графики
|
||||
|ГДЕ
|
||||
| Графики.Дата МЕЖДУ &ДатаС И &ДатаПо
|
||||
| И Графики.Сотрудник В (&МассивВрачей)
|
||||
| И Графики.Сотрудник.ПометкаУдаления = ЛОЖЬ
|
||||
| И (Графики.Филиал.Наименование ПОДОБНО ""%Дана%"" ИЛИ Графики.Филиал.Наименование ПОДОБНО ""%Ляхова 22%"")
|
||||
| И НЕ Графики.Филиал.Наименование ПОДОБНО ""%Абилити%""
|
||||
| И НЕ Графики.Филиал.Наименование ПОДОБНО ""%Целитель%""
|
||||
| {branch_clause}";
|
||||
|
||||
Запрос.УстановитьПараметр("МассивВрачей", МассивСсылок);
|
||||
Запрос.УстановитьПараметр("ДатаС", ДатаС);
|
||||
Запрос.УстановитьПараметр("ДатаПо", ДатаПо);
|
||||
|
||||
Таблица = Запрос.Выполнить().Выгрузить();
|
||||
|
||||
// Получаем существующие заявки для блокировки занятых слотов
|
||||
ЗапросЗаявок = Новый Запрос;
|
||||
ЗапросЗаявок.Текст =
|
||||
"ВЫБРАТЬ
|
||||
| Заявка.Сотрудник КАК Сотрудник,
|
||||
| Заявка.ДатаНачала КАК ДатаНачала,
|
||||
| Заявка.ДатаОкончания КАК ДатаОкончания
|
||||
|ИЗ
|
||||
| Документ.Заявка КАК Заявка
|
||||
|ГДЕ
|
||||
| Заявка.ПометкаУдаления = ЛОЖЬ
|
||||
| И (Заявка.Состояние.ТипСостояния <> &ТипОтменена ИЛИ Заявка.Состояние.ТипСостояния ЕСТЬ NULL)
|
||||
| И Заявка.Сотрудник В (&МассивВрачей)
|
||||
| И Заявка.ДатаНачала < &ДатаПо
|
||||
| И Заявка.ДатаОкончания > &ДатаС";
|
||||
|
||||
ЗапросЗаявок.УстановитьПараметр("МассивВрачей", МассивСсылок);
|
||||
ЗапросЗаявок.УстановитьПараметр("ДатаС", ДатаС);
|
||||
ЗапросЗаявок.УстановитьПараметр("ДатаПо", ДатаПо);
|
||||
ЗапросЗаявок.УстановитьПараметр("ТипОтменена", Перечисления.ТипыСостоянийЗаявок.Отменена);
|
||||
|
||||
ТаблицаЗаявок = ЗапросЗаявок.Выполнить().Выгрузить();
|
||||
|
||||
Слоты = Новый Массив;
|
||||
ТекущееВремя = ТекущаяДата();
|
||||
|
||||
Для Каждого Стр Из Таблица Цикл
|
||||
Длит = Стр.ДлительностьПриема;
|
||||
ШагМинут = ?(ТипЗнч(Длит) = Тип("Дата"), Час(Длит) * 60 + Минута(Длит), 30);
|
||||
Если ШагМинут < 15 ИЛИ ШагМинут > 60 Тогда ШагМинут = 30; КонецЕсли;
|
||||
ШагСекунд = ШагМинут * 60;
|
||||
|
||||
ДатаСмены = НачалоДня(Стр.Дата);
|
||||
НачалоСмены = ДатаСмены + (Стр.ВремяНачала - НачалоДня(Стр.ВремяНачала));
|
||||
КонецСмены = ДатаСмены + (Стр.ВремяОкончания - НачалоДня(Стр.ВремяОкончания));
|
||||
|
||||
IDВрача = НРег(XMLСтрока(Стр.Сотрудник));
|
||||
IDФилиала = НРег(XMLСтрока(Стр.Филиал));
|
||||
ИмяКабинета = ?(ЗначениеЗаполнено(Стр.Кабинет), Стр.КабинетИмя, "");
|
||||
|
||||
Текущий = НачалоСмены;
|
||||
Пока (Текущий + ШагСекунд) <= КонецСмены Цикл
|
||||
СлотКонец = Текущий + ШагСекунд;
|
||||
|
||||
// Проверка на занятость заявками
|
||||
Занят = Ложь;
|
||||
Для Каждого СтрЗаявки Из ТаблицаЗаявок Цикл
|
||||
Если СтрЗаявки.Сотрудник = Стр.Сотрудник И (СтрЗаявки.ДатаНачала < СлотКонец) И (СтрЗаявки.ДатаОкончания > Текущий) Тогда
|
||||
Занят = Истина;
|
||||
Прервать;
|
||||
КонецЕсли;
|
||||
КонецЦикла;
|
||||
|
||||
Слот = Новый Структура;
|
||||
Слот.Вставить("doctor_id", IDВрача);
|
||||
Слот.Вставить("branch_id", IDФилиала);
|
||||
Слот.Вставить("cabinet", ИмяКабинета);
|
||||
Слот.Вставить("start", Формат(Текущий, "ДФ=yyyy-MM-ddTHH:mm:ssZ"));
|
||||
Слот.Вставить("end", Формат(СлотКонец, "ДФ=yyyy-MM-ddTHH:mm:ssZ"));
|
||||
Слот.Вставить("status", ?(Занят, "busy", "free"));
|
||||
Слот.Вставить("slot_id", IDВрача + "_" + Формат(Текущий, "ДФ=yyyyMMddHHmmss"));
|
||||
Слоты.Добавить(Слот);
|
||||
|
||||
Текущий = СлотКонец;
|
||||
КонецЦикла;
|
||||
КонецЦикла;
|
||||
|
||||
ЗаписьJSON = Новый ЗаписьJSON;
|
||||
ЗаписьJSON.УстановитьСтроку();
|
||||
ЗаписатьJSON(ЗаписьJSON, Слоты);
|
||||
Результат = ЗаписьJSON.Закрыть();
|
||||
"""
|
||||
data, err = call_1c(code)
|
||||
if err:
|
||||
self.send_json(502, {"code": "1c_error", "message": err})
|
||||
else:
|
||||
self.send_json(200, data or [])
|
||||
return
|
||||
|
||||
if path.startswith("/appointments/"):
|
||||
appt_id = path.split("/")[2]
|
||||
code = f"""
|
||||
ДокСсылка = Документы.Заявка.ПолучитьСсылку(Новый УникальныйИдентификатор("{appt_id}"));
|
||||
Ответ = Новый Структура;
|
||||
Ответ.Вставить("appointment_id", "{appt_id}");
|
||||
Ответ.Вставить("status", "booked");
|
||||
Если ЗначениеЗаполнено(ДокСсылка) И Не ДокСсылка.Пустая() Тогда
|
||||
Если ДокСсылка.ПометкаУдаления Тогда
|
||||
Ответ.Вставить("status", "cancelled");
|
||||
ИначеЕсли ДокСсылка.Состояние.ТипСостояния = Перечисления.ТипыСостоянийЗаявок.Отменена Тогда
|
||||
Ответ.Вставить("status", "cancelled");
|
||||
КонецЕсли;
|
||||
Ответ.Вставить("start", Формат(ДокСсылка.ДатаНачала, "ДФ=yyyy-MM-ddTHH:mm:ssZ"));
|
||||
Ответ.Вставить("end", Формат(ДокСсылка.ДатаОкончания, "ДФ=yyyy-MM-ddTHH:mm:ssZ"));
|
||||
КонецЕсли;
|
||||
|
||||
ЗаписьJSON = Новый ЗаписьJSON;
|
||||
ЗаписьJSON.УстановитьСтроку();
|
||||
ЗаписатьJSON(ЗаписьJSON, Ответ);
|
||||
Результат = ЗаписьJSON.Закрыть();
|
||||
"""
|
||||
data, err = call_1c(code)
|
||||
if err:
|
||||
self.send_json(502, {"code": "1c_error", "message": err})
|
||||
else:
|
||||
self.send_json(200, data or {"appointment_id": appt_id, "status": "booked"})
|
||||
return
|
||||
|
||||
if path == "/changes":
|
||||
self.send_json(200, {"version": 1, "doctor_ids": []})
|
||||
return
|
||||
|
||||
self.send_json(404, {"code": "not_found", "message": "Unknown endpoint"})
|
||||
|
||||
def do_POST(self):
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
path = parsed.path.rstrip("/")
|
||||
|
||||
content_len = int(self.headers.get("Content-Length", 0))
|
||||
post_data = self.rfile.read(content_len) if content_len > 0 else b"{}"
|
||||
try:
|
||||
req_json = json.loads(post_data.decode("utf-8"))
|
||||
except Exception:
|
||||
self.send_json(400, {"code": "bad_request", "message": "Invalid JSON"})
|
||||
return
|
||||
|
||||
if path == "/appointments":
|
||||
doctor_id = req_json.get("doctor_id", "")
|
||||
branch_id = req_json.get("branch_id", "")
|
||||
patient_name = req_json.get("patient_name", "")
|
||||
patient_phone = req_json.get("patient_phone", "")
|
||||
comment = req_json.get("comment", "")
|
||||
start_str = req_json.get("start", "")
|
||||
if not start_str:
|
||||
start_str = req_json.get("start_time", "")
|
||||
if "patient" in req_json and isinstance(req_json["patient"], dict):
|
||||
p = req_json["patient"]
|
||||
if not patient_name:
|
||||
patient_name = p.get("name") or f"{p.get('last_name','')} {p.get('first_name','')} {p.get('middle_name','')}".strip()
|
||||
if not patient_phone:
|
||||
patient_phone = p.get("phone", "")
|
||||
if not comment:
|
||||
comment = p.get("comment", "")
|
||||
if not patient_name:
|
||||
patient_name = "Пациент с сайта"
|
||||
|
||||
# Normalize phone digits and generate search patterns
|
||||
digits = "".join([c for c in patient_phone if c.isdigit()])
|
||||
if len(digits) >= 10:
|
||||
tail = digits[-10:]
|
||||
d = list(tail)
|
||||
pattern_delims = f"%{d[0]}{d[1]}{d[2]}%{d[3]}{d[4]}{d[5]}%{d[6]}{d[7]}%{d[8]}{d[9]}%"
|
||||
pattern_solid = f"%{tail}%"
|
||||
else:
|
||||
pattern_delims = f"%{digits}%"
|
||||
pattern_solid = f"%{digits}%"
|
||||
|
||||
code = f"""
|
||||
Попытка
|
||||
ВрачСсылка = Справочники.Сотрудники.ПолучитьСсылку(Новый УникальныйИдентификатор("{doctor_id}"));
|
||||
|
||||
// 1. Поиск существующего клиента по номеру телефона и подмена на него
|
||||
ЗапросКл = Новый Запрос;
|
||||
ЗапросКл.Текст =
|
||||
"ВЫБРАТЬ ПЕРВЫЕ 1
|
||||
| КИ.Объект КАК Клиент
|
||||
|ИЗ
|
||||
| РегистрСведений.КонтактнаяИнформация КАК КИ
|
||||
|ГДЕ
|
||||
| КИ.Тип = ЗНАЧЕНИЕ(Перечисление.ТипыКонтактнойИнформации.Телефон)
|
||||
| И КИ.Объект ССЫЛКА Справочник.Клиенты
|
||||
| И КИ.Объект.ПометкаУдаления = ЛОЖЬ
|
||||
| И (КИ.Представление ПОДОБНО &ШаблонРазд
|
||||
| ИЛИ КИ.Представление ПОДОБНО &ШаблонСплош
|
||||
| ИЛИ КИ.Поле3 ПОДОБНО &ШаблонРазд
|
||||
| ИЛИ КИ.Поле3 ПОДОБНО &ШаблонСплош)";
|
||||
|
||||
ЗапросКл.УстановитьПараметр("ШаблонРазд", "{pattern_delims}");
|
||||
ЗапросКл.УстановитьПараметр("ШаблонСплош", "{pattern_solid}");
|
||||
ВыбКл = ЗапросКл.Выполнить().Выбрать();
|
||||
|
||||
КлиентСсылка = Неопределено;
|
||||
Если ВыбКл.Следующий() И ЗначениеЗаполнено(ВыбКл.Клиент) Тогда
|
||||
// Подменяем на найденного клиента из базы 1С
|
||||
КлиентСсылка = ВыбКл.Клиент;
|
||||
Иначе
|
||||
// Клиент не найден -> создаем нового
|
||||
ОбъектКл = Справочники.Клиенты.СоздатьЭлемент();
|
||||
ОбъектКл.Наименование = "{patient_name}";
|
||||
ОбъектКл.Комментарий = "Создан через сайт danamedical.ru";
|
||||
ОбъектКл.Записать();
|
||||
КлиентСсылка = ОбъектКл.Ссылка;
|
||||
|
||||
Попытка
|
||||
ВидКИ = Неопределено;
|
||||
Попытка
|
||||
ВидКИ = Справочники.ВидыКонтактнойИнформации.ТелефонСотовый;
|
||||
Исключение
|
||||
ВидКИ = Справочники.ВидыКонтактнойИнформации.НайтиПоНаименованию("Телефон сотовый");
|
||||
КонецПопытки;
|
||||
|
||||
МЗ = РегистрыСведений.КонтактнаяИнформация.СоздатьМенеджерЗаписи();
|
||||
МЗ.Объект = КлиентСсылка;
|
||||
МЗ.Тип = Перечисления.ТипыКонтактнойИнформации.Телефон;
|
||||
Если ЗначениеЗаполнено(ВидКИ) Тогда
|
||||
МЗ.Вид = ВидКИ;
|
||||
КонецЕсли;
|
||||
МЗ.Представление = "{patient_phone}";
|
||||
МЗ.Поле3 = "{patient_phone}";
|
||||
МЗ.Записать(Истина);
|
||||
Исключение
|
||||
КонецПопытки;
|
||||
КонецЕсли;
|
||||
|
||||
// Время
|
||||
ВремяНач = ДМ_СайтAPI.РазобратьISO8601("{start_str}");
|
||||
Если Не ЗначениеЗаполнено(ВремяНач) Тогда
|
||||
ВремяНач = ТекущаяДата();
|
||||
КонецЕсли;
|
||||
Длит = ВрачСсылка.ДлительностьПриема;
|
||||
ШагМинут = ?(ТипЗнч(Длит) = Тип("Дата"), Час(Длит) * 60 + Минута(Длит), 30);
|
||||
Если ШагМинут < 15 ИЛИ ШагМинут > 60 Тогда ШагМинут = 30; КонецЕсли;
|
||||
|
||||
// Определение филиала (строго клиника сети Дана)
|
||||
ФилиалСсылка = Неопределено;
|
||||
Если ЗначениеЗаполнено("{branch_id}") Тогда
|
||||
Попытка
|
||||
ФилиалКанд = Справочники.Филиалы.ПолучитьСсылку(Новый УникальныйИдентификатор("{branch_id}"));
|
||||
Если ЗначениеЗаполнено(ФилиалКанд) И Не ФилиалКанд.Пустая() Тогда
|
||||
ИмяФ = ВРег(ФилиалКанд.Наименование);
|
||||
Если (Найти(ИмяФ, "ДАНА") > 0 ИЛИ Найти(ИмяФ, "ЛЯХОВА 22") > 0)
|
||||
И Найти(ИмяФ, "АБИЛИТИ") = 0
|
||||
И Найти(ИмяФ, "ЦЕЛИТЕЛЬ") = 0 Тогда
|
||||
ФилиалСсылка = ФилиалКанд;
|
||||
КонецЕсли;
|
||||
КонецЕсли;
|
||||
Исключение
|
||||
КонецПопытки;
|
||||
КонецЕсли;
|
||||
|
||||
Если Не ЗначениеЗаполнено(ФилиалСсылка) Тогда
|
||||
ЗапросСмены = Новый Запрос;
|
||||
ЗапросСмены.Текст =
|
||||
"ВЫБРАТЬ ПЕРВЫЕ 1
|
||||
| Графики.Филиал КАК Филиал
|
||||
|ИЗ
|
||||
| РегистрСведений.ГрафикиРаботы КАК Графики
|
||||
|ГДЕ
|
||||
| Графики.Сотрудник = &Врач
|
||||
| И Графики.Дата = НАЧАЛОПЕРИОДА(&ВремяНач, ДЕНЬ)
|
||||
| И (Графики.Филиал.Наименование ПОДОБНО ""%Дана%"" ИЛИ Графики.Филиал.Наименование ПОДОБНО ""%Ляхова 22%"")
|
||||
| И НЕ Графики.Филиал.Наименование ПОДОБНО ""%Абилити%""
|
||||
| И НЕ Графики.Филиал.Наименование ПОДОБНО ""%Целитель%"" ";
|
||||
ЗапросСмены.УстановитьПараметр("Врач", ВрачСсылка);
|
||||
ЗапросСмены.УстановитьПараметр("ВремяНач", ВремяНач);
|
||||
ВыбСмены = ЗапросСмены.Выполнить().Выбрать();
|
||||
Если ВыбСмены.Следующий() Тогда
|
||||
ФилиалСсылка = ВыбСмены.Филиал;
|
||||
КонецЕсли;
|
||||
КонецЕсли;
|
||||
|
||||
Если Не ЗначениеЗаполнено(ФилиалСсылка) Тогда
|
||||
ЗапросФ = Новый Запрос(
|
||||
"ВЫБРАТЬ ПЕРВЫЕ 1 Ссылка ИЗ Справочник.Филиалы КАК Филиалы
|
||||
|ГДЕ Филиалы.ПометкаУдаления = ЛОЖЬ
|
||||
| И (Филиалы.Наименование ПОДОБНО ""%Дана%"" ИЛИ Филиалы.Наименование ПОДОБНО ""%Ляхова 22%"")
|
||||
| И НЕ Филиалы.Наименование ПОДОБНО ""%Абилити%""
|
||||
| И НЕ Филиалы.Наименование ПОДОБНО ""%Целитель%"" ");
|
||||
ВыбФ = ЗапросФ.Выполнить().Выбрать();
|
||||
Если ВыбФ.Следующий() Тогда
|
||||
ФилиалСсылка = ВыбФ.Ссылка;
|
||||
КонецЕсли;
|
||||
КонецЕсли;
|
||||
|
||||
// Создаем Заявку
|
||||
Док = Документы.Заявка.СоздатьДокумент();
|
||||
Док.Дата = ТекущаяДата();
|
||||
Док.ДатаНачала = ВремяНач;
|
||||
Док.ДатаОкончания = ВремяНач + ШагМинут * 60;
|
||||
Док.ВремяНачала = Док.ДатаНачала;
|
||||
Док.ВремяОкончания = Док.ДатаОкончания;
|
||||
Док.Сотрудник = ВрачСсылка;
|
||||
Док.Филиал = ФилиалСсылка;
|
||||
Док.Клиент = КлиентСсылка;
|
||||
Док.Состояние = Справочники.ВидыСостоянийЗаявок.Запланирована;
|
||||
Док.Примечание = "Онлайн-запись: {comment}";
|
||||
|
||||
Ист = Справочники.ИсточникиИнформации.НайтиПоНаименованию("Сайт");
|
||||
Если ЗначениеЗаполнено(Ист) Тогда
|
||||
Попытка Док.БИТ_ИсточникИнформации = Ист; Исключение КонецПопытки;
|
||||
КонецЕсли;
|
||||
|
||||
Док.Записать(РежимЗаписиДокумента.Запись);
|
||||
ID = НРег(XMLСтрока(Док.Ссылка));
|
||||
|
||||
Ответ = Новый Структура;
|
||||
Ответ.Вставить("appointment_id", ID);
|
||||
Ответ.Вставить("status", "booked");
|
||||
Ответ.Вставить("start", Формат(Док.ДатаНачала, "ДФ=yyyy-MM-ddTHH:mm:ssZ"));
|
||||
Ответ.Вставить("end", Формат(Док.ДатаОкончания, "ДФ=yyyy-MM-ddTHH:mm:ssZ"));
|
||||
Ответ.Вставить("branch_id", НРег(XMLСтрока(ФилиалСсылка)));
|
||||
Ответ.Вставить("patient_id", НРег(XMLСтрока(КлиентСсылка)));
|
||||
|
||||
ЗаписьJSON = Новый ЗаписьJSON;
|
||||
ЗаписьJSON.УстановитьСтроку();
|
||||
ЗаписатьJSON(ЗаписьJSON, Ответ);
|
||||
Результат = ЗаписьJSON.Закрыть();
|
||||
Исключение
|
||||
Результат = "ОШИБКА: " + ОписаниеОшибки();
|
||||
КонецПопытки;
|
||||
"""
|
||||
data, err = call_1c(code)
|
||||
if err or (isinstance(data, str) and data.startswith("ОШИБКА")):
|
||||
self.send_json(500, {"code": "booking_failed", "message": err or data})
|
||||
else:
|
||||
self.send_json(201, data)
|
||||
return
|
||||
|
||||
self.send_json(404, {"code": "not_found", "message": "Unknown endpoint"})
|
||||
|
||||
def run():
|
||||
server_address = ("0.0.0.0", 8088)
|
||||
httpd = HTTPServer(server_address, BridgeHandler)
|
||||
print("Bridge server running on port 8088 with live Postgres link...", flush=True)
|
||||
httpd.serve_forever()
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
BIN
web/src/assets/checkups/hero-davlenie-v-poryadke.webp
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
web/src/assets/checkups/hero-planirovannie-beremennosti.webp
Normal file
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 52 KiB |
BIN
web/src/assets/checkups/hero-profilaktika-raka-sheyki-matki.webp
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
web/src/assets/checkups/hero-proverit-svoe-zhenskoe-zdorove.webp
Normal file
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 77 KiB |
BIN
web/src/assets/checkups/hero-vedenie-beremennosti-2-trim.webp
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
web/src/assets/checkups/hero-vedenie-beremennosti-3-trim.webp
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
web/src/assets/checkups/hero-zdorovaya-zhenshchina-40.webp
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
web/src/assets/checkups/hero-zdorovye-roditeli.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
@ -38,7 +38,13 @@ function hourSlots(hours) {
|
||||
let from = m ? Number(m[1]) : 8;
|
||||
let to = m ? Number(m[2]) : 20;
|
||||
if (!(from >= 0 && to <= 24 && from < to)) [from, to] = [8, 20];
|
||||
return Array.from({ length: to - from }, (_, i) => `${String(from + i).padStart(2, '0')}:00`);
|
||||
const list = [];
|
||||
for (let h = from; h < to; h++) {
|
||||
const hh = String(h).padStart(2, '0');
|
||||
list.push(`${hh}:00`);
|
||||
list.push(`${hh}:30`);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function todayISO() {
|
||||
@ -87,11 +93,14 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
||||
}));
|
||||
};
|
||||
|
||||
// на сегодня — только часы, до которых ещё больше часа
|
||||
// на сегодня — только слоты в будущем
|
||||
const isPast = (slot) => {
|
||||
if (values.date !== todayISO()) return false;
|
||||
const now = new Date();
|
||||
return Number(slot.slice(0, 2)) <= now.getHours();
|
||||
const [h, m] = slot.split(':').map(Number);
|
||||
const slotMinutes = h * 60 + (m || 0);
|
||||
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
return slotMinutes <= nowMinutes;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@ -313,7 +322,7 @@ export default function LeadModal({ form = 'appointment', doctor, slot, service,
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="lead-slots__hint">Сначала выберите дату — покажем время по часам</p>
|
||||
<p className="lead-slots__hint">Сначала выберите дату — покажем время приёма</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -46,6 +46,17 @@ import promoKindergarten from '../assets/figma/promo-sad.webp';
|
||||
import promoBaby from '../assets/figma/promo-rodilsya.webp';
|
||||
|
||||
import checkupHeroGirl from '../assets/figma/checkup-hero-girl.webp';
|
||||
// Вырезанные фигуры для шапки чек-апа (прозрачный фон, 540 px по высоте шапки), 14.10.2026
|
||||
import cutCervix from '../assets/checkups/hero-profilaktika-raka-sheyki-matki.webp';
|
||||
import cutPostpartum from '../assets/checkups/hero-poslerodovoy-chek-ap-cherez-mesyats.webp';
|
||||
import cutPlanning from '../assets/checkups/hero-planirovannie-beremennosti.webp';
|
||||
import cutWoman40 from '../assets/checkups/hero-zdorovaya-zhenshchina-40.webp';
|
||||
import cutWomanHealth from '../assets/checkups/hero-proverit-svoe-zhenskoe-zdorove.webp';
|
||||
import cutTrimester1 from '../assets/checkups/hero-vedenie-beremennosti-1-trimestr.webp';
|
||||
import cutTrimester2 from '../assets/checkups/hero-vedenie-beremennosti-2-trim.webp';
|
||||
import cutTrimester3 from '../assets/checkups/hero-vedenie-beremennosti-3-trim.webp';
|
||||
import cutParents from '../assets/checkups/hero-zdorovye-roditeli.webp';
|
||||
import cutPressure from '../assets/checkups/hero-davlenie-v-poryadke.webp';
|
||||
|
||||
// Направления: фото для плитки и специальность, врачей которой показываем на странице услуги
|
||||
// (связей врач ↔ услуга в базе почти нет, поэтому подбираем по специальности).
|
||||
@ -88,11 +99,12 @@ export function directionImage(service) {
|
||||
|
||||
const CHECKUP_MEDIA = {
|
||||
'zdorovaya-devoch-a': { photo: ckGirl, icon: icoFemale, hero: checkupHeroGirl },
|
||||
'proverit-svoe-zhenskoe-zdorove-': { photo: ckWoman, icon: icoCheckup },
|
||||
'planirovannie-beremennosti': { photo: ckPregnancy, icon: icoPregnant },
|
||||
'profilaktika-raka-sheyki-matki': { photo: ckCervix, icon: icoRibbon },
|
||||
'davlenie-v-poryadke': { photo: ckPressure, icon: icoTonometer },
|
||||
'zdorovye-roditeli': { photo: ckParents, icon: icoFamily },
|
||||
'proverit-svoe-zhenskoe-zdorove-': { photo: ckWoman, icon: icoCheckup, cutout: cutWomanHealth },
|
||||
'planirovannie-beremennosti': { photo: ckPregnancy, icon: icoPregnant, cutout: cutPlanning },
|
||||
'profilaktika-raka-sheyki-matki': { photo: ckCervix, icon: icoRibbon, cutout: cutCervix },
|
||||
// серо-синий фон шапки (#7886a9) — давление и родители
|
||||
'davlenie-v-poryadke': { photo: ckPressure, icon: icoTonometer, cutout: cutPressure, heroBg: '#7886a9' },
|
||||
'zdorovye-roditeli': { photo: ckParents, icon: icoFamily, cutout: cutParents, heroBg: '#7886a9' },
|
||||
'mne-god': { photo: ckYear, icon: icoBaby },
|
||||
'chek-ap-ya-v-sad': { photo: ckKindergarten, icon: icoPlayground },
|
||||
pervoklashka: { photo: ckSchool, icon: icoSchool },
|
||||
@ -100,11 +112,11 @@ const CHECKUP_MEDIA = {
|
||||
detskiy: { photo: ckMini, icon: icoStethoscope },
|
||||
'zdorovyy-zhkt': { photo: ckGut, icon: icoStomach },
|
||||
// программ ниже в макете нет — берём близкие по смыслу фото макета
|
||||
'vedenie-beremennosti-1-trimestr': { photo: ckPregnancy, icon: icoPregnant },
|
||||
'vedenie-beremennosti-2-trim': { photo: ckPregnancy, icon: icoPregnant },
|
||||
'vedenie-beremennosti-3-trim': { photo: ckPregnancy, icon: icoPregnant },
|
||||
'poslerodovoy-chek-ap-cherez-mesyats': { photo: ckWoman, icon: icoFemale },
|
||||
'zdorovaya-zhenshchina-40': { photo: ckWoman, icon: icoFemale },
|
||||
'vedenie-beremennosti-1-trimestr': { photo: ckPregnancy, icon: icoPregnant, cutout: cutTrimester1 },
|
||||
'vedenie-beremennosti-2-trim': { photo: ckPregnancy, icon: icoPregnant, cutout: cutTrimester2 },
|
||||
'vedenie-beremennosti-3-trim': { photo: ckPregnancy, icon: icoPregnant, cutout: cutTrimester3 },
|
||||
'poslerodovoy-chek-ap-cherez-mesyats': { photo: ckWoman, icon: icoFemale, cutout: cutPostpartum },
|
||||
'zdorovaya-zhenshchina-40': { photo: ckWoman, icon: icoFemale, cutout: cutWoman40 },
|
||||
'chek-ap-ya-rodilsya': { photo: ckYear, icon: icoBaby },
|
||||
};
|
||||
|
||||
@ -117,6 +129,10 @@ export function checkupMedia(checkup) {
|
||||
// крупный кадр наверху страницы — только отдельный большой снимок: фото карточки
|
||||
// (360×270) в блоке 364×485 было бы размытым
|
||||
hero: m.hero || '',
|
||||
// вырезанная фигура на фоне шапки, как в макете: справа на всю высоту, без рамки
|
||||
cutout: m.cutout || '',
|
||||
// свой цвет фона шапки, если в макете он не коралловый
|
||||
heroBg: m.heroBg || '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -12,6 +12,12 @@
|
||||
background: var(--orange);
|
||||
}
|
||||
|
||||
/* программы с вырезанной фигурой: женщины, беременность, гинекология — #f08372;
|
||||
другой цвет задаёт heroBg в media.js (давление, родители — #7886a9). Детские — --orange (#ee7203). */
|
||||
.checkup-hero--cutout {
|
||||
background: var(--checkup-hero-bg, #f08372);
|
||||
}
|
||||
|
||||
.checkup-hero__row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@ -67,6 +73,12 @@
|
||||
max-width: 570px;
|
||||
font-size: 18px;
|
||||
line-height: 28px;
|
||||
/* в шапке — короткое описание; полный список анализов — ниже, в «Что входит в программу» */
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
line-clamp: 4;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.checkup-hero__actions {
|
||||
@ -429,3 +441,70 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- вырезанная фигура в шапке ----------
|
||||
Фото без фона справа на всю высоту шапки, низом к краю, как в макете. Широкий снимок
|
||||
уменьшается целиком (без обрезки), чтобы не заходить на текст; справа может выходить за сетку. */
|
||||
.checkup-hero__row--cutout .checkup-hero__copy {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.checkup-hero__image--cutout {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 592px;
|
||||
right: min(0px, calc((100% - 100vw) / 2 + 80px));
|
||||
width: auto;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.checkup-hero__image--cutout img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: right bottom;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.checkup-hero__row--cutout .checkup-hero__copy {
|
||||
max-width: 55%;
|
||||
}
|
||||
.checkup-hero__image--cutout {
|
||||
left: 58%;
|
||||
right: calc(-1 * var(--gutter));
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* телефон: фигура уходит за текст и приглушена, чтобы текст читался */
|
||||
@media (max-width: 767px) {
|
||||
.checkup-hero__row--cutout .checkup-hero__copy {
|
||||
max-width: none;
|
||||
}
|
||||
.checkup-hero__image--cutout {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 18%;
|
||||
right: calc(-1 * var(--gutter));
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
.checkup-hero__image--cutout img {
|
||||
object-fit: cover;
|
||||
object-position: 70% bottom;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.checkup-hero__text {
|
||||
-webkit-line-clamp: 6;
|
||||
line-clamp: 6;
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,7 +27,7 @@ export default function CheckupPage() {
|
||||
if (!checkup.data || checkup.data.slug !== slug) return <p className="state checkup-loading">Загружаем…</p>;
|
||||
|
||||
const c = checkup.data;
|
||||
const { hero, photo } = checkupMedia(c);
|
||||
const { hero, photo, cutout, heroBg } = checkupMedia(c);
|
||||
const items = c.items?.length ? c.items.map(cleanText) : checklistItems(c.blocks);
|
||||
const intro = introText(c.blocks);
|
||||
const lead = cleanText(c.lead);
|
||||
@ -40,8 +40,11 @@ export default function CheckupPage() {
|
||||
|
||||
return (
|
||||
<div className="checkup-page">
|
||||
<section className="checkup-hero">
|
||||
<div className="container checkup-hero__row">
|
||||
<section
|
||||
className={`checkup-hero${cutout ? ' checkup-hero--cutout' : ''}`}
|
||||
style={heroBg ? { '--checkup-hero-bg': heroBg } : undefined}
|
||||
>
|
||||
<div className={`container checkup-hero__row${cutout ? ' checkup-hero__row--cutout' : ''}`}>
|
||||
<div className="checkup-hero__copy">
|
||||
<Breadcrumbs
|
||||
items={[{ to: '/', label: 'Главная' }, { to: '/checkup/', label: 'Чек-апы' }, { label: name }]}
|
||||
@ -60,7 +63,11 @@ export default function CheckupPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{hero ? (
|
||||
{cutout ? (
|
||||
<div className="checkup-hero__image checkup-hero__image--cutout">
|
||||
<img src={cutout} alt="" />
|
||||
</div>
|
||||
) : hero ? (
|
||||
<div className="checkup-hero__image">
|
||||
<img src={hero} alt="" />
|
||||
</div>
|
||||
|
||||