Files
drivers_bot/web/static/work_order.js
VPN SaaS Dev 545f4d088d
Some checks failed
ci / test (push) Has been cancelled
Add owner work order approval page
2026-05-16 10:51:05 +09:00

211 lines
8.3 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 state = {
user: null,
authConfig: null,
detail: null,
};
function orderId() {
return Number(new URLSearchParams(window.location.search).get("id") || 0);
}
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, currency = "RUB") {
return Number(value || 0).toLocaleString("ru-RU", {
style: "currency",
currency,
maximumFractionDigits: currency === "KRW" ? 0 : 2,
});
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function statusLabel(status) {
return {
draft: "Черновик",
diagnosis: "Диагностика",
waiting_owner_approval: "Ждет согласования",
approved_by_owner: "Согласован",
rejected_by_owner: "Отклонен",
in_progress: "В работе",
completed: "Завершен",
cancelled: "Отменен",
}[status] || status || "Без статуса";
}
function itemTotal(item) {
return item.total ?? item.price ?? Number(item.quantity || 0) * Number(item.unit_price || 0);
}
function renderItems(rootSelector, items, emptyText) {
const root = document.querySelector(rootSelector);
root.innerHTML = items.length
? items.map((item) => `
<div class="stack-item work-order-card">
<strong>${escapeHtml(item.title)}</strong>
${item.description ? `<small>${escapeHtml(item.description)}</small>` : ""}
<small>${Number(item.quantity || 1)} ${escapeHtml(item.unit || "шт")} × ${money(item.unit_price || item.price || 0, state.detail.visit.currency)}</small>
<div class="work-order-totals single-total">
<span>Сумма: <strong>${money(itemTotal(item), state.detail.visit.currency)}</strong></span>
</div>
</div>
`).join("")
: `<div class="empty">${emptyText}</div>`;
}
function renderDecision(detail) {
const status = detail.visit.status;
const isWaiting = status === "waiting_owner_approval";
document.querySelector("#approveBtn").classList.toggle("hidden", !isWaiting);
document.querySelector("#rejectBtn").classList.toggle("hidden", !isWaiting);
document.querySelector("#ownerComment").disabled = !isWaiting;
if (isWaiting) {
document.querySelector("#decisionTitle").textContent = "Нужно ваше решение";
document.querySelector("#decisionText").textContent = "Проверьте работы, материалы и итоговую сумму. Решение попадет в историю заказ-наряда.";
return;
}
if (status === "approved_by_owner") {
document.querySelector("#decisionTitle").textContent = "Заказ-наряд согласован";
document.querySelector("#decisionText").textContent = "СТО может выполнять и закрывать работы по согласованной смете.";
return;
}
if (status === "completed") {
document.querySelector("#decisionTitle").textContent = "Работы завершены";
document.querySelector("#decisionText").textContent = "Заказ-наряд сохранен в истории автомобиля.";
return;
}
document.querySelector("#decisionTitle").textContent = "Решение сейчас не требуется";
document.querySelector("#decisionText").textContent = "Когда СТО отправит смету на согласование, здесь появятся кнопки решения.";
}
function renderProfileLink(detail) {
const link = document.querySelector("#fillProfileLink");
const missing = detail.catalog?.missing_vehicle_fields || [];
if (!missing.length || detail.visit.status === "completed") {
link.classList.add("hidden");
return;
}
link.href = `/?section=carProfile&car_id=${detail.vehicle.id}`;
link.classList.remove("hidden");
}
async function loadDetail() {
const id = orderId();
if (!id) throw new Error("Не указан заказ-наряд");
state.detail = await api(`/work-orders/${id}/detail`);
const detail = state.detail;
document.querySelector("#centerName").textContent = detail.service_center.display_name || detail.service_center.name;
document.querySelector("#orderTitle").textContent = detail.visit.work_order_number || `Заказ-наряд #${detail.visit.id}`;
document.querySelector("#vehicleMeta").textContent = [
detail.vehicle.name,
detail.vehicle.license_plate_display,
detail.visit.odometer ? `${detail.visit.odometer} км` : "",
].filter(Boolean).join(" · ");
document.querySelector("#statusBadge").textContent = statusLabel(detail.visit.status);
document.querySelector("#orderTotal").textContent = money(detail.visit.final_total || detail.visit.total_cost || 0, detail.visit.currency);
document.querySelector("#ownerComment").value = detail.visit.owner_comment || "";
renderItems("#laborList", detail.work_items || [], "Работы пока не добавлены");
renderItems("#productList", detail.product_items || [], "Материалы пока не добавлены");
renderDecision(detail);
renderProfileLink(detail);
}
async function decide(action) {
const id = orderId();
const comment = document.querySelector("#ownerComment").value.trim() || null;
const button = action === "approve" ? document.querySelector("#approveBtn") : document.querySelector("#rejectBtn");
button.disabled = true;
try {
await api(`/work-orders/${id}/${action}`, {
method: "POST",
body: JSON.stringify({ comment }),
});
toast(action === "approve" ? "Заказ-наряд согласован" : "Заказ-наряд отклонен");
await loadDetail();
} catch (error) {
console.error(error);
toast(error.message || "Ошибка", "error");
} finally {
button.disabled = false;
}
}
document.querySelector("#refreshBtn").addEventListener("click", () => loadDetail());
document.querySelector("#approveBtn").addEventListener("click", () => decide("approve"));
document.querySelector("#rejectBtn").addEventListener("click", () => decide("reject"));
document.querySelector("#telegramRetryBtn").addEventListener("click", () => window.location.reload());
Promise.all([loadAuthConfig()])
.then(() => ensureUser())
.then(() => loadDetail())
.catch((error) => {
if (error.message === "Требуется вход через Telegram") return;
console.error(error);
toast(error.message || "Ошибка", "error");
});