Files
drivers_bot/web/static/sto.js
VPN SaaS Dev ac5845d5a0
Some checks failed
ci / test (push) Has been cancelled
Gate STO workplace by role
2026-05-16 10:33:33 +09:00

428 lines
18 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", "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, "&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;
renderDashboard(dashboard);
renderAppointments();
renderWorkOrders();
renderStaff();
}
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 canManage = (activeCenter()?.employee_role || "owner") === "owner";
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>
</div>` : ""}
</div>
`).join("")
: `<div class="empty">Новых записей нет</div>`;
}
function renderWorkOrders() {
const role = activeCenter()?.employee_role || "owner";
const canComplete = role === "owner";
document.querySelector("#workOrdersList").innerHTML = state.workOrders.length
? state.workOrders.map((item) => `
<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>
<form class="inline-work-form" data-labor-form="${item.id}">
<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 />
<button type="submit">+ Работа</button>
</form>
<form class="inline-work-form" data-product-form="${item.id}">
<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 />
<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;
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");
});