Complete CarPass product flows
This commit is contained in:
@@ -319,6 +319,9 @@ const state = {
|
||||
analytics: null,
|
||||
serviceCenters: [],
|
||||
publicServiceCenters: [],
|
||||
confirmations: null,
|
||||
connectedServices: [],
|
||||
adminPendingServices: [],
|
||||
vehicleScore: null,
|
||||
vehicleTimeline: [],
|
||||
achievements: [],
|
||||
@@ -512,6 +515,7 @@ async function ensureUser() {
|
||||
body: JSON.stringify({ init_data: tg.initData }),
|
||||
});
|
||||
hideAuthOverlay();
|
||||
updateRoleVisibility();
|
||||
return;
|
||||
}
|
||||
if (state.authConfig?.allow_dev_auth) {
|
||||
@@ -519,6 +523,7 @@ async function ensureUser() {
|
||||
localStorage.setItem("driversDevTelegramId", devId);
|
||||
state.user = await api("/users/me");
|
||||
hideAuthOverlay();
|
||||
updateRoleVisibility();
|
||||
return;
|
||||
}
|
||||
await showTelegramLogin();
|
||||
@@ -530,6 +535,11 @@ function hideAuthOverlay() {
|
||||
document.body.classList.remove("auth-required");
|
||||
}
|
||||
|
||||
function updateRoleVisibility() {
|
||||
const isAdmin = ["admin", "verifier", "moderator"].includes(state.user?.platform_role);
|
||||
document.querySelectorAll(".admin-only").forEach((node) => node.classList.toggle("hidden", !isAdmin));
|
||||
}
|
||||
|
||||
function showTelegramOpenHint() {
|
||||
const overlay = document.querySelector("#authOverlay");
|
||||
const slot = document.querySelector("#telegramLoginSlot");
|
||||
@@ -578,6 +588,7 @@ async function showTelegramLogin() {
|
||||
});
|
||||
localStorage.setItem("driversUser", JSON.stringify(state.user));
|
||||
hideAuthOverlay();
|
||||
updateRoleVisibility();
|
||||
await loadCars();
|
||||
};
|
||||
const script = document.createElement("script");
|
||||
@@ -799,7 +810,17 @@ function renderCars() {
|
||||
}
|
||||
|
||||
function setInputValue(form, name, value) {
|
||||
if (form?.elements[name]) form.elements[name].value = value ?? "";
|
||||
if (!form?.elements[name]) return;
|
||||
const input = form.elements[name];
|
||||
if (input.type === "checkbox") {
|
||||
input.checked = Boolean(value);
|
||||
return;
|
||||
}
|
||||
input.value = value ?? "";
|
||||
}
|
||||
|
||||
function csvList(value) {
|
||||
return value ? value.split(",").map((item) => item.trim()).filter(Boolean) : null;
|
||||
}
|
||||
|
||||
function fillCarProfileForm() {
|
||||
@@ -816,6 +837,13 @@ function fillCarProfileForm() {
|
||||
}
|
||||
hint.textContent = [car.make, car.model, car.trim, car.year].filter(Boolean).join(" ") || car.name;
|
||||
[
|
||||
"plate_number",
|
||||
"vin",
|
||||
"generation",
|
||||
"body_type",
|
||||
"engine_volume_l",
|
||||
"transmission",
|
||||
"drive_type",
|
||||
"fuel_type",
|
||||
"target_consumption_l_per_100km",
|
||||
"fuel_tank_volume_l",
|
||||
@@ -827,9 +855,168 @@ function fillCarProfileForm() {
|
||||
"brake_fluid_type",
|
||||
"tire_pressure_front_bar",
|
||||
"tire_pressure_rear_bar",
|
||||
"tire_size",
|
||||
"oil_change_interval_km",
|
||||
"oil_change_interval_months",
|
||||
"purchase_price",
|
||||
"purchase_date",
|
||||
"purchase_type",
|
||||
"loan_principal",
|
||||
"loan_down_payment",
|
||||
"loan_term_months",
|
||||
"loan_annual_interest_rate",
|
||||
"loan_first_payment_date",
|
||||
"include_depreciation",
|
||||
"notes",
|
||||
].forEach((name) => setInputValue(form, name, car[name]));
|
||||
}
|
||||
|
||||
async function loadConfirmations() {
|
||||
const root = document.querySelector("#confirmationRequests");
|
||||
if (!root) return;
|
||||
try {
|
||||
state.confirmations = await api("/my/confirmations");
|
||||
const visits = state.confirmations.service_visits || [];
|
||||
const changes = state.confirmations.change_requests || [];
|
||||
const links = state.confirmations.service_links || [];
|
||||
if (!visits.length && !changes.length && !links.length) {
|
||||
root.innerHTML = `<div class="empty">Новых запросов нет</div>`;
|
||||
return;
|
||||
}
|
||||
root.innerHTML = [
|
||||
...visits.map((visit) => `
|
||||
<div class="stack-item">
|
||||
<strong>Визит СТО #${visit.id}</strong>
|
||||
<small>${visit.visit_date} · ${visit.odometer || "-"} км · ${money(visit.total_cost || 0)}</small>
|
||||
<div class="row-actions">
|
||||
<button type="button" data-confirm-visit="${visit.id}">Подтвердить</button>
|
||||
<button type="button" data-dispute-visit="${visit.id}">Спор</button>
|
||||
</div>
|
||||
</div>`),
|
||||
...changes.map((item) => `
|
||||
<div class="stack-item">
|
||||
<strong>Изменение ${item.field_name}</strong>
|
||||
<small>${item.old_value || "-"} → ${item.new_value || "-"}</small>
|
||||
<div class="row-actions">
|
||||
<button type="button" data-approve-change="${item.id}">Принять</button>
|
||||
<button type="button" data-reject-change="${item.id}">Отклонить</button>
|
||||
</div>
|
||||
</div>`),
|
||||
...links.map((link) => `
|
||||
<div class="stack-item">
|
||||
<strong>Запрос доступа от СТО #${link.service_center_id}</strong>
|
||||
<small>Авто #${link.car_id} · ${link.access_level}</small>
|
||||
<div class="row-actions">
|
||||
<button type="button" data-approve-link="${link.id}">Разрешить</button>
|
||||
<button type="button" data-revoke-link="${link.id}">Отклонить</button>
|
||||
</div>
|
||||
</div>`),
|
||||
].join("");
|
||||
bindConfirmationActions(root);
|
||||
} catch (error) {
|
||||
root.innerHTML = `<div class="empty">Не удалось загрузить подтверждения</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function bindConfirmationActions(root) {
|
||||
root.querySelectorAll("[data-confirm-visit]").forEach((button) => {
|
||||
button.addEventListener("click", () => runAction(button, "Подтверждаю...", async () => {
|
||||
await api(`/service-visits/${button.dataset.confirmVisit}/confirm`, { method: "POST" });
|
||||
await loadConfirmations();
|
||||
await loadSelectedCar();
|
||||
}));
|
||||
});
|
||||
root.querySelectorAll("[data-dispute-visit]").forEach((button) => {
|
||||
button.addEventListener("click", () => runAction(button, "Отмечаю спор...", async () => {
|
||||
await api(`/service-visits/${button.dataset.disputeVisit}/dispute`, { method: "POST" });
|
||||
await loadConfirmations();
|
||||
}));
|
||||
});
|
||||
root.querySelectorAll("[data-approve-change]").forEach((button) => {
|
||||
button.addEventListener("click", () => runAction(button, "Применяю...", async () => {
|
||||
await api(`/vehicle-change-requests/${button.dataset.approveChange}/approve`, { method: "POST" });
|
||||
await loadConfirmations();
|
||||
await loadCars();
|
||||
}));
|
||||
});
|
||||
root.querySelectorAll("[data-reject-change]").forEach((button) => {
|
||||
button.addEventListener("click", () => runAction(button, "Отклоняю...", async () => {
|
||||
await api(`/vehicle-change-requests/${button.dataset.rejectChange}/reject`, { method: "POST" });
|
||||
await loadConfirmations();
|
||||
}));
|
||||
});
|
||||
root.querySelectorAll("[data-approve-link]").forEach((button) => {
|
||||
button.addEventListener("click", () => runAction(button, "Разрешаю доступ...", async () => {
|
||||
await api(`/service-centers/links/${button.dataset.approveLink}/approve`, { method: "POST" });
|
||||
await loadConfirmations();
|
||||
await loadConnectedServices();
|
||||
}));
|
||||
});
|
||||
root.querySelectorAll("[data-revoke-link]").forEach((button) => {
|
||||
button.addEventListener("click", () => runAction(button, "Отклоняю...", async () => {
|
||||
await api(`/service-centers/links/${button.dataset.revokeLink}/revoke`, { method: "POST" });
|
||||
await loadConfirmations();
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async function loadConnectedServices() {
|
||||
const root = document.querySelector("#connectedServices");
|
||||
if (!root) return;
|
||||
try {
|
||||
state.connectedServices = await api("/my/service-links");
|
||||
root.innerHTML = state.connectedServices.length
|
||||
? state.connectedServices.map((link) => `
|
||||
<div class="stack-item">
|
||||
<strong>${link.service_center_name}</strong>
|
||||
<small>${link.car_name} · ${link.access_level} · ${link.status}</small>
|
||||
${link.status === "approved" ? `<button type="button" data-revoke-link="${link.id}">Отозвать доступ</button>` : ""}
|
||||
</div>`).join("")
|
||||
: `<div class="empty">Подключенных автосервисов пока нет</div>`;
|
||||
root.querySelectorAll("[data-revoke-link]").forEach((button) => {
|
||||
button.addEventListener("click", () => runAction(button, "Отзываю доступ...", async () => {
|
||||
await api(`/service-centers/links/${button.dataset.revokeLink}/revoke`, { method: "POST" });
|
||||
await loadConnectedServices();
|
||||
}));
|
||||
});
|
||||
} catch (error) {
|
||||
root.innerHTML = `<div class="empty">Не удалось загрузить подключения</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAdminPendingServices() {
|
||||
const root = document.querySelector("#adminPendingServices");
|
||||
if (!root) return;
|
||||
try {
|
||||
state.adminPendingServices = await api("/admin/service-centers/pending");
|
||||
root.innerHTML = state.adminPendingServices.length
|
||||
? state.adminPendingServices.map((center) => `
|
||||
<div class="stack-item">
|
||||
<strong>#${center.id} ${center.display_name || center.name}</strong>
|
||||
<small>${[center.legal_name, center.city, center.address].filter(Boolean).join(" · ") || "Данные не заполнены"}</small>
|
||||
<small>Документы: ${(center.document_photo_urls || []).length}</small>
|
||||
<div class="row-actions">
|
||||
<button type="button" data-admin-action="verify" data-admin-center="${center.id}">Одобрить</button>
|
||||
<button type="button" data-admin-action="request-changes" data-admin-center="${center.id}">Правки</button>
|
||||
<button type="button" data-admin-action="reject" data-admin-center="${center.id}">Отклонить</button>
|
||||
</div>
|
||||
</div>`).join("")
|
||||
: `<div class="empty">Pending-заявок нет</div>`;
|
||||
root.querySelectorAll("[data-admin-action]").forEach((button) => {
|
||||
button.addEventListener("click", () => runAction(button, "Сохраняю решение...", async () => {
|
||||
const comment = button.dataset.adminAction === "verify" ? "Одобрено" : window.prompt("Комментарий для владельца СТО") || "";
|
||||
await api(`/admin/service-centers/${button.dataset.adminCenter}/${button.dataset.adminAction}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason: comment, comment }),
|
||||
});
|
||||
await loadAdminPendingServices();
|
||||
}));
|
||||
});
|
||||
} catch (error) {
|
||||
root.innerHTML = `<div class="empty">Нет доступа или сервер не ответил</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function openCarProfile() {
|
||||
openDrawerSection("carProfileSection");
|
||||
}
|
||||
@@ -1050,9 +1237,13 @@ function renderStats(stats) {
|
||||
<button class="stat pop" data-report="summary"><span>${periodTitle}</span><strong>${money(stats.total_cost)}</strong><em>${stats.date_from} - ${stats.date_to}</em></button>
|
||||
<button class="stat pop" data-report="summary"><span>В месяц</span><strong>${money(stats.cost_per_month || 0)}</strong><em>${t("среднее в периоде")}</em></button>
|
||||
<button class="stat pop" data-report="summary"><span>Прогноз</span><strong>${money(stats.forecast_next_month || 0)}</strong><em>ближайший месяц</em></button>
|
||||
<button class="stat pop" data-report="summary"><span>Фиксированные</span><strong>${money(stats.fixed_costs || 0)}</strong><em>страховка, налоги, кредит</em></button>
|
||||
<button class="stat pop" data-report="summary"><span>Переменные</span><strong>${money(stats.variable_costs || 0)}</strong><em>топливо, ремонт, услуги</em></button>
|
||||
<button class="stat pop" data-report="summary"><span>Кредит</span><strong>${money((Number(stats.loan_principal_cost || 0) + Number(stats.loan_interest_cost || 0)))}</strong><em>тело и проценты</em></button>
|
||||
<button class="stat pop" data-report="efficiency"><span>${t("За день")}</span><strong>${money(costPerDay)}</strong><em>${t("среднее в периоде")}</em></button>
|
||||
<button class="stat pop" data-report="efficiency"><span>${t("На 100 км")}</span><strong>${costPer100 ? money(costPer100) : "-"}</strong><em>${stats.distance_km} км</em></button>
|
||||
<button class="stat pop" data-report="efficiency"><span>${t("На 1 км")}</span><strong>${stats.cost_per_km ? money(stats.cost_per_km) : "-"}</strong><em>${stats.avg_consumption_l_per_100km ? `${stats.avg_consumption_l_per_100km.toFixed(2)} л/100` : t("нет данных")}</em></button>
|
||||
${stats.cost_warning ? `<div class="stat wide warning"><span>Предупреждение</span><strong>${stats.cost_warning}</strong><em>мягкая проверка расходов</em></div>` : ""}
|
||||
`;
|
||||
root.querySelectorAll("[data-report]").forEach((button) => {
|
||||
button.addEventListener("click", () => openReport(button.dataset.report));
|
||||
@@ -1265,9 +1456,12 @@ function openReport(type = "summary") {
|
||||
${reportMetric(t("Пробег"), `${stats.distance_km} км`)}
|
||||
${reportMetric(t("Прогноз сегодня"), analytics?.predicted_today ? `${analytics.predicted_today} км` : "-")}
|
||||
${reportMetric(t("+30 дней"), analytics?.predicted_30_days ? `${analytics.predicted_30_days} км` : "-")}
|
||||
${reportMetric("Средний полный бак", analytics?.average_full_tank_distance ? `${analytics.average_full_tank_distance} км` : "-")}
|
||||
${reportMetric("Средний бак", analytics?.average_cost_per_full_tank ? money(analytics.average_cost_per_full_tank) : "-")}
|
||||
${reportMetric(t("Текущая цена"), analytics?.current_price_per_liter ? `${formatFuelPrice(analytics.current_price_per_liter)} / л` : "-")}
|
||||
${reportMetric(t("Прогноз цены"), analytics?.predicted_price_per_liter_30_days ? `${formatFuelPrice(analytics.predicted_price_per_liter_30_days)} / л` : "-")}
|
||||
</div>
|
||||
${analytics?.full_tank_warning ? `<div class="tip-card warning">${analytics.full_tank_warning}</div>` : ""}
|
||||
<div class="tip-card">${analytics?.insight || t("Лучший рост точности даст привычка заносить одометр при каждой заправке и сервисе.")}</div>
|
||||
`,
|
||||
};
|
||||
@@ -1589,7 +1783,15 @@ document.querySelector("#carForm").addEventListener("submit", async (event) => {
|
||||
model: data.model || null,
|
||||
trim: data.trim || null,
|
||||
year: data.year ? Number(data.year) : null,
|
||||
plate_number: data.plate_number || null,
|
||||
vin: data.vin || null,
|
||||
current_odometer: numberOrNull(data.current_odometer),
|
||||
fuel_type: data.fuel_type || null,
|
||||
purchase_price: numberOrNull(data.purchase_price),
|
||||
purchase_date: data.purchase_date || null,
|
||||
purchase_type: data.purchase_type || "unknown",
|
||||
purchase_currency: state.user?.currency || "RUB",
|
||||
currency: state.user?.currency || "RUB",
|
||||
}),
|
||||
});
|
||||
form.reset();
|
||||
@@ -1614,6 +1816,13 @@ document.querySelector("#carProfileForm").addEventListener("submit", async (even
|
||||
const updated = await api(`/cars/${car.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
plate_number: data.plate_number || null,
|
||||
vin: data.vin || null,
|
||||
generation: data.generation || null,
|
||||
body_type: data.body_type || null,
|
||||
engine_volume_l: numberOrNull(data.engine_volume_l),
|
||||
transmission: data.transmission || null,
|
||||
drive_type: data.drive_type || null,
|
||||
fuel_type: data.fuel_type || null,
|
||||
target_consumption_l_per_100km: numberOrNull(data.target_consumption_l_per_100km),
|
||||
fuel_tank_volume_l: numberOrNull(data.fuel_tank_volume_l),
|
||||
@@ -1625,6 +1834,20 @@ document.querySelector("#carProfileForm").addEventListener("submit", async (even
|
||||
brake_fluid_type: data.brake_fluid_type || null,
|
||||
tire_pressure_front_bar: numberOrNull(data.tire_pressure_front_bar),
|
||||
tire_pressure_rear_bar: numberOrNull(data.tire_pressure_rear_bar),
|
||||
tire_size: data.tire_size || null,
|
||||
oil_change_interval_km: numberOrNull(data.oil_change_interval_km),
|
||||
oil_change_interval_months: numberOrNull(data.oil_change_interval_months),
|
||||
purchase_price: numberOrNull(data.purchase_price),
|
||||
purchase_date: data.purchase_date || null,
|
||||
purchase_type: data.purchase_type || "unknown",
|
||||
include_depreciation: Boolean(data.include_depreciation),
|
||||
loan_principal: numberOrNull(data.loan_principal),
|
||||
loan_down_payment: numberOrNull(data.loan_down_payment),
|
||||
loan_term_months: numberOrNull(data.loan_term_months),
|
||||
loan_annual_interest_rate: numberOrNull(data.loan_annual_interest_rate),
|
||||
loan_first_payment_date: data.loan_first_payment_date || null,
|
||||
loan_currency: state.user?.currency || car.currency || "RUB",
|
||||
notes: data.notes || null,
|
||||
}),
|
||||
});
|
||||
state.cars = state.cars.map((item) => (item.id === updated.id ? updated : item));
|
||||
@@ -1730,6 +1953,11 @@ document.querySelector("#expenseForm").addEventListener("submit", async (event)
|
||||
odometer: numberOrNull(data.odometer),
|
||||
period_start: data.period_start || null,
|
||||
period_end: data.period_end || null,
|
||||
period_months: numberOrNull(data.period_months),
|
||||
payment_period_months: numberOrNull(data.period_months),
|
||||
policy_number: data.policy_number || null,
|
||||
insurance_type: data.insurance_type || null,
|
||||
notes: data.notes || null,
|
||||
is_recurring: Boolean(data.is_recurring),
|
||||
}),
|
||||
});
|
||||
@@ -1792,11 +2020,12 @@ async function openDrawerSection(sectionId, options = {}) {
|
||||
: "Напомним о ТО, страховке и регулярном внесении пробега.",
|
||||
);
|
||||
}
|
||||
if (sectionId === "confirmationsSection") renderPlaceholderList("#confirmationRequests", "Новых запросов нет");
|
||||
if (sectionId === "connectedServicesSection") renderPlaceholderList("#connectedServices", "Подключенных автосервисов пока нет");
|
||||
if (sectionId === "confirmationsSection") await loadConfirmations();
|
||||
if (sectionId === "connectedServicesSection") await loadConnectedServices();
|
||||
if (sectionId === "servicePanelSection") await loadServiceCenters();
|
||||
if (sectionId === "publicServicesSection") await loadPublicServiceCenters();
|
||||
if (sectionId === "reviewsSection") renderServiceReviews();
|
||||
if (sectionId === "adminSection") await loadAdminPendingServices();
|
||||
if (options.expenseCategory) {
|
||||
openDrawerSection("expensesSection");
|
||||
presetExpense(options.expenseCategory);
|
||||
@@ -1809,7 +2038,11 @@ function presetExpense(category) {
|
||||
const form = document.querySelector("#expenseForm");
|
||||
form.category.value = category;
|
||||
form.title.value = expenseLabel(category);
|
||||
if (category === "insurance") form.is_recurring.checked = true;
|
||||
form.is_recurring.checked = category === "insurance" || category === "tax";
|
||||
if (category === "insurance") {
|
||||
form.period_months.value = "12";
|
||||
form.insurance_type.value = "mandatory";
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-action]").forEach((button) => {
|
||||
@@ -1888,6 +2121,9 @@ document.querySelector("#serviceCenterForm").addEventListener("submit", async (e
|
||||
: null,
|
||||
working_hours: data.working_hours || null,
|
||||
business_registration_number: data.business_registration_number || null,
|
||||
facade_photo_url: data.facade_photo_url || null,
|
||||
document_photo_urls: csvList(data.document_photo_urls),
|
||||
additional_photo_urls: csvList(data.additional_photo_urls),
|
||||
}),
|
||||
});
|
||||
form.reset();
|
||||
|
||||
Reference in New Issue
Block a user