Refactor menu flows into dedicated pages
Some checks failed
ci / test (push) Has been cancelled

This commit is contained in:
VPN SaaS Dev
2026-05-16 11:59:09 +09:00
parent 01a69fc42d
commit ecfb5aa949
20 changed files with 2415 additions and 97 deletions

132
web/book_sto.html Normal file
View File

@@ -0,0 +1,132 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#16806a" />
<title>Запись в СТО</title>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="stylesheet" href="/static/styles.css" />
<script src="https://telegram.org/js/telegram-web-app.js"></script>
</head>
<body class="auth-required flow-page">
<div class="auth-overlay" id="authOverlay">
<div class="auth-panel">
<p class="eyebrow">CarPass</p>
<h1>Запись в СТО</h1>
<p>Откройте страницу через Telegram, чтобы выбрать автомобиль и свободное окно.</p>
<div class="auth-actions">
<a id="telegramLoginLink" class="telegram-login-link hidden" href="#" rel="noreferrer">Открыть в Telegram</a>
<button id="telegramRetryBtn" class="telegram-secondary-btn" type="button">Проверить вход</button>
</div>
</div>
</div>
<main class="shell flow-shell">
<header class="topbar">
<div>
<p class="eyebrow">СТО</p>
<h1>Создать запись</h1>
</div>
<a class="ghost-btn" href="/">Меню</a>
</header>
<section class="flow-hero">
<div>
<p class="eyebrow">Заявка</p>
<h2 id="bookingTitle">Выберите сервис</h2>
<small id="bookingHint">После отправки СТО подтвердит время или предложит другое окно.</small>
</div>
<div class="flow-steps">
<span class="active">1</span>
<span class="active">2</span>
<span>3</span>
</div>
</section>
<section class="flow-layout">
<aside class="workspace flow-side">
<div class="section-head">
<div>
<p class="eyebrow">Каталог</p>
<h2>Сервисы</h2>
</div>
</div>
<form id="filterForm" class="grid-form drawer-form compact-form">
<label>
Город
<input name="city" placeholder="Seoul" />
</label>
<label>
Специализация
<input name="specialization" placeholder="BMW, масло, тормоза" />
</label>
<button type="submit">Найти</button>
</form>
<div id="serviceList" class="stack-list"></div>
</aside>
<section class="workspace">
<form id="bookingForm" class="grid-form drawer-form flow-form">
<div class="form-block wide">
<p class="eyebrow">1. Авто и услуга</p>
<h3>Что нужно сделать</h3>
<small>Если в карточке авто есть жидкости и нормы, СТО увидит их в заказ-наряде.</small>
</div>
<label>
Автомобиль
<select name="vehicle_id" id="vehicleSelect" required></select>
</label>
<label>
Услуга
<select name="service_type" id="serviceTypeSelect">
<option value="oil_change">Замена масла</option>
<option value="diagnostics">Диагностика</option>
<option value="maintenance">ТО</option>
<option value="tire_service">Шиномонтаж</option>
<option value="brakes">Тормоза</option>
<option value="repair">Ремонт</option>
<option value="other">Другое</option>
</select>
</label>
<label>
Длительность
<select name="estimated_duration_minutes" id="durationSelect">
<option value="60">1 час</option>
<option value="90">1.5 часа</option>
<option value="120">2 часа</option>
<option value="180">3 часа</option>
</select>
</label>
<label>
Дата
<input name="date" id="bookingDateInput" type="date" />
</label>
<div class="form-block wide">
<p class="eyebrow">2. Время</p>
<h3>Свободное окно</h3>
<small id="slotHint">Выберите СТО слева, затем дату и услугу.</small>
</div>
<label class="wide">
Окно записи
<select name="slot" id="slotSelect" required></select>
</label>
<label class="wide">
Комментарий
<textarea name="customer_comment" placeholder="Например: стук спереди справа, масло свое, нужен осмотр подвески"></textarea>
</label>
<div class="row-actions wide">
<button id="createBookingBtn" type="submit">Отправить заявку</button>
<a class="ghost-btn" href="/car_profile.html">Проверить карточку авто</a>
</div>
</form>
</section>
</section>
</main>
<div class="toast hidden" id="toast" role="status" aria-live="polite"></div>
<script src="/static/page_common.js"></script>
<script src="/static/book_sto.js"></script>
</body>
</html>

211
web/car_profile.html Normal file
View File

@@ -0,0 +1,211 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#16806a" />
<title>Паспорт автомобиля</title>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="stylesheet" href="/static/styles.css" />
<script src="https://telegram.org/js/telegram-web-app.js"></script>
</head>
<body class="auth-required flow-page">
<div class="auth-overlay" id="authOverlay">
<div class="auth-panel">
<p class="eyebrow">CarPass</p>
<h1>Паспорт автомобиля</h1>
<p>Откройте страницу через Telegram, чтобы безопасно редактировать данные авто.</p>
<div class="auth-actions">
<a id="telegramLoginLink" class="telegram-login-link hidden" href="#" rel="noreferrer">Открыть в Telegram</a>
<button id="telegramRetryBtn" class="telegram-secondary-btn" type="button">Проверить вход</button>
</div>
</div>
</div>
<main class="shell flow-shell">
<header class="topbar">
<div>
<p class="eyebrow">Автомобиль</p>
<h1>Паспорт автомобиля</h1>
</div>
<div class="top-actions">
<a class="ghost-btn" href="/">Гараж</a>
<button class="icon-btn" id="newVehicleBtn" title="Новое авто" aria-label="Новое авто">+</button>
</div>
</header>
<section class="flow-hero">
<div>
<p class="eyebrow">Карточка авто</p>
<h2 id="pageTitle">Выберите автомобиль</h2>
<small id="pageHint">Данные отсюда используются в заказ-нарядах, подборе жидкостей и рекомендациях ТО.</small>
</div>
<div class="flow-steps">
<span class="active">1</span>
<span>2</span>
<span>3</span>
</div>
</section>
<section class="flow-layout">
<aside class="workspace flow-side">
<div class="section-head">
<div>
<p class="eyebrow">Гараж</p>
<h2>Автомобили</h2>
</div>
</div>
<div id="vehicleList" class="stack-list"></div>
</aside>
<section class="workspace">
<form id="vehicleProfileForm" class="grid-form drawer-form flow-form">
<div class="form-block wide">
<p class="eyebrow">1. Основа</p>
<h3>Идентификация</h3>
<small>VIN и госномер помогают СТО точно связать заказ-наряд с автомобилем.</small>
</div>
<label>
Название
<input name="name" placeholder="Kia K5" required />
</label>
<label>
Марка
<select name="make" id="makeSelect"></select>
</label>
<label>
Модель
<select name="model" id="modelSelect"></select>
</label>
<label>
Комплектация
<select name="trim" id="trimSelect"></select>
</label>
<label>
Год
<input name="year" type="number" min="1900" max="2100" />
</label>
<label>
Госномер
<input name="plate_number" placeholder="12가3456" />
</label>
<label>
VIN
<input name="vin" maxlength="17" placeholder="17 символов без I/O/Q" />
</label>
<label>
Текущий пробег
<input name="current_odometer" type="number" min="0" />
</label>
<div class="form-block wide">
<p class="eyebrow">2. Техника</p>
<h3>Жидкости и нормы</h3>
<small>Если СТО добавит материалы в заказ-наряд, пустые поля здесь будут дополняться автоматически.</small>
</div>
<label>
Тип топлива
<select name="fuel_type">
<option value="">Не задано</option>
<option value="gasoline">Бензин</option>
<option value="diesel">Дизель</option>
<option value="hybrid">Гибрид</option>
<option value="electric">Электро</option>
</select>
</label>
<label>
Объем двигателя, л
<input name="engine_volume_l" type="number" min="0" step="0.01" />
</label>
<label>
Коробка
<select name="transmission">
<option value="">Не задано</option>
<option value="manual">Механика</option>
<option value="automatic">Автомат</option>
<option value="cvt">CVT</option>
<option value="dct">DCT</option>
</select>
</label>
<label>
Привод
<select name="drive_type">
<option value="">Не задано</option>
<option value="fwd">Передний</option>
<option value="rwd">Задний</option>
<option value="awd">Полный</option>
</select>
</label>
<label>
Моторное масло
<input name="engine_oil_type" placeholder="5W-30 API SP" />
</label>
<label>
Объем масла, л
<input name="engine_oil_volume_l" type="number" min="0" step="0.01" />
</label>
<label>
Трансмиссионная жидкость
<input name="transmission_fluid_type" placeholder="ATF WS / DCTF / CVT" />
</label>
<label>
Объем трансмиссии, л
<input name="transmission_fluid_volume_l" type="number" min="0" step="0.01" />
</label>
<label>
Антифриз
<input name="coolant_type" placeholder="LLC / G48" />
</label>
<label>
Тормозная жидкость
<input name="brake_fluid_type" placeholder="DOT 4 LV" />
</label>
<label>
Размер шин
<input name="tire_size" placeholder="205/55 R16" />
</label>
<label>
Интервал масла, км
<input name="oil_change_interval_km" type="number" min="0" />
</label>
<div class="form-block wide">
<p class="eyebrow">3. Владение</p>
<h3>Расходы и заметки</h3>
<small>Эти данные влияют на стоимость владения и прогнозы.</small>
</div>
<label>
Стоимость покупки
<input name="purchase_price" type="number" min="0" step="0.01" />
</label>
<label>
Дата покупки
<input name="purchase_date" type="date" />
</label>
<label>
Тип покупки
<select name="purchase_type">
<option value="unknown">Не указано</option>
<option value="cash">Наличные</option>
<option value="credit">Кредит</option>
<option value="lease">Лизинг</option>
</select>
</label>
<label>
Заметки
<input name="notes" placeholder="Особенности авто" />
</label>
<div class="row-actions wide">
<button type="submit" id="saveVehicleBtn">Сохранить паспорт</button>
<button type="button" class="danger-btn hidden" id="deleteVehicleBtn">Удалить автомобиль</button>
</div>
</form>
</section>
</section>
</main>
<div class="toast hidden" id="toast" role="status" aria-live="polite"></div>
<script src="/static/page_common.js"></script>
<script src="/static/car_profile.js"></script>
</body>
</html>

