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

@@ -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),
}),
}));
}