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: [],
};
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;
renderDashboard(dashboard);
renderAppointments();
renderWorkOrders();
renderStaff();
}
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";
document.querySelector("#workOrdersList").innerHTML = state.workOrders.length
? state.workOrders.map((item) => `
${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)}
${["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;
const center = activeCenter();
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.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("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,
quantity: Number(data.quantity || 1),
unit: "job",
unit_price: Number(data.unit_price || 0),
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,
quantity: Number(data.quantity || 1),
unit: "pcs",
unit_price: Number(data.unit_price || 0),
product_type: "part",
}),
}));
}
});
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");
});