81
web/data_exchange.html Normal file
View File

@@ -0,0 +1,81 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#16806a" />
<title>Импорт и экспорт</title>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="stylesheet" href="/static/styles.css" />
<script src="https://telegram.org/js/telegram-web-app.js"></script>
</head>
<body class="auth-required flow-page">
<div class="auth-overlay" id="authOverlay">
<div class="auth-panel">
<p class="eyebrow">CarPass</p>
<h1>Импорт и экспорт</h1>
<p>Откройте страницу через Telegram, чтобы выгрузить или перенести данные гаража.</p>
<div class="auth-actions">
<a id="telegramLoginLink" class="telegram-login-link hidden" href="#" rel="noreferrer">Открыть в Telegram</a>
<button id="telegramRetryBtn" class="telegram-secondary-btn" type="button">Проверить вход</button>
</div>
</div>
</div>
<main class="shell flow-shell">
<header class="topbar">
<div>
<p class="eyebrow">Данные</p>
<h1>Импорт и экспорт</h1>
</div>
<a class="ghost-btn" href="/">Меню</a>
</header>
<section class="flow-hero">
<div>
<p class="eyebrow">Обмен данными</p>
<h2>CarPass exchange v1</h2>
<small>Экспорт включает автомобили, расходы, записи, заказ-наряды как архив и собственные СТО.</small>
</div>
<span class="trust-badge">JSON</span>
</section>
<section class="flow-layout">
<section class="workspace">
<div class="form-block">
<p class="eyebrow">Экспорт</p>
<h3>Выгрузить мои данные</h3>
<small>Файл подходит для резервной копии или переноса в другой аккаунт. Активные заказ-наряды не создаются при импорте.</small>
</div>
<div class="row-actions">
<button id="exportBtn" type="button">Скачать JSON</button>
</div>
<div id="exportSummary" class="stack-list"></div>
</section>
<section class="workspace">
<div class="form-block">
<p class="eyebrow">Импорт</p>
<h3>Загрузить файл</h3>
<small>Импорт создает недостающие автомобили и записи, а совпавшие карточки дополняет только пустыми полями.</small>
</div>
<form id="importForm" class="grid-form drawer-form flow-form">
<label class="wide">
Файл JSON
<input id="importFile" name="file" type="file" accept="application/json,.json" required />
</label>
<div class="row-actions wide">
<button id="previewBtn" type="button">Проверить файл</button>
<button id="importBtn" type="submit">Импортировать</button>
</div>
</form>
<div id="importSummary" class="stack-list"></div>
</section>
</section>
</main>
<div class="toast hidden" id="toast" role="status" aria-live="polite"></div>
<script src="/static/page_common.js"></script>
<script src="/static/data_exchange.js"></script>
</body>
</html>

View File

@@ -257,8 +257,8 @@
<details class="menu-group" open>
<summary>Автомобиль</summary>
<button class="menu-row" data-menu-section="carsSection">Мои автомобили</button>
<button class="menu-row" data-menu-section="carFormSection">Добавить авто</button>
<button class="menu-row" data-menu-section="carProfileSection">Параметры авто</button>
<button class="menu-row" data-page-link="/car_profile.html?action=new">Добавить авто</button>
<button class="menu-row" data-page-link="/car_profile.html">Паспорт и параметры авто</button>
<button class="menu-row" data-menu-section="maintenanceRecommendationsSection">Рекомендации ТО</button>
<button class="menu-row" data-menu-section="confirmationsSection">Подтверждения</button>
<button class="menu-row" data-menu-section="connectedServicesSection">Подключенные СТО</button>
@@ -277,17 +277,20 @@
<details class="menu-group" open>
<summary>СТО</summary>
<button class="menu-row" data-menu-section="publicServicesSection">Каталог СТО</button>
<button class="menu-row" data-page-link="/book_sto.html">Записаться в СТО</button>
<button class="menu-row" data-menu-section="appointmentsSection">Мои записи</button>
<button class="menu-row" data-menu-section="reviewsSection">Отзывы</button>
<button class="menu-row sto-workplace-only hidden" data-open-sto-page>Панель СТО</button>
<button class="menu-row sto-calendar-only hidden" data-page-link="/sto_settings.html">Настройки СТО</button>
<button class="menu-row sto-calendar-only hidden" data-menu-section="stoCalendarSection">Календарь СТО</button>
<button class="menu-row service-owner-only hidden" data-menu-section="servicePanelSection">Мое СТО</button>
<button class="menu-row service-owner-only hidden" data-page-link="/service_profile.html">Профиль СТО</button>
</details>
<details class="menu-group">
<summary>Аккаунт</summary>
<button class="menu-row" data-menu-section="settingsSection">Настройки</button>
<button class="menu-row" data-menu-section="notificationsSection">Уведомления</button>
<button class="menu-row" data-page-link="/data_exchange.html">Импорт / экспорт</button>
<button class="menu-row admin-only hidden" data-menu-section="adminSection">Админ</button>
</details>
</div>
@@ -428,7 +431,7 @@
<section class="drawer-section hidden" id="publicServicesSection">
<h2>СТО</h2>
<div class="tip-card">Обычный профиль не показывает панель СТО. Для бизнеса отправьте заявку на проверку.</div>
<button class="wide-btn service-owner-only hidden" type="button" data-menu-section="servicePanelSection">Открыть профиль СТО</button>
<button class="wide-btn service-owner-only hidden" type="button" data-page-link="/service_profile.html">Открыть профиль СТО</button>
<div id="publicServiceCenters" class="stack-list"></div>
<div id="serviceCard" class="service-card hidden"></div>
</section>

139
web/service_profile.html Normal file
View File

