Add owner work order approval page
Some checks failed
ci / test (push) Has been cancelled

This commit is contained in:
VPN SaaS Dev
2026-05-16 10:51:05 +09:00
parent ac5845d5a0
commit 545f4d088d
12 changed files with 1066 additions and 48 deletions

View File

@@ -2280,6 +2280,22 @@ async function loadCars() {
}
}
async function applyInitialRoute() {
const params = new URLSearchParams(window.location.search);
const section = params.get("section");
const carId = Number(params.get("car_id") || 0);
if (carId && state.cars.some((car) => car.id === carId)) {
state.selectedCarId = carId;
renderCars();
fillCarProfileForm();
await loadSelectedCar();
}
if (section === "carProfile") {
await openDrawerSection("carProfileSection");
window.history.replaceState({}, "", window.location.pathname);
}
}
async function selectCar(carId) {
state.selectedCarId = carId;
renderCars();
@@ -2841,7 +2857,8 @@ Promise.all([loadAuthConfig()])
initCarCatalog();
return Promise.all([loadMyServiceCenters().catch(() => []), loadCars()]);
})
.then(() => applyInitialRoute())
.catch((error) => {
if (error.message === "Требуется вход через Telegram") return;
document.body.insertAdjacentHTML("afterbegin", `<div class="error">${error.message}</div>`);
});
});

View File

@@ -13,6 +13,8 @@ const state = {
appointments: [],
workOrders: [],
employees: [],
catalogsByVehicleId: {},
catalogLookup: {},
};
function authHeaders(extra = {}) {
@@ -184,12 +186,57 @@ async function loadWorkplace() {
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.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
? `
@@ -223,42 +270,70 @@ function renderAppointments() {
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) => `
<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>
? 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>
<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("")
`;
}).join("")
: `<div class="empty">Активных заказ-нарядов нет</div>`;
}
@@ -331,7 +406,6 @@ document.querySelector("#inviteForm").addEventListener("submit", async (event) =
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",
@@ -369,6 +443,13 @@ document.body.addEventListener("click", async (event) => {
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",
@@ -383,6 +464,26 @@ document.body.addEventListener("click", async (event) => {
}
});
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]")) {
@@ -392,10 +493,11 @@ document.body.addEventListener("submit", async (event) => {
method: "POST",
body: JSON.stringify({
title: data.title,
category: data.category || null,
quantity: Number(data.quantity || 1),
unit: "job",
unit: data.unit || "job",
unit_price: Number(data.unit_price || 0),
work_type: "repair",
work_type: data.work_type || "repair",
}),
}));
}
@@ -406,10 +508,16 @@ document.body.addEventListener("submit", async (event) => {
method: "POST",
body: JSON.stringify({
title: data.title,
category: data.category || null,
quantity: Number(data.quantity || 1),
unit: "pcs",
unit: data.unit || "pcs",
unit_price: Number(data.unit_price || 0),
product_type: "part",
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),
}),
}));
}

View File

@@ -524,7 +524,8 @@ body {
button,
input,
select {
select,
textarea {
font: inherit;
}
@@ -697,7 +698,8 @@ label {
}
input,
select {
select,
textarea {
width: 100%;
min-height: 42px;
border: 1px solid var(--line);
@@ -712,12 +714,19 @@ select {
}
input:focus,
select:focus {
select:focus,
textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 4px rgba(22, 128, 106, 0.12);
}
textarea {
min-height: 86px;
padding: 10px 11px;
resize: vertical;
}
select:disabled {
background: #f1f5f3;
color: #9aa4a0;
@@ -1725,6 +1734,74 @@ select {
overflow-wrap: anywhere;
}
.work-order-page {
background:
linear-gradient(180deg, #ffffff 0, #f3f7f5 240px),
var(--bg);
}
.work-order-shell {
width: min(1120px, 100%);
}
.work-order-hero {
grid-template-columns: minmax(0, 1fr) auto;
}
.work-order-total {
display: grid;
gap: 4px;
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.07);
}
.work-order-total span {
color: rgba(244, 251, 248, 0.68);
font-size: 12px;
}
.work-order-total strong {
color: #fff;
font-size: clamp(24px, 4vw, 34px);
}
.work-order-layout {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
align-items: start;
}
.work-order-decision {
grid-column: 1 / -1;
display: grid;
gap: 12px;
}
.work-order-decision p {
margin: 8px 0 0;
color: var(--muted);
}
.single-total {
grid-template-columns: 1fr;
}
.catalog-work-form {
grid-template-columns: minmax(160px, 1.1fr) minmax(0, 1.3fr) minmax(74px, 0.5fr) minmax(86px, 0.7fr) auto;
}
.compact-tip {
display: grid;
gap: 8px;
}
.compact-tip button {
width: fit-content;
}
@keyframes toastIn {
from {
opacity: 0;
@@ -1807,6 +1884,7 @@ select {
}
.sto-grid,
.work-order-layout,
.staff-form {
grid-template-columns: 1fr;
}
@@ -1822,6 +1900,10 @@ select {
min-width: 70vw;
scroll-snap-align: start;
}
.catalog-work-form {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
@@ -1926,6 +2008,10 @@ select {
grid-template-columns: 1fr;
}
.work-order-hero {
grid-template-columns: 1fr;
}
.sto-page .top-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) 44px;

210
web/static/work_order.js Normal file
View File

@@ -0,0 +1,210 @@
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, "&amp;")
.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");
});

92
web/work_order.html Normal file
View File

@@ -0,0 +1,92 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#16806a" />
<title>Заказ-наряд</title>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="stylesheet" href="/static/styles.css" />
<script src="https://telegram.org/js/telegram-web-app.js"></script>
</head>
<body class="auth-required work-order-page">
<div class="auth-overlay" id="authOverlay">
<div class="auth-panel">
<p class="eyebrow">CarPass</p>
<h1>Заказ-наряд</h1>
<p id="authMessage">Откройте страницу через Telegram-бота, чтобы подтвердить доступ к заказ-наряду.</p>
<div class="auth-actions">
<a id="telegramLoginLink" class="telegram-login-link hidden" href="#" rel="noreferrer">Открыть в Telegram</a>
<button id="telegramRetryBtn" class="telegram-secondary-btn" type="button">Проверить вход</button>
</div>
</div>
</div>
<main class="shell work-order-shell">
<header class="topbar">
<div>
<p class="eyebrow">Согласование</p>
<h1>Заказ-наряд</h1>
</div>
<button class="icon-btn" id="refreshBtn" title="Обновить" aria-label="Обновить"></button>
</header>
<section class="passport-panel work-order-hero">
<div class="passport-head">
<div>
<p class="eyebrow" id="centerName">СТО</p>
<h2 id="orderTitle">Загружаю...</h2>
<small id="vehicleMeta">Проверяю доступ</small>
</div>
<span class="trust-badge" id="statusBadge">Статус</span>
</div>
<div class="work-order-total">
<span>Итого к согласованию</span>
<strong id="orderTotal">-</strong>
</div>
</section>
<section class="work-order-layout">
<section class="workspace">
<div class="section-head">
<div>
<p class="eyebrow">Состав</p>
<h2>Работы</h2>
</div>
</div>
<div id="laborList" class="stack-list"></div>
</section>
<section class="workspace">
<div class="section-head">
<div>
<p class="eyebrow">Материалы</p>
<h2>Запчасти и жидкости</h2>
</div>
</div>
<div id="productList" class="stack-list"></div>
</section>
<section class="workspace work-order-decision">
<div>
<p class="eyebrow">Решение владельца</p>
<h2 id="decisionTitle">Проверьте смету</h2>
<p id="decisionText">Если все понятно, согласуйте заказ-наряд. После согласования СТО сможет завершить работы.</p>
</div>
<label>
Комментарий
<textarea id="ownerComment" rows="3" placeholder="Например: согласен, но старые детали прошу оставить в багажнике"></textarea>
</label>
<div class="row-actions">
<button type="button" id="approveBtn">Согласовать</button>
<button type="button" class="ghost-btn" id="rejectBtn">Отклонить</button>
</div>
<a class="telegram-login-link hidden" id="fillProfileLink" href="/">Заполнить карточку авто</a>
</section>
</section>
</main>
<div class="toast hidden" id="toast" role="status" aria-live="polite"></div>
<script src="/static/work_order.js"></script>
</body>
</html>