Files
drivers_bot/web/static/sto.js
2026-05-16 19:35:04 +09:00

545 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const tg = window.Telegram?.WebApp;
tg?.ready();
tg?.expand();
const APPROVED_SERVICE_STATUSES = new Set(["approved", "verified"]);
const STO_WORKPLACE_ROLES = new Set(["owner", "manager", "receptionist", "mechanic"]);
const state = {
user: null,
authConfig: null,
centers: [],
activeCenterId: null,
appointments: [],
workOrders: [],
employees: [],
catalogsByVehicleId: {},
catalogLookup: {},
};
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) };
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");
}
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");
}
function showAuthOverlay() {
document.body.classList.add("auth-required");
const botUsername = state.authConfig?.bot_username;
const link = document.querySelector("#telegramLoginLink");
if (botUsername && link) {
link.href = `https://t.me/${botUsername}`;
link.classList.remove("hidden");
}
}
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 money(value) {
const currency = state.user?.currency || "RUB";
return Number(value || 0).toLocaleString("ru-RU", {
style: "currency",
currency,
maximumFractionDigits: currency === "KRW" ? 0 : 2,
});
}
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 escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&")
.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 activeCenter() {
return state.centers.find((center) => center.id === state.activeCenterId) || state.centers[0] || null;
}
function roleLabel(role) {
return {
owner: "Владелец",
manager: "Менеджер",
receptionist: "Администратор",
mechanic: "Механик",
}[role] || role || "Сотрудник";
}
function statusLabel(status) {
return {
draft: "Черновик",
diagnosis: "Диагностика",
waiting_owner_approval: "Ждет согласования",
approved_by_owner: "Согласован",
rejected_by_owner: "Отклонен клиентом",
in_progress: "В работе",
completed: "Завершен",
requested: "Новая заявка",
confirmed: "Подтверждена",
confirmed_by_sto: "Подтверждена СТО",
proposed_new_time: "Предложено другое время",
invited: "Приглашен",
active: "Активен",
inactive: "Отключен",
revoked: "Отозван",
expired: "Истекло",
}[status] || status || "Без статуса";
}
async function loadCenters() {
const centers = await api("/service-centers/my");
state.centers = centers.filter((center) =>
APPROVED_SERVICE_STATUSES.has(center.verification_status) && STO_WORKPLACE_ROLES.has(center.employee_role || "owner"),
);
if (!state.activeCenterId && state.centers.length) state.activeCenterId = state.centers[0].id;
if (state.activeCenterId && !state.centers.some((center) => center.id === state.activeCenterId)) {
state.activeCenterId = state.centers[0]?.id || null;
}
}
function renderAccessDenied() {
document.querySelector("#centerTitle").textContent = "Доступ закрыт";
document.querySelector("#centerMeta").textContent = "Страница доступна владельцу подтвержденного СТО и активным механикам.";
document.querySelector("#roleBadge").textContent = "Нет доступа";
document.querySelector("#centerSelect").innerHTML = "";
document.querySelector("#dashboardStats").innerHTML = "";
document.querySelector("#appointmentsList").innerHTML = `<div class="empty">Нет доступного подтвержденного СТО.</div>`;
document.querySelector("#workOrdersList").innerHTML = `<div class="empty">Рабочее место недоступно.</div>`;
document.querySelector("#staffPanel").classList.add("hidden");
}
async function loadWorkplace() {
await loadCenters();
if (!state.centers.length) {
renderAccessDenied();
return;
}
const center = activeCenter();
document.querySelector("#centerSelect").innerHTML = state.centers
.map((item) => `<option value="${item.id}">${escapeHtml(item.display_name || item.name)}</option>`)
.join("");
document.querySelector("#centerSelect").value = String(center.id);
document.querySelector("#centerTitle").textContent = center.display_name || center.name;
document.querySelector("#centerMeta").textContent = [center.city, center.address].filter(Boolean).join(", ") || "Адрес не указан";
document.querySelector("#roleBadge").textContent = roleLabel(center.employee_role || "owner");
const [dashboard, appointments, visits, employees] = await Promise.all([
api(`/sto/dashboard?service_center_id=${center.id}`).catch(() => null),
api(`/sto/appointments?service_center_id=${center.id}`).catch(() => []),
api(`/service-centers/${center.id}/visits`).catch(() => []),
center.employee_role === "owner" ? api(`/service-centers/${center.id}/employees`).catch(() => []) : Promise.resolve([]),
]);
state.appointments = appointments.filter((item) => ["requested", "confirmed", "confirmed_by_sto", "proposed_new_time"].includes(item.status));
state.workOrders = visits.filter((item) => !["completed", "cancelled", "archived", "confirmed", "disputed"].includes(item.status));
state.employees = employees;
await loadWorkOrderCatalogs(center.id);
renderDashboard(dashboard);
renderAppointments();
renderWorkOrders();
renderStaff();
}
async function loadWorkOrderCatalogs(serviceCenterId) {
state.catalogsByVehicleId = {};
state.catalogLookup = {};
const vehicleIds = [...new Set(state.workOrders.map((item) => item.vehicle_id).filter(Boolean))];
await Promise.all(vehicleIds.map(async (vehicleId) => {
try {
state.catalogsByVehicleId[vehicleId] = await api(`/work-orders/catalog?service_center_id=${serviceCenterId}&vehicle_id=${vehicleId}`);
} catch (_) {
state.catalogsByVehicleId[vehicleId] = { items: [], vehicle_suggestions: [], missing_vehicle_fields: [] };
}
}));
}
function catalogForWorkOrder(workOrder) {
return state.catalogsByVehicleId[workOrder.vehicle_id] || { items: [], vehicle_suggestions: [], missing_vehicle_fields: [] };
}
function registerCatalogOption(item) {
const key = `${item.source || "catalog"}:${item.id || Object.keys(state.catalogLookup).length}:${item.item_type}:${item.title}`;
state.catalogLookup[key] = item;
return key;
}
function catalogOptions(workOrder, itemType) {
const catalog = catalogForWorkOrder(workOrder);
const catalogItems = (catalog.items || []).filter((item) => item.item_type === itemType);
const suggestions = itemType === "product" ? (catalog.vehicle_suggestions || []) : [];
return [...catalogItems, ...suggestions].map((item) => {
const key = registerCatalogOption(item);
const meta = [item.brand, item.category, item.specification || item.sku].filter(Boolean).join(" · ");
return `<option value="${escapeHtml(key)}">${escapeHtml(item.title)}${meta ? ` · ${escapeHtml(meta)}` : ""}</option>`;
}).join("");
}
function missingVehicleFieldsText(fields) {
const labels = {
engine_oil: "моторное масло",
transmission_fluid: "трансмиссионная жидкость",
coolant: "антифриз",
brake_fluid: "тормозная жидкость",
};
return fields.map((field) => labels[field] || field).join(", ");
}
function renderDashboard(dashboard) {
document.querySelector("#dashboardStats").innerHTML = dashboard
? `
<div class="stat-card"><span>Заявки</span><strong>${dashboard.pending_appointments}</strong></div>
<div class="stat-card"><span>Подтверждено</span><strong>${dashboard.confirmed_appointments}</strong></div>
<div class="stat-card"><span>Заказ-наряды</span><strong>${dashboard.active_work_orders}</strong></div>
<div class="stat-card"><span>Авто</span><strong>${dashboard.connected_vehicles}</strong></div>
<div class="stat-card"><span>Месяц</span><strong>${money(dashboard.revenue_month || 0)}</strong></div>
`
: `<div class="empty">Сводка недоступна</div>`;
}
function renderAppointments() {
const role = activeCenter()?.employee_role || "owner";
const canManage = ["owner", "manager", "receptionist"].includes(role);
document.querySelector("#appointmentsList").innerHTML = state.appointments.length
? state.appointments.map((item) => `
<div class="stack-item work-order-card">
<strong>${escapeHtml(item.service_name)}</strong>
<small>${formatDateTime(item.confirmed_start_at || item.requested_start_at)} · авто #${item.vehicle_id}</small>
<span class="trust-badge">${statusLabel(item.status)}</span>
${canManage ? `<div class="row-actions">
${item.status === "requested" ? `<button type="button" data-confirm-appointment="${item.id}">Подтвердить</button>` : ""}
${["confirmed", "confirmed_by_sto"].includes(item.status) ? `<button type="button" data-create-work-order="${item.id}">Открыть заказ-наряд</button>` : ""}
<button type="button" class="ghost-btn" data-reject-appointment="${item.id}">Отклонить</button>
${!["converted_to_work_order", "completed"].includes(item.status) ? `<button type="button" class="danger-btn" data-delete-appointment="${item.id}">Удалить</button>` : ""}
</div>` : ""}
</div>
`).join("")
: `<div class="empty">Новых записей нет</div>`;
}
function renderWorkOrders() {
const role = activeCenter()?.employee_role || "owner";
const canComplete = role === "owner";
state.catalogLookup = {};
document.querySelector("#workOrdersList").innerHTML = state.workOrders.length
? state.workOrders.map((item) => {
const catalog = catalogForWorkOrder(item);
const missingFields = catalog.missing_vehicle_fields || [];
return `
<div class="stack-item work-order-card">
<div class="work-order-head">
<div>
<strong>${escapeHtml(item.work_order_number || `Заказ-наряд #${item.id}`)}</strong>
<small>${item.visit_date} · авто #${item.vehicle_id} · ${item.odometer || "-"} км</small>
</div>
<span class="trust-badge">${statusLabel(item.status)}</span>
</div>
${item.customer_complaint ? `<small>Жалоба: ${escapeHtml(item.customer_complaint)}</small>` : ""}
${item.diagnosis ? `<small>Диагностика: ${escapeHtml(item.diagnosis)}</small>` : ""}
<div class="work-order-totals">
<span>Работы: <strong>${money(item.labor_total || 0)}</strong></span>
<span>Запчасти: <strong>${money(item.product_total || 0)}</strong></span>
<span>Итого: <strong>${money(item.final_total || item.total_cost || 0)}</strong></span>
</div>
${missingFields.length ? `<div class="tip-card compact-tip">
Для точного подбора не хватает: ${escapeHtml(missingVehicleFieldsText(missingFields))}.
<button type="button" class="ghost-btn" data-request-vehicle-profile="${item.id}" data-missing-fields="${escapeHtml(missingFields.join(","))}">Попросить заполнить</button>
</div>` : ""}
<form class="inline-work-form catalog-work-form" data-labor-form="${item.id}">
<select name="catalog_item" data-catalog-select>
<option value="">Работа из каталога</option>
${catalogOptions(item, "work")}
</select>
<input name="title" placeholder="Работа" required />
<input name="quantity" type="number" min="0.001" step="0.001" value="1" aria-label="Количество" />
<input name="unit_price" type="number" min="0" step="0.01" placeholder="Цена" required />
<input name="unit" type="hidden" value="job" />
<input name="work_type" type="hidden" value="repair" />
<input name="category" type="hidden" />
<button type="submit">+ Работа</button>
</form>
<form class="inline-work-form catalog-work-form" data-product-form="${item.id}">
<select name="catalog_item" data-catalog-select>
<option value="">Материал из каталога</option>
${catalogOptions(item, "product")}
</select>
<input name="title" placeholder="Запчасть / материал" required />
<input name="quantity" type="number" min="0.001" step="0.001" value="1" aria-label="Количество" />
<input name="unit_price" type="number" min="0" step="0.01" placeholder="Цена" required />
<input name="unit" type="hidden" value="pcs" />
<input name="product_type" type="hidden" value="part" />
<input name="category" type="hidden" />
<input name="brand" type="hidden" />
<input name="sku" type="hidden" />
<input name="viscosity" type="hidden" />
<input name="specification" type="hidden" />
<input name="volume" type="hidden" />
<button type="submit">+ Материал</button>
</form>
<div class="row-actions">
${["draft", "diagnosis", "approved_by_owner"].includes(item.status) ? `<button type="button" data-start-work-order="${item.id}">В работу</button>` : ""}
${role === "owner" ? `<button type="button" data-submit-work-order="${item.id}">На согласование</button>` : ""}
${canComplete ? `<button type="button" class="ghost-btn" data-complete-work-order="${item.id}">Завершить</button>` : ""}
</div>
</div>
`;
}).join("")
: `<div class="empty">Активных заказ-нарядов нет</div>`;
}
function renderStaff() {
const center = activeCenter();
const panel = document.querySelector("#staffPanel");
if (!center || center.employee_role !== "owner") {
panel.classList.add("hidden");
return;
}
panel.classList.remove("hidden");
document.querySelector("#employeesList").innerHTML = state.employees.length
? state.employees.map((employee) => `
<div class="stack-item">
<strong>${escapeHtml(employee.first_name || employee.username || `Telegram ${employee.telegram_id || employee.user_id}`)}</strong>
<small>${employee.telegram_id ? `Telegram ID: ${employee.telegram_id}` : `User #${employee.user_id}`}</small>
<span class="trust-badge">${roleLabel(employee.role)} · ${statusLabel(employee.status)}</span>
${employee.invite_token ? `<small>Команда для сотрудника: /accept_sto_invite ${employee.invite_token}</small>` : ""}
${employee.role !== "owner" ? `<div class="row-actions">
<button type="button" data-role-employee="${employee.id}" data-role="mechanic">Механик</button>
<button type="button" data-role-employee="${employee.id}" data-role="receptionist">Администратор</button>
<button type="button" class="ghost-btn" data-disable-employee="${employee.id}">Отключить</button>
</div>` : ""}
</div>
`).join("")
: `<div class="empty">Сотрудников пока нет</div>`;
}
async function runAction(button, callback) {
if (button) button.disabled = true;
try {
await callback();
toast("Готово");
await loadWorkplace();
} catch (error) {
console.error(error);
toast(error.message || "Ошибка", "error");
} finally {
if (button) button.disabled = false;
}
}
document.querySelector("#centerSelect").addEventListener("change", async (event) => {
state.activeCenterId = Number(event.currentTarget.value);
await loadWorkplace();
});
document.querySelector("#refreshBtn").addEventListener("click", () => loadWorkplace());
document.querySelector("#inviteForm").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const center = activeCenter();
const data = formData(form);
await runAction(form.querySelector('button[type="submit"]'), async () => {
const employee = await api(`/service-centers/${center.id}/employees/invite`, {
method: "POST",
body: JSON.stringify({
telegram_id: Number(data.telegram_id),
role: data.role,
}),
});
const result = document.querySelector("#inviteResult");
result.classList.remove("hidden");
result.textContent = `Передайте сотруднику команду: /accept_sto_invite ${employee.invite_token}`;
form.reset();
});
});
document.body.addEventListener("click", async (event) => {
const button = event.target.closest("button");
if (!button) return;
if (button.dataset.confirmAppointment) {
await runAction(button, () => api(`/sto/appointments/${button.dataset.confirmAppointment}/confirm`, {
method: "POST",
body: JSON.stringify({ comment: "Подтверждено в панели СТО" }),
}));
}
if (button.dataset.rejectAppointment) {
await runAction(button, () => api(`/sto/appointments/${button.dataset.rejectAppointment}/reject`, {
method: "POST",
body: JSON.stringify({ comment: "Отклонено в панели СТО" }),
}));
}
if (button.dataset.deleteAppointment) {
if (!window.confirm("Удалить бронь из панели СТО?")) return;
await runAction(button, () => api(`/sto/appointments/${button.dataset.deleteAppointment}`, {
method: "DELETE",
}));
}
if (button.dataset.createWorkOrder) {
const odometer = window.prompt("Пробег на приемке, км") || "";
await runAction(button, () => api(`/sto/appointments/${button.dataset.createWorkOrder}/create-work-order`, {
method: "POST",
body: JSON.stringify({ odometer: numberOrNull(odometer), notes: "Создано в панели СТО" }),
}));
}
if (button.dataset.startWorkOrder) {
await runAction(button, () => api(`/work-orders/${button.dataset.startWorkOrder}/start`, {
method: "POST",
body: JSON.stringify({ comment: "Взято в работу" }),
}));
}
if (button.dataset.submitWorkOrder) {
await runAction(button, () => api(`/work-orders/${button.dataset.submitWorkOrder}/submit-approval`, {
method: "POST",
body: JSON.stringify({ comment: "Смета готова к согласованию" }),
}));
}
if (button.dataset.completeWorkOrder) {
const odometer = window.prompt("Пробег на закрытии, км. Можно оставить пустым, если пробег уже указан.") || "";
await runAction(button, () => api(`/work-orders/${button.dataset.completeWorkOrder}/complete`, {
method: "POST",
body: JSON.stringify({ comment: "Работы завершены", odometer: numberOrNull(odometer) }),
}));
}
if (button.dataset.requestVehicleProfile) {
const missingFields = (button.dataset.missingFields || "").split(",").filter(Boolean);
await runAction(button, () => api(`/work-orders/${button.dataset.requestVehicleProfile}/request-vehicle-profile`, {
method: "POST",
body: JSON.stringify({ missing_fields: missingFields }),
}));
}
if (button.dataset.roleEmployee) {
await runAction(button, () => api(`/service-centers/employees/${button.dataset.roleEmployee}`, {
method: "PATCH",
body: JSON.stringify({ role: button.dataset.role, status: "active" }),
}));
}
if (button.dataset.disableEmployee) {
await runAction(button, () => api(`/service-centers/employees/${button.dataset.disableEmployee}`, {
method: "PATCH",
body: JSON.stringify({ status: "inactive" }),
}));
}
});
document.body.addEventListener("change", (event) => {
const select = event.target.closest("[data-catalog-select]");
if (!select || !select.value) return;
const item = state.catalogLookup[select.value];
if (!item) return;
const form = select.closest("form");
form.title.value = item.title || "";
form.quantity.value = item.default_quantity || 1;
form.unit_price.value = item.default_unit_price || 0;
if (form.unit) form.unit.value = item.unit || form.unit.value;
if (form.category) form.category.value = item.category || "";
if (form.work_type) form.work_type.value = item.work_type || "repair";
if (form.product_type) form.product_type.value = item.product_type || "part";
if (form.brand) form.brand.value = item.brand || "";
if (form.sku) form.sku.value = item.sku || "";
if (form.viscosity) form.viscosity.value = item.viscosity || "";
if (form.specification) form.specification.value = item.specification || "";
if (form.volume) form.volume.value = item.volume || "";
});
document.body.addEventListener("submit", async (event) => {
const form = event.target;
if (form.matches("[data-labor-form]")) {
event.preventDefault();
const data = formData(form);
await runAction(form.querySelector('button[type="submit"]'), () => api(`/work-orders/${form.dataset.laborForm}/labor-items`, {
method: "POST",
body: JSON.stringify({
title: data.title,
category: data.category || null,
quantity: Number(data.quantity || 1),
unit: data.unit || "job",
unit_price: Number(data.unit_price || 0),
work_type: data.work_type || "repair",
}),
}));
}
if (form.matches("[data-product-form]")) {
event.preventDefault();
const data = formData(form);
await runAction(form.querySelector('button[type="submit"]'), () => api(`/work-orders/${form.dataset.productForm}/product-items`, {
method: "POST",
body: JSON.stringify({
title: data.title,
category: data.category || null,
quantity: Number(data.quantity || 1),
unit: data.unit || "pcs",
unit_price: Number(data.unit_price || 0),
product_type: data.product_type || "part",
brand: data.brand || null,
sku: data.sku || null,
viscosity: data.viscosity || null,
specification: data.specification || null,
volume: numberOrNull(data.volume),
}),
}));
}
});
document.querySelector("#telegramRetryBtn").addEventListener("click", () => window.location.reload());
Promise.all([loadAuthConfig()])
.then(() => ensureUser())
.then(() => loadWorkplace())
.catch((error) => {
if (error.message === "Требуется вход через Telegram") return;
console.error(error);
toast(error.message || "Ошибка", "error");
});