@@ -0,0 +1,139 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#16806a" />
<title>Профиль СТО</title>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="stylesheet" href="/static/styles.css" />
<script src="https://telegram.org/js/telegram-web-app.js"></script>
</head>
<body class="auth-required flow-page">
<div class="auth-overlay" id="authOverlay">
<div class="auth-panel">
<p class="eyebrow">CarPass Business</p>
<h1>Профиль СТО</h1>
<p>Откройте страницу через Telegram, чтобы безопасно отправить или обновить заявку СТО.</p>
<div class="auth-actions">
<a id="telegramLoginLink" class="telegram-login-link hidden" href="#" rel="noreferrer">Открыть в Telegram</a>
<button id="telegramRetryBtn" class="telegram-secondary-btn" type="button">Проверить вход</button>
</div>
</div>
</div>
<main class="shell flow-shell">
<header class="topbar">
<div>
<p class="eyebrow">СТО</p>
<h1>Профиль сервиса</h1>
</div>
<div class="top-actions">
<a class="ghost-btn" href="/">Меню</a>
<button class="icon-btn" id="newCenterBtn" title="Новая заявка" aria-label="Новая заявка">+</button>
</div>
</header>
<section class="flow-hero">
<div>
<p class="eyebrow">Регистрация</p>
<h2 id="centerTitle">Заявка СТО</h2>
<small id="centerHint">Карточка используется в каталоге, записи клиентов и уведомлениях по заказ-нарядам.</small>
</div>
<span class="trust-badge" id="centerStatus">Черновик</span>
</section>
<section class="flow-layout">
<aside class="workspace flow-side">
<div class="section-head">
<div>
<p class="eyebrow">Доступные СТО</p>
<h2>Мои сервисы</h2>
</div>
</div>
<div id="centersList" class="stack-list"></div>
</aside>
<section class="workspace">
<form id="serviceProfileForm" class="grid-form drawer-form flow-form">
<div class="form-block wide">
<p class="eyebrow">1. Карточка</p>
<h3>Название и контакты</h3>
<small>Клиент видит понятное название, город, адрес и телефон для связи.</small>
</div>
<label>
Название СТО
<input name="display_name" placeholder="Smart Service" required />
</label>
<label>
Юридическое название
<input name="legal_name" placeholder="ООО Smart Service" />
</label>
<label>
Страна
<input name="country" maxlength="2" placeholder="KR" />
</label>
<label>
Город
<input name="city" placeholder="Seoul" />
</label>
<label>
Адрес
<input name="address" placeholder="Gangnam-daero 12" />
</label>
<label>
Телефон
<input name="phone" placeholder="+82..." />
</label>
<label>
Контактное лицо
<input name="contact_person" placeholder="Имя администратора" />
</label>
<label>
График работы
<input name="working_hours" placeholder="Пн-Сб 09:00-19:00" />
</label>
<div class="form-block wide">
<p class="eyebrow">2. Проверка</p>
<h3>Документы и специализация</h3>
<small>После отправки модератор проверит документы. Подтвержденная СТО получает запись клиентов и рабочее место.</small>
</div>
<label class="wide">
Специализация
<input name="specializations" placeholder="Hyundai, Kia, BMW, электрика, шиномонтаж" />
</label>
<label class="wide">
Описание
<textarea name="description" placeholder="Коротко о сервисе, оборудовании и сильных сторонах"></textarea>
</label>
<label>
Регистрационный номер
<input name="business_registration_number" />
</label>
<label>
Фото фасада, URL
<input name="facade_photo_url" placeholder="https://..." />
</label>
<label class="wide">
Фото документов, URL через запятую
<input name="document_photo_urls" placeholder="https://..., https://..." />
</label>
<label class="wide">
Дополнительные фото, URL через запятую
<input name="additional_photo_urls" placeholder="https://..., https://..." />
</label>
<div class="row-actions wide">
<button id="saveCenterBtn" type="submit">Отправить заявку</button>
<a class="ghost-btn hidden" id="openSettingsLink" href="/sto_settings.html">Настройки СТО</a>
</div>
</form>
</section>
</section>
</main>
<div class="toast hidden" id="toast" role="status" aria-live="polite"></div>
<script src="/static/page_common.js"></script>
<script src="/static/service_profile.js"></script>
</body>
</html>

View File

@@ -1203,33 +1203,10 @@ async function openServiceCard(serviceCenterId) {
<div class="service-actions">
<button type="button" class="ghost-btn" id="attachServiceBtn">Привязать выбранное авто</button>
</div>
<form class="grid-form drawer-form" id="serviceBookingForm">
<label>
Услуга
<select name="service_type">
<option value="oil_change">Замена масла</option>
<option value="diagnostics">Диагностика</option>
<option value="maintenance">ТО</option>
<option value="tire_service">Шиномонтаж</option>
<option value="brakes">Тормоза</option>
<option value="repair">Ремонт</option>
<option value="other">Другое</option>
</select>
</label>
<label>
Дата
<input name="date" type="date" value="${today()}" />
</label>
<label>
Свободное окно
<select name="slot" id="bookingSlotSelect"></select>
</label>
<label>
Комментарий
<input name="customer_comment" placeholder="Что нужно сделать" />
</label>
<button type="submit">Записаться</button>
</form>
<div class="tip-card">
Запись вынесена на отдельную страницу: там можно выбрать автомобиль, услугу, дату и свободное окно без тесного меню.
<button type="button" class="wide-btn" data-page-link="/book_sto.html?service_center_id=${center.id}">Записаться в это СТО</button>
</div>
<form class="grid-form drawer-form" id="serviceReviewForm">
<label>
Оценка
@@ -1288,55 +1265,9 @@ async function openServiceCard(serviceCenterId) {
haptic("success");
});
});
const bookingForm = card.querySelector("#serviceBookingForm");
const reloadSlots = () => loadServiceBookingSlots(serviceCenterId, bookingForm);
bookingForm.querySelector('[name="service_type"]').addEventListener("change", reloadSlots);
bookingForm.querySelector('[name="date"]').addEventListener("change", reloadSlots);
bookingForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (!state.selectedCarId) {
toast("Выбери автомобиль", "error");
return;
}
const data = formData(bookingForm);
if (!data.slot) {
toast("Выбери свободное окно", "error");
return;
}
await runAction(bookingForm.querySelector('button[type="submit"]'), "Создаю запись...", async () => {
await api("/appointments", {
method: "POST",
body: JSON.stringify({
service_center_id: serviceCenterId,
vehicle_id: state.selectedCarId,
service_type: data.service_type,
service_name: bookingServiceName(data.service_type),
requested_start_at: data.slot,
customer_comment: data.customer_comment || null,
}),
});
await loadAppointments();
toast("Заявка отправлена в СТО");
haptic("success");
});
});
await reloadSlots();
card.scrollIntoView({ behavior: "smooth", block: "start" });
}
function bookingServiceName(type) {
const names = {
oil_change: "Замена масла",
diagnostics: "Диагностика",
maintenance: "ТО",
tire_service: "Шиномонтаж",
brakes: "Тормоза",
repair: "Ремонт",
other: "Другое",
};
return names[type] || "Обслуживание";
}
function formatDateTime(value) {
if (!value) return "-";
const date = new Date(value);
@@ -1344,21 +1275,6 @@ function formatDateTime(value) {
return date.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
}
async function loadServiceBookingSlots(serviceCenterId, form) {
const select = form.querySelector("#bookingSlotSelect");
const serviceType = form.querySelector('[name="service_type"]').value;
const date = form.querySelector('[name="date"]').value || today();
select.innerHTML = `<option value="">Загружаю...</option>`;
try {
const slots = await api(`/sto/${serviceCenterId}/available-slots?service_type=${encodeURIComponent(serviceType)}&date_from=${date}&date_to=${date}`);
select.innerHTML = slots.length
? slots.map((slot) => `<option value="${slot.start_at}">${formatDateTime(slot.start_at)}</option>`).join("")
: `<option value="">Нет свободных окон</option>`;
} catch (error) {
select.innerHTML = `<option value="">Слоты не загрузились</option>`;
}
}
async function loadAppointments() {
const root = document.querySelector("#appointmentsList");
if (!root) return;
@@ -2348,8 +2264,16 @@ async function applyInitialRoute() {
await loadSelectedCar();
}
if (section === "carProfile") {
await openDrawerSection("carProfileSection");
window.history.replaceState({}, "", window.location.pathname);
const target = carId ? `/car_profile.html?car_id=${carId}` : "/car_profile.html";
window.location.replace(target);
return;
}
if (section) {
const sectionId = `${section}Section`;
if (document.getElementById(sectionId)) {
await openDrawerSection(sectionId);
window.history.replaceState({}, "", window.location.pathname);
}
}
}
@@ -2789,6 +2713,13 @@ document.querySelectorAll("[data-menu-section]").forEach((button) => {
});
});
document.addEventListener("click", (event) => {
const link = event.target.closest("[data-page-link]");
if (!link) return;
event.preventDefault();
window.location.href = link.dataset.pageLink;
});
document.querySelectorAll("[data-open-sto-page]").forEach((button) => {
button.addEventListener("click", () => {
if (!stoWorkplaceCenters().length) {

150
web/static/book_sto.js Normal file
View File

@@ -0,0 +1,150 @@
const page = CarPassPage;
const params = new URLSearchParams(window.location.search);
const state = {
centers: [],
vehicles: [],
selectedCenterId: Number(params.get("service_center_id") || 0) || null,
};
const SERVICE_NAMES = {
oil_change: "Замена масла",
diagnostics: "Диагностика",
maintenance: "Техническое обслуживание",
tire_service: "Шиномонтаж",
brakes: "Тормозная система",
repair: "Ремонт",
other: "Другое",
};
function selectedCenter() {
return state.centers.find((item) => item.id === state.selectedCenterId) || null;
}
function renderCenters() {
const root = document.querySelector("#serviceList");
root.innerHTML = state.centers.length
? state.centers.map((center) => `
<button type="button" class="service-list-card ${center.id === state.selectedCenterId ? "active" : ""}" data-center="${center.id}">
<strong>${page.escapeHtml(center.display_name || center.name)}</strong>
<small>${page.escapeHtml([center.city, center.address].filter(Boolean).join(", ") || "Адрес уточняется")}</small>
<small>${center.nearest_slot_at ? `Ближайшее окно: ${page.formatDateTime(center.nearest_slot_at)}` : "Онлайн-запись по графику СТО"}</small>
</button>
`).join("")
: `<div class="empty">Подходящих СТО не найдено.</div>`;
root.querySelectorAll("[data-center]").forEach((button) => {
button.addEventListener("click", async () => {
state.selectedCenterId = Number(button.dataset.center);
renderCenters();
renderBookingHead();
await loadSlots();
});
});
}
function renderBookingHead() {
const center = selectedCenter();
document.querySelector("#bookingTitle").textContent = center ? (center.display_name || center.name) : "Выберите сервис";
document.querySelector("#bookingHint").textContent = center
? [center.city, center.address, center.working_hours].filter(Boolean).join(" · ") || "Выберите удобное время записи."
: "Выберите СТО слева, потом автомобиль, услугу и свободное окно.";
}
function renderVehicles() {
const select = document.querySelector("#vehicleSelect");
select.innerHTML = state.vehicles.length
? state.vehicles.map((car) => `<option value="${car.id}">${page.escapeHtml([car.name, car.make, car.model, car.license_plate_display].filter(Boolean).join(" · "))}</option>`).join("")
: `<option value="">Сначала добавьте автомобиль</option>`;
select.disabled = !state.vehicles.length;
}
async function loadCenters(filters = {}) {
const query = new URLSearchParams();
query.set("has_slots", "true");
if (filters.city) query.set("city", filters.city);
if (filters.specialization) query.set("specialization", filters.specialization);
state.centers = await page.api(`/sto/catalog?${query.toString()}`);
if (state.selectedCenterId && !state.centers.some((item) => item.id === state.selectedCenterId)) {
state.centers = [await page.api(`/service-centers/${state.selectedCenterId}`).catch(() => null), ...state.centers].filter(Boolean);
}
if (!state.selectedCenterId && state.centers.length) state.selectedCenterId = state.centers[0].id;
renderCenters();
renderBookingHead();
}
async function loadVehicles() {
state.vehicles = await page.api("/my/vehicles");
renderVehicles();
}
async function loadSlots() {
const center = selectedCenter();
const select = document.querySelector("#slotSelect");
if (!center) {
select.innerHTML = `<option value="">Выберите СТО</option>`;
select.disabled = true;
return;
}
const form = document.querySelector("#bookingForm");
const data = page.formData(form);
const date = data.date || page.today();
const serviceType = data.service_type || "maintenance";
const duration = data.estimated_duration_minutes || "60";
document.querySelector("#slotHint").textContent = "Проверяю свободные окна...";
const slots = await page.api(`/sto/${center.id}/available-slots?service_type=${encodeURIComponent(serviceType)}&date_from=${date}&date_to=${date}&duration_minutes=${duration}`);
select.disabled = !slots.length;
select.innerHTML = slots.length
? slots.map((slot) => `<option value="${slot.start_at}">${page.formatDateTime(slot.start_at)} - ${page.formatDateTime(slot.end_at).slice(-5)}</option>`).join("")
: `<option value="">На эту дату окон нет</option>`;
document.querySelector("#slotHint").textContent = slots.length ? "Выберите удобное окно." : "Попробуйте другую дату или длительность.";
}
document.querySelector("#filterForm").addEventListener("submit", async (event) => {
event.preventDefault();
await page.runAction(event.currentTarget.querySelector("button"), "Ищу СТО...", async () => {
await loadCenters(page.formData(event.currentTarget));
await loadSlots();
});
});
["#serviceTypeSelect", "#durationSelect", "#bookingDateInput"].forEach((selector) => {
document.querySelector(selector).addEventListener("change", () => {
loadSlots().catch((error) => page.toast(error.message || "Не удалось обновить окна", "error"));
});
});
document.querySelector("#bookingForm").addEventListener("submit", async (event) => {
event.preventDefault();
const center = selectedCenter();
const data = page.formData(event.currentTarget);
if (!center) {
page.toast("Выберите СТО", "error");
return;
}
if (!data.vehicle_id) {
page.toast("Добавьте автомобиль перед записью", "error");
return;
}
await page.runAction(document.querySelector("#createBookingBtn"), "Отправляю заявку...", async () => {
await page.api("/appointments", {
method: "POST",
body: JSON.stringify({
service_center_id: center.id,
vehicle_id: Number(data.vehicle_id),
service_type: data.service_type,
service_name: SERVICE_NAMES[data.service_type] || "Обслуживание",
requested_start_at: data.slot,
estimated_duration_minutes: Number(data.estimated_duration_minutes || 60),
customer_comment: data.customer_comment || null,
}),
});
page.toast("Заявка отправлена в СТО");
window.setTimeout(() => { window.location.href = "/?section=appointments"; }, 700);
});
});
page.boot(async () => {
document.querySelector("#bookingDateInput").value = page.today();
await Promise.all([loadCenters(), loadVehicles()]);
await loadSlots();
});

215
web/static/car_profile.js Normal file
View File

@@ -0,0 +1,215 @@
const page = CarPassPage;
const state = {
cars: [],
catalog: [],
selectedCarId: null,
};
const params = new URLSearchParams(window.location.search);
function selectedCar() {
return state.cars.find((item) => item.id === state.selectedCarId) || null;
}
function ensureOption(select, value) {
if (!value) return;
if (![...select.options].some((option) => option.value === value)) {
select.insertAdjacentHTML("beforeend", `<option value="${page.escapeHtml(value)}">${page.escapeHtml(value)}</option>`);
}
}
function selectedModel() {
const makeName = document.querySelector("#makeSelect").value;
const modelName = document.querySelector("#modelSelect").value;
const make = state.catalog.find((item) => item.name === makeName);
return make?.models?.find((item) => item.name === modelName) || null;
}
function syncModels(modelValue = "", trimValue = "") {
const makeName = document.querySelector("#makeSelect").value;
const modelSelect = document.querySelector("#modelSelect");
const models = state.catalog.find((item) => item.name === makeName)?.models || [];
modelSelect.disabled = !models.length;
modelSelect.innerHTML = models.length
? `<option value="">Модель</option>` + models.map((model) => `<option value="${page.escapeHtml(model.name)}">${page.escapeHtml(model.name)}</option>`).join("")
: `<option value="">Сначала марка</option>`;
ensureOption(modelSelect, modelValue);
modelSelect.value = modelValue || "";
syncTrims(trimValue);
}
function syncTrims(trimValue = "") {
const trimSelect = document.querySelector("#trimSelect");
const trims = selectedModel()?.trims || [];
trimSelect.disabled = !trims.length;
trimSelect.innerHTML = trims.length
? `<option value="">Комплектация</option>` + trims.map((trim) => `<option value="${page.escapeHtml(trim.name)}">${page.escapeHtml(trim.name)}</option>`).join("")
: `<option value="">Сначала модель</option>`;
ensureOption(trimSelect, trimValue);
trimSelect.value = trimValue || "";
const trim = trims.find((item) => item.name === trimSelect.value);
const fuel = document.querySelector('[name="fuel_type"]');
if (trim?.fuel_type && !fuel.value) fuel.value = trim.fuel_type;
if (trim?.body_type && !document.querySelector('[name="body_type"]')?.value) {
const bodyInput = document.querySelector('[name="body_type"]');
if (bodyInput) bodyInput.value = trim.body_type;
}
}
async function loadCatalog() {
state.catalog = await page.api("/catalog/makes");
const makeSelect = document.querySelector("#makeSelect");
const makes = [...state.catalog].sort((a, b) => a.name.localeCompare(b.name, "ru"));
makeSelect.innerHTML = `<option value="">Марка</option>` + makes
.map((make) => `<option value="${page.escapeHtml(make.name)}">${page.escapeHtml(make.name)}</option>`)
.join("");
makeSelect.addEventListener("change", () => syncModels());
document.querySelector("#modelSelect").addEventListener("change", () => syncTrims());
document.querySelector("#trimSelect").addEventListener("change", () => syncTrims(document.querySelector("#trimSelect").value));
}
async function loadCars() {
state.cars = await page.api(`/cars?owner_id=${page.state.user.id}`);
const routeCarId = Number(params.get("car_id") || 0);
if (params.get("action") === "new") state.selectedCarId = null;
else if (routeCarId && state.cars.some((item) => item.id === routeCarId)) state.selectedCarId = routeCarId;
else if (!state.selectedCarId && state.cars.length) state.selectedCarId = state.cars[0].id;
renderVehicles();
fillForm();
}
function renderVehicles() {
const root = document.querySelector("#vehicleList");
root.innerHTML = state.cars.length
? state.cars.map((car) => `
<button type="button" class="service-list-card ${car.id === state.selectedCarId ? "active" : ""}" data-vehicle="${car.id}">
<strong>${page.escapeHtml(car.name)}</strong>
<small>${page.escapeHtml([car.make, car.model, car.year, car.license_plate_display].filter(Boolean).join(" · ") || "Паспорт без деталей")}</small>
</button>
`).join("")
: `<div class="empty">Автомобилей пока нет. Заполните форму справа.</div>`;
root.querySelectorAll("[data-vehicle]").forEach((button) => {
button.addEventListener("click", () => {
state.selectedCarId = Number(button.dataset.vehicle);
renderVehicles();
fillForm();
});
});
}
function setValue(form, name, value) {
const input = form.elements[name];
if (!input) return;
input.value = value ?? "";
}
function fillForm() {
const form = document.querySelector("#vehicleProfileForm");
const car = selectedCar();
form.reset();
document.querySelector("#deleteVehicleBtn").classList.toggle("hidden", !car);
document.querySelector("#pageTitle").textContent = car ? car.name : "Новый автомобиль";
document.querySelector("#pageHint").textContent = car
? [car.make, car.model, car.year, car.license_plate_display].filter(Boolean).join(" · ") || "Заполните недостающие данные паспорта."
: "Создайте карточку, а потом дополняйте ее по мере обслуживания.";
if (!car) {
syncModels();
return;
}
[
"name",
"year",
"plate_number",
"vin",
"current_odometer",
"fuel_type",
"engine_volume_l",
"transmission",
"drive_type",
"engine_oil_type",
"engine_oil_volume_l",
"transmission_fluid_type",
"transmission_fluid_volume_l",
"coolant_type",
"brake_fluid_type",
"tire_size",
"oil_change_interval_km",
"purchase_price",
"purchase_date",
"purchase_type",
"notes",
].forEach((name) => setValue(form, name, car[name]));
ensureOption(form.elements.make, car.make);
form.elements.make.value = car.make || "";
syncModels(car.model || "", car.trim || "");
}
function payloadFromForm(form) {
const data = page.formData(form);
return {
name: data.name,
make: data.make || null,
model: data.model || null,
trim: data.trim || null,
year: page.numberOrNull(data.year),
plate_number: data.plate_number || null,
vin: data.vin || null,
current_odometer: page.numberOrNull(data.current_odometer),
fuel_type: data.fuel_type || null,
engine_volume_l: page.numberOrNull(data.engine_volume_l),
transmission: data.transmission || null,
drive_type: data.drive_type || null,
engine_oil_type: data.engine_oil_type || null,
engine_oil_volume_l: page.numberOrNull(data.engine_oil_volume_l),
transmission_fluid_type: data.transmission_fluid_type || null,
transmission_fluid_volume_l: page.numberOrNull(data.transmission_fluid_volume_l),
coolant_type: data.coolant_type || null,
brake_fluid_type: data.brake_fluid_type || null,
tire_size: data.tire_size || null,
oil_change_interval_km: page.numberOrNull(data.oil_change_interval_km),
purchase_price: page.numberOrNull(data.purchase_price),
purchase_date: data.purchase_date || null,
purchase_type: data.purchase_type || "unknown",
purchase_currency: page.state.user?.currency || "RUB",
currency: page.state.user?.currency || "RUB",
notes: data.notes || null,
};
}
document.querySelector("#newVehicleBtn").addEventListener("click", () => {
state.selectedCarId = null;
renderVehicles();
fillForm();
});
document.querySelector("#vehicleProfileForm").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const car = selectedCar();
await page.runAction(document.querySelector("#saveVehicleBtn"), "Сохраняю паспорт...", async () => {
const saved = await page.api(car ? `/cars/${car.id}` : "/cars", {
method: car ? "PATCH" : "POST",
body: JSON.stringify(payloadFromForm(form)),
});
state.selectedCarId = saved.id;
await loadCars();
page.toast("Паспорт сохранен");
});
});
document.querySelector("#deleteVehicleBtn").addEventListener("click", async (event) => {
const car = selectedCar();
if (!car || !window.confirm(`Удалить автомобиль «${car.name}» и все его записи?`)) return;
await page.runAction(event.currentTarget, "Удаляю автомобиль...", async () => {
await page.api(`/cars/${car.id}`, { method: "DELETE" });
state.selectedCarId = null;
await loadCars();
page.toast("Автомобиль удален");
});
});
page.boot(async () => {
await loadCatalog();
await loadCars();
});

View File

@@ -0,0 +1,96 @@
const page = CarPassPage;
let selectedPayload = null;
function summaryItems(counts = {}) {
const labels = {
vehicles: "Автомобили",
fuel_entries: "Заправки",
service_entries: "ТО и ремонт",
expense_entries: "Расходы",
appointments: "Записи в СТО",
service_visits: "Заказ-наряды",
};
return Object.entries(labels).map(([key, label]) => `
<div class="stack-item">
<strong>${label}</strong>
<small>${page.escapeHtml(counts[key] ?? 0)}</small>
</div>
`).join("");
}
function countExport(payload) {
const vehicles = payload.vehicles || [];
return {
vehicles: vehicles.length,
fuel_entries: vehicles.reduce((sum, item) => sum + (item.fuel_entries || []).length, 0),
service_entries: vehicles.reduce((sum, item) => sum + (item.service_entries || []).length, 0),
expense_entries: vehicles.reduce((sum, item) => sum + (item.expense_entries || []).length, 0),
appointments: vehicles.reduce((sum, item) => sum + (item.appointments || []).length, 0),
service_visits: vehicles.reduce((sum, item) => sum + (item.service_visits || []).length, 0),
};
}
function renderPreview(preview) {
document.querySelector("#importSummary").innerHTML = `
${summaryItems(preview.counts)}
${preview.warnings?.length ? `<div class="tip-card warning">${preview.warnings.map(page.escapeHtml).join("<br />")}</div>` : ""}
`;
}
async function readSelectedFile() {
const file = document.querySelector("#importFile").files[0];
if (!file) throw new Error("Выберите JSON-файл");
selectedPayload = JSON.parse(await file.text());
return selectedPayload;
}
document.querySelector("#exportBtn").addEventListener("click", async (event) => {
await page.runAction(event.currentTarget, "Готовлю файл...", async () => {
const payload = await page.api("/my/export");
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
link.href = url;
link.download = `carpass-export-${stamp}.json`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
document.querySelector("#exportSummary").innerHTML = summaryItems(countExport(payload));
page.toast("Экспорт подготовлен");
});
});
document.querySelector("#previewBtn").addEventListener("click", async (event) => {
await page.runAction(event.currentTarget, "Проверяю файл...", async () => {
const payload = await readSelectedFile();
const preview = await page.api("/my/import/preview", {
method: "POST",
body: JSON.stringify(payload),
});
renderPreview(preview);
page.toast("Файл проверен");
});
});
document.querySelector("#importForm").addEventListener("submit", async (event) => {
event.preventDefault();
await page.runAction(document.querySelector("#importBtn"), "Импортирую...", async () => {
const payload = selectedPayload || await readSelectedFile();
const result = await page.api("/my/import", {
method: "POST",
body: JSON.stringify(payload),
});
renderPreview(result.preview);
document.querySelector("#importSummary").insertAdjacentHTML("afterbegin", `
<div class="tip-card">
Импортировано: авто ${result.imported.vehicles_created}, заправок ${result.imported.fuel_entries}, сервисных записей ${result.imported.service_entries}, расходов ${result.imported.expense_entries}.
</div>
`);
page.toast("Импорт завершен");
});
});
page.boot(async () => {});

152
web/static/page_common.js Normal file
View File

@@ -0,0 +1,152 @@
const tg = window.Telegram?.WebApp;
tg?.ready();
tg?.expand();
const CarPassPage = (() => {
const state = { user: null, authConfig: null };
function authHeaders(extra = {}) {
const headers = { ...extra };
if (tg?.initData) headers["X-Telegram-Init-Data"] = tg.initData;
if (!tg?.initData && state.authConfig?.allow_dev_auth) {
headers["X-Dev-Telegram-Id"] = localStorage.getItem("driversDevTelegramId") || "1";
}
return headers;
}
async function api(path, options = {}) {
const { headers: optionHeaders = {}, ...fetchOptions } = options;
const headers = { "Content-Type": "application/json", ...authHeaders(optionHeaders) };
if (options.body instanceof FormData) delete headers["Content-Type"];
const response = await fetch(`/api${path}`, { ...fetchOptions, headers });
if (!response.ok) throw new Error(await response.text() || response.statusText);
if (response.status === 204) return null;
return response.json();
}
async function loadAuthConfig() {
state.authConfig = await api("/users/auth/config");
}
function showAuthOverlay() {
document.body.classList.add("auth-required");
const link = document.querySelector("#telegramLoginLink");
if (state.authConfig?.bot_username && link) {
link.href = `https://t.me/${state.authConfig.bot_username}`;
link.classList.remove("hidden");
}
}
async function ensureUser() {
if (tg?.initData) {
state.user = await api("/users/webapp-auth", {
method: "POST",
body: JSON.stringify({ init_data: tg.initData }),
});
} else if (state.authConfig?.allow_dev_auth) {
state.user = await api("/users/me");
} else {
showAuthOverlay();
throw new Error("Требуется вход через Telegram");
}
document.body.classList.remove("auth-required");
document.querySelector("#authOverlay")?.classList.add("hidden");
return state.user;
}
function toast(message, tone = "success") {
const node = document.querySelector("#toast");
if (!node) return;
node.textContent = message;
node.className = `toast ${tone}`;
window.clearTimeout(toast.timer);
toast.timer = window.setTimeout(() => node.classList.add("hidden"), 2600);
}
function setBusy(button, busy, label = "Сохраняю...") {
if (!button) return;
if (busy) {
button.dataset.label = button.textContent;
button.disabled = true;
button.classList.add("is-busy");
button.innerHTML = `<span class="spinner"></span><span>${label}</span>`;
} else {
button.disabled = false;
button.classList.remove("is-busy");
button.textContent = button.dataset.label || button.textContent;
delete button.dataset.label;
}
}
async function runAction(button, label, callback) {
setBusy(button, true, label);
try {
const result = await callback();
return result;
} catch (error) {
toast(error.message || "Ошибка", "error");
throw error;
} finally {
setBusy(button, false, label);
}
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function formData(form) {
return Object.fromEntries(new FormData(form).entries());
}
function numberOrNull(value) {
return value === "" || value == null ? null : Number(value);
}
function csvList(value) {
return value ? value.split(",").map((item) => item.trim()).filter(Boolean) : null;
}
function formatDateTime(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value).slice(0, 16).replace("T", " ");
return date.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
}
function today() {
return new Date().toISOString().slice(0, 10);
}
async function boot(init) {
try {
await loadAuthConfig();
await ensureUser();
await init();
} catch (error) {
if (error.message === "Требуется вход через Telegram") return;
console.error(error);
toast(error.message || "Ошибка", "error");
}
}
document.querySelector("#telegramRetryBtn")?.addEventListener("click", () => window.location.reload());
return {
state,
api,
boot,
toast,
runAction,
escapeHtml,
formData,
numberOrNull,
csvList,
formatDateTime,
today,
};
})();

View File

@@ -0,0 +1,129 @@
const page = CarPassPage;
const APPROVED_STATUSES = new Set(["approved", "verified"]);
const state = {
centers: [],
selectedCenterId: null,
};
function selectedCenter() {
return state.centers.find((item) => item.id === state.selectedCenterId) || null;
}
function statusLabel(status) {
return {
pending: "На проверке",
approved: "Подтверждено",
verified: "Подтверждено",
rejected: "Отклонено",
needs_changes: "Нужны правки",
draft: "Черновик",
suspended: "Приостановлено",
}[status] || status || "Черновик";
}
function setValue(form, name, value) {
const input = form.elements[name];
if (input) input.value = Array.isArray(value) ? value.join(", ") : value ?? "";
}
function renderCenters() {
const root = document.querySelector("#centersList");
root.innerHTML = state.centers.length
? state.centers.map((center) => `
<button type="button" class="service-list-card ${center.id === state.selectedCenterId ? "active" : ""}" data-center="${center.id}">
<strong>${page.escapeHtml(center.display_name || center.name)}</strong>
<small>${page.escapeHtml([statusLabel(center.verification_status), center.city, center.employee_role].filter(Boolean).join(" · "))}</small>
</button>
`).join("")
: `<div class="empty">Заявок СТО пока нет. Заполните форму справа.</div>`;
root.querySelectorAll("[data-center]").forEach((button) => {
button.addEventListener("click", () => {
state.selectedCenterId = Number(button.dataset.center);
renderCenters();
fillForm();
});
});
}
function fillForm() {
const form = document.querySelector("#serviceProfileForm");
const center = selectedCenter();
form.reset();
document.querySelector("#centerTitle").textContent = center ? (center.display_name || center.name) : "Новая заявка СТО";
document.querySelector("#centerHint").textContent = center
? [center.city, center.address, center.phone].filter(Boolean).join(" · ") || "Дополните карточку, чтобы клиентам было проще записаться."
: "Заполните карточку сервиса и отправьте ее на проверку.";
document.querySelector("#centerStatus").textContent = statusLabel(center?.verification_status);
document.querySelector("#saveCenterBtn").textContent = center ? "Сохранить профиль" : "Отправить заявку";
document.querySelector("#openSettingsLink").classList.toggle("hidden", !center || !APPROVED_STATUSES.has(center.verification_status));
if (!center) return;
[
"display_name",
"legal_name",
"country",
"city",
"address",
"phone",
"contact_person",
"working_hours",
"specializations",
"description",
"business_registration_number",
"facade_photo_url",
"document_photo_urls",
"additional_photo_urls",
].forEach((name) => setValue(form, name, center[name]));
}
function payloadFromForm(form) {
const data = page.formData(form);
return {
display_name: data.display_name,
legal_name: data.legal_name || null,
country: data.country || null,
city: data.city || null,
address: data.address || null,
phone: data.phone || null,
contact_person: data.contact_person || null,
working_hours: data.working_hours || null,
specializations: page.csvList(data.specializations),
description: data.description || null,
business_registration_number: data.business_registration_number || null,
facade_photo_url: data.facade_photo_url || null,
document_photo_urls: page.csvList(data.document_photo_urls),
additional_photo_urls: page.csvList(data.additional_photo_urls),
};
}
async function loadCenters() {
state.centers = await page.api("/service-centers/my");
if (!state.selectedCenterId && state.centers.length) state.selectedCenterId = state.centers[0].id;
if (state.selectedCenterId && !state.centers.some((item) => item.id === state.selectedCenterId)) {
state.selectedCenterId = state.centers[0]?.id || null;
}
renderCenters();
fillForm();
}
document.querySelector("#newCenterBtn").addEventListener("click", () => {
state.selectedCenterId = null;
renderCenters();
fillForm();
});
document.querySelector("#serviceProfileForm").addEventListener("submit", async (event) => {
event.preventDefault();
const center = selectedCenter();
await page.runAction(document.querySelector("#saveCenterBtn"), center ? "Сохраняю..." : "Отправляю...", async () => {
const saved = await page.api(center ? `/service-centers/${center.id}` : "/service-centers", {
method: center ? "PATCH" : "POST",
body: JSON.stringify(payloadFromForm(event.currentTarget)),
});
state.selectedCenterId = saved.id;
await loadCenters();
page.toast(center ? "Профиль СТО обновлен" : "Заявка отправлена на проверку");
});
});
page.boot(loadCenters);

179
web/static/sto_settings.js Normal file
View File

@@ -0,0 +1,179 @@
const page = CarPassPage;
const APPROVED_STATUSES = new Set(["approved", "verified"]);
const MANAGER_ROLES = new Set(["owner", "manager"]);
const state = {
centers: [],
activeCenterId: null,
catalog: null,
};
function activeCenter() {
return state.centers.find((item) => item.id === state.activeCenterId) || null;
}
function roleLabel(role) {
return { owner: "Владелец", manager: "Менеджер", receptionist: "Администратор", mechanic: "Механик" }[role] || role || "СТО";
}
function timeValue(value) {
return String(value || "").slice(0, 5);
}
function setScheduleForm(settings) {
const form = document.querySelector("#bookingSettingsForm");
form.open_time.value = timeValue(settings.open_time || "09:00");
form.close_time.value = timeValue(settings.close_time || "18:00");
form.lunch_break_start.value = timeValue(settings.lunch_break_start || "");
form.lunch_break_end.value = timeValue(settings.lunch_break_end || "");
form.slot_duration_minutes.value = settings.slot_duration_minutes ?? 30;
form.booking_buffer_minutes.value = settings.booking_buffer_minutes ?? 0;
form.max_parallel_bookings.value = settings.max_parallel_bookings ?? 1;
form.timezone.value = settings.timezone || "Asia/Seoul";
form.accepts_online_booking.checked = settings.accepts_online_booking !== false;
const days = new Set(settings.working_days || [0, 1, 2, 3, 4]);
form.querySelectorAll('[name="working_days"]').forEach((input) => {
input.checked = days.has(Number(input.value));
});
}
function schedulePayload(form, centerId) {
const data = page.formData(form);
const workingDays = [...form.querySelectorAll('[name="working_days"]:checked')].map((input) => Number(input.value));
return {
service_center_id: centerId,
working_days: workingDays,
open_time: data.open_time || "09:00",
close_time: data.close_time || "18:00",
lunch_break_start: data.lunch_break_start || null,
lunch_break_end: data.lunch_break_end || null,
timezone: data.timezone || "Asia/Seoul",
slot_duration_minutes: Number(data.slot_duration_minutes || 30),
booking_buffer_minutes: Number(data.booking_buffer_minutes || 0),
max_parallel_bookings: Number(data.max_parallel_bookings || 1),
accepts_online_booking: Boolean(data.accepts_online_booking),
};
}
function catalogPayload(form, centerId) {
const data = page.formData(form);
const isWork = data.item_type === "work";
return {
service_center_id: centerId,
item_type: data.item_type,
title: data.title,
category: data.category || null,
description: data.description || null,
work_type: isWork ? (data.category || "other") : null,
product_type: isWork ? null : (data.category || "other"),
brand: data.brand || null,
sku: data.sku || null,
unit: data.unit || (isWork ? "pcs" : "pcs"),
default_quantity: page.numberOrNull(data.default_quantity) || 1,
default_unit_price: page.numberOrNull(data.default_unit_price) || 0,
viscosity: data.viscosity || null,
specification: data.specification || null,
};
}
function renderHeader() {
const center = activeCenter();
document.querySelector("#centerSelect").innerHTML = state.centers
.map((item) => `<option value="${item.id}">${page.escapeHtml(item.display_name || item.name)}</option>`)
.join("");
if (center) document.querySelector("#centerSelect").value = String(center.id);
document.querySelector("#settingsTitle").textContent = center ? (center.display_name || center.name) : "Нет доступной СТО";
document.querySelector("#settingsHint").textContent = center
? [center.city, center.address].filter(Boolean).join(", ") || "Заполните график и каталог для команды."
: "Настройки доступны владельцу или менеджеру подтвержденной СТО.";
document.querySelector("#roleBadge").textContent = center ? roleLabel(center.employee_role) : "Нет доступа";
}
function renderCatalog() {
const root = document.querySelector("#catalogList");
const centerId = activeCenter()?.id;
const items = (state.catalog?.items || []).filter((item) => item.service_center_id === centerId);
root.innerHTML = items.length
? items.map((item) => `
<div class="stack-item">
<strong>${page.escapeHtml(item.title)}</strong>
<small>${page.escapeHtml([item.item_type === "work" ? "Работа" : "Материал", item.category, item.brand, item.sku].filter(Boolean).join(" · "))}</small>
<small>${page.escapeHtml(item.default_quantity)} ${page.escapeHtml(item.unit)} · ${page.escapeHtml(item.default_unit_price)}</small>
</div>
`).join("")
: `<div class="empty">Пока нет собственных позиций. Системный каталог уже доступен в заказ-нарядах.</div>`;
}
async function loadCenters() {
const centers = await page.api("/service-centers/my");
state.centers = centers.filter((center) =>
APPROVED_STATUSES.has(center.verification_status) && MANAGER_ROLES.has(center.employee_role || "owner"),
);
if (!state.activeCenterId && state.centers.length) state.activeCenterId = state.centers[0].id;
if (state.activeCenterId && !state.centers.some((item) => item.id === state.activeCenterId)) {
state.activeCenterId = state.centers[0]?.id || null;
}
renderHeader();
}
async function loadSettings() {
const center = activeCenter();
if (!center) {
document.querySelector("#bookingSettingsForm").classList.add("hidden");
document.querySelector("#catalogForm").classList.add("hidden");
document.querySelector("#catalogList").innerHTML = `<div class="empty">Нет подтвержденной СТО с ролью владельца или менеджера.</div>`;
return;
}
document.querySelector("#bookingSettingsForm").classList.remove("hidden");
document.querySelector("#catalogForm").classList.remove("hidden");
const [settings, catalog] = await Promise.all([
page.api(`/sto/settings/booking?service_center_id=${center.id}`),
page.api(`/work-orders/catalog?service_center_id=${center.id}`),
]);
state.catalog = catalog;
setScheduleForm(settings);
renderCatalog();
}
document.querySelector("#centerSelect").addEventListener("change", async (event) => {
state.activeCenterId = Number(event.currentTarget.value);
renderHeader();
await loadSettings();
});
document.querySelector("#bookingSettingsForm").addEventListener("submit", async (event) => {
event.preventDefault();
const center = activeCenter();
if (!center) return;
await page.runAction(document.querySelector("#saveScheduleBtn"), "Сохраняю график...", async () => {
const settings = await page.api("/sto/settings/booking", {
method: "POST",
body: JSON.stringify(schedulePayload(event.currentTarget, center.id)),
});
setScheduleForm(settings);
page.toast("График сохранен");
});
});
document.querySelector("#catalogForm").addEventListener("submit", async (event) => {
event.preventDefault();
const center = activeCenter();
if (!center) return;
await page.runAction(document.querySelector("#saveCatalogBtn"), "Добавляю позицию...", async () => {
await page.api("/work-orders/catalog", {
method: "POST",
body: JSON.stringify(catalogPayload(event.currentTarget, center.id)),
});
event.currentTarget.reset();
event.currentTarget.default_quantity.value = 1;
event.currentTarget.default_unit_price.value = 0;
state.catalog = await page.api(`/work-orders/catalog?service_center_id=${center.id}`);
renderCatalog();
page.toast("Позиция добавлена");
});
});
page.boot(async () => {
await loadCenters();
await loadSettings();
});

View File

@@ -1556,6 +1556,151 @@ select {
display: none;
}
.ghost-btn {
display: inline-flex;
min-height: 42px;
align-items: center;
justify-content: center;
border-radius: 8px;
text-decoration: none;
}
.flow-page {
background:
linear-gradient(180deg, #ffffff 0, #f3f7f5 260px),
var(--bg);
}
.flow-shell {
width: min(1220px, 100%);
}
.flow-hero {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: center;
margin-bottom: 14px;
padding: 18px;
border: 1px solid rgba(208, 220, 214, 0.92);
border-radius: 8px;
background:
linear-gradient(135deg, rgba(18, 115, 95, 0.1), rgba(47, 111, 159, 0.08)),
#fff;
box-shadow: var(--shadow);
animation: rise 360ms ease both;
}
.flow-hero h2 {
margin: 2px 0 4px;
font-size: clamp(22px, 3vw, 32px);
}
.flow-hero small,
.form-block small {
color: var(--muted);
}
.flow-steps {
display: flex;
gap: 8px;
}
.flow-steps span {
display: grid;
width: 32px;
height: 32px;
place-items: center;
border: 1px solid var(--line);
border-radius: 50%;
background: #fff;
color: var(--muted);
font-size: 13px;
font-weight: 850;
}
.flow-steps span.active {
border-color: rgba(18, 115, 95, 0.3);
background: #dff4ed;
color: #0f604f;
}
.flow-layout {
display: grid;
grid-template-columns: minmax(280px, 0.85fr) minmax(0, 1.45fr);
gap: 14px;
align-items: start;
}
.flow-side {
position: sticky;
top: 88px;
max-height: calc(100vh - 104px);
overflow: auto;
}
.flow-form {
grid-template-columns: repeat(2, minmax(180px, 1fr));
gap: 12px;
}
.flow-form .wide,
.wide {
grid-column: 1 / -1;
}
.form-block {
display: grid;
gap: 4px;
padding: 12px;
border: 1px solid rgba(208, 220, 214, 0.9);
border-radius: 8px;
background: #fbfdfc;
}
.form-block h3 {
margin: 0;
color: var(--text);
font-size: 17px;
letter-spacing: 0;
}
.compact-form {
grid-template-columns: 1fr;
margin-bottom: 12px;
}
.weekday-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 8px;
}
.weekday-grid label {
display: flex;
min-height: 38px;
align-items: center;
justify-content: center;
gap: 6px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
color: var(--text);
font-weight: 750;
}
.weekday-grid input {
width: 16px;
min-height: 16px;
accent-color: var(--accent);
}
.service-list-card.active {
border-color: rgba(18, 115, 95, 0.55);
background: #eef8f4;
box-shadow: 0 12px 26px rgba(18, 115, 95, 0.12);
}
.scan-form {
display: grid;
gap: 10px;
@@ -1914,11 +2059,25 @@ select {
}
.sto-grid,
.flow-layout,
.work-order-layout,
.staff-form {
grid-template-columns: 1fr;
}
.flow-side {
position: static;
max-height: none;
}
.flow-form {
grid-template-columns: 1fr;
}
.weekday-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.mini-stats {
display: flex;
overflow-x: auto;
@@ -2042,6 +2201,15 @@ select {
grid-template-columns: 1fr;
}
.flow-hero {
grid-template-columns: 1fr;
padding: 14px;
}
.weekday-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sto-page .top-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) 44px;

View File

@@ -150,7 +150,7 @@ function renderProfileLink(detail) {
link.classList.add("hidden");
return;
}
link.href = `/?section=carProfile&car_id=${detail.vehicle.id}`;
link.href = `/car_profile.html?car_id=${detail.vehicle.id}`;
link.classList.remove("hidden");
}

176
web/sto_settings.html Normal file
View File

@@ -0,0 +1,176 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#16806a" />
<title>Настройки СТО</title>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="stylesheet" href="/static/styles.css" />
<script src="https://telegram.org/js/telegram-web-app.js"></script>
</head>
<body class="auth-required flow-page">
<div class="auth-overlay" id="authOverlay">
<div class="auth-panel">
<p class="eyebrow">CarPass Business</p>
<h1>Настройки СТО</h1>
<p>Откройте страницу через Telegram, чтобы редактировать график и каталог материалов.</p>
<div class="auth-actions">
<a id="telegramLoginLink" class="telegram-login-link hidden" href="#" rel="noreferrer">Открыть в Telegram</a>
<button id="telegramRetryBtn" class="telegram-secondary-btn" type="button">Проверить вход</button>
</div>
</div>
</div>
<main class="shell flow-shell">
<header class="topbar">
<div>
<p class="eyebrow">СТО</p>
<h1>Параметры сервиса</h1>
</div>
<div class="top-actions">
<a class="ghost-btn" href="/sto.html">Рабочее место</a>
<select id="centerSelect" aria-label="СТО"></select>
</div>
</header>
<section class="flow-hero">
<div>
<p class="eyebrow">Управление</p>
<h2 id="settingsTitle">Выберите СТО</h2>
<small id="settingsHint">График влияет на свободные окна записи, каталог ускоряет создание заказ-нарядов.</small>
</div>
<span class="trust-badge" id="roleBadge">Проверка</span>
</section>
<section class="flow-layout">
<section class="workspace">
<form id="bookingSettingsForm" class="grid-form drawer-form flow-form">
<div class="form-block wide">
<p class="eyebrow">1. График</p>
<h3>Онлайн-запись</h3>
<small>Клиент видит только свободные окна по этому расписанию.</small>
</div>
<label class="wide">
Рабочие дни
<div class="weekday-grid" id="weekdayGrid">
<label><input type="checkbox" name="working_days" value="0" /> Пн</label>
<label><input type="checkbox" name="working_days" value="1" /> Вт</label>
<label><input type="checkbox" name="working_days" value="2" /> Ср</label>
<label><input type="checkbox" name="working_days" value="3" /> Чт</label>
<label><input type="checkbox" name="working_days" value="4" /> Пт</label>
<label><input type="checkbox" name="working_days" value="5" /> Сб</label>
<label><input type="checkbox" name="working_days" value="6" /> Вс</label>
</div>
</label>
<label>
Открытие
<input name="open_time" type="time" />
</label>
<label>
Закрытие
<input name="close_time" type="time" />
</label>
<label>
Обед с
<input name="lunch_break_start" type="time" />
</label>
<label>
Обед до
<input name="lunch_break_end" type="time" />
</label>
<label>
Длительность слота, мин
<input name="slot_duration_minutes" type="number" min="10" max="240" />
</label>
<label>
Буфер между записями, мин
<input name="booking_buffer_minutes" type="number" min="0" max="240" />
</label>
<label>
Параллельных записей
<input name="max_parallel_bookings" type="number" min="1" max="20" />
</label>
<label>
Часовой пояс
<input name="timezone" placeholder="Asia/Seoul" />
</label>
<label class="check wide">
<input name="accepts_online_booking" type="checkbox" />
Принимать онлайн-запись
</label>
<div class="row-actions wide">
<button id="saveScheduleBtn" type="submit">Сохранить график</button>
</div>
</form>
</section>
<section class="workspace">
<form id="catalogForm" class="grid-form drawer-form flow-form">
<div class="form-block wide">
<p class="eyebrow">2. Каталог</p>
<h3>Работы и материалы</h3>
<small>Добавленные позиции будут доступны в заказ-наряде вместе с системным каталогом.</small>
</div>
<label>
Тип
<select name="item_type">
<option value="work">Работа</option>
<option value="product">Материал</option>
</select>
</label>
<label>
Название
<input name="title" placeholder="Замена масла ДВС" required />
</label>
<label>
Категория
<input name="category" placeholder="engine_oil" />
</label>
<label>
Бренд
<input name="brand" placeholder="Hyundai / BMW / Shell" />
</label>
<label>
Артикул
<input name="sku" placeholder="SKU" />
</label>
<label>
Единица
<input name="unit" placeholder="pcs / l / hour" />
</label>
<label>
Количество
<input name="default_quantity" type="number" min="0.01" step="0.01" value="1" />
</label>
<label>
Цена
<input name="default_unit_price" type="number" min="0" step="0.01" value="0" />
</label>
<label>
Вязкость
<input name="viscosity" placeholder="5W-30" />
</label>
<label>
Спецификация
<input name="specification" placeholder="API SP / MB 229.5" />
</label>
<label class="wide">
Описание
<input name="description" placeholder="Комментарий для сотрудников" />
</label>
<div class="row-actions wide">
<button id="saveCatalogBtn" type="submit">Добавить позицию</button>
</div>
</form>
<div class="list-heading">Каталог СТО</div>
<div id="catalogList" class="stack-list"></div>
</section>
</section>
</main>
<div class="toast hidden" id="toast" role="status" aria-live="polite"></div>
<script src="/static/page_common.js"></script>
<script src="/static/sto_settings.js"></script>
</body>
</html>