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

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();
});