This commit is contained in:
@@ -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
150
web/static/book_sto.js
Normal 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
215
web/static/car_profile.js
Normal 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();
|
||||
});
|
||||
96
web/static/data_exchange.js
Normal file
96
web/static/data_exchange.js
Normal 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
152
web/static/page_common.js
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
})();
|
||||
129
web/static/service_profile.js
Normal file
129
web/static/service_profile.js
Normal 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
179
web/static/sto_settings.js
Normal 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();
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user