const tg = window.Telegram?.WebApp; tg?.ready(); tg?.expand(); const APPROVED_SERVICE_STATUSES = new Set(["approved", "verified"]); const STO_WORKPLACE_ROLES = new Set(["owner", "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, ">") .replace(/"/g, """); } 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 = `
Нет доступного подтвержденного СТО.
`; document.querySelector("#workOrdersList").innerHTML = `
Рабочее место недоступно.
`; 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) => ``) .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 ``; }).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 ? `
Заявки${dashboard.pending_appointments}
Подтверждено${dashboard.confirmed_appointments}
Заказ-наряды${dashboard.active_work_orders}
Авто${dashboard.connected_vehicles}
Месяц${money(dashboard.revenue_month || 0)}
` : `
Сводка недоступна
`; } function renderAppointments() { const canManage = (activeCenter()?.employee_role || "owner") === "owner"; document.querySelector("#appointmentsList").innerHTML = state.appointments.length ? state.appointments.map((item) => `
${escapeHtml(item.service_name)} ${formatDateTime(item.confirmed_start_at || item.requested_start_at)} · авто #${item.vehicle_id} ${statusLabel(item.status)} ${canManage ? `
${item.status === "requested" ? `` : ""} ${["confirmed", "confirmed_by_sto"].includes(item.status) ? `` : ""}
` : ""}
`).join("") : `
Новых записей нет
`; } 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 `
${escapeHtml(item.work_order_number || `Заказ-наряд #${item.id}`)} ${item.visit_date} · авто #${item.vehicle_id} · ${item.odometer || "-"} км
${statusLabel(item.status)}
${item.customer_complaint ? `Жалоба: ${escapeHtml(item.customer_complaint)}` : ""} ${item.diagnosis ? `Диагностика: ${escapeHtml(item.diagnosis)}` : ""}
Работы: ${money(item.labor_total || 0)} Запчасти: ${money(item.product_total || 0)} Итого: ${money(item.final_total || item.total_cost || 0)}
${missingFields.length ? `
Для точного подбора не хватает: ${escapeHtml(missingVehicleFieldsText(missingFields))}.
` : ""}
${["draft", "diagnosis", "approved_by_owner"].includes(item.status) ? `` : ""} ${role === "owner" ? `` : ""} ${canComplete ? `` : ""}
`; }).join("") : `
Активных заказ-нарядов нет
`; } 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) => `
${escapeHtml(employee.first_name || employee.username || `Telegram ${employee.telegram_id || employee.user_id}`)} ${employee.telegram_id ? `Telegram ID: ${employee.telegram_id}` : `User #${employee.user_id}`} ${roleLabel(employee.role)} · ${statusLabel(employee.status)} ${employee.invite_token ? `Команда для сотрудника: /accept_sto_invite ${employee.invite_token}` : ""} ${employee.role !== "owner" ? `
` : ""}
`).join("") : `
Сотрудников пока нет
`; } 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.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) { await runAction(button, () => api(`/work-orders/${button.dataset.completeWorkOrder}/complete`, { method: "POST", body: JSON.stringify({ comment: "Работы завершены" }), })); } 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"); });