Mechanic's work place
Some checks failed
ci / test (push) Has been cancelled

This commit is contained in:
VPN SaaS Dev
2026-05-16 10:04:56 +09:00
parent fec9635079
commit 83ad880b9d
39 changed files with 2951 additions and 74 deletions

View File

@@ -244,6 +244,14 @@
<h2>Меню</h2>
<button class="icon-btn" id="closeMenuBtn" aria-label="Закрыть">×</button>
</div>
<section class="service-profile-card hidden" id="serviceProfileCard">
<div>
<p class="eyebrow">Профиль СТО</p>
<strong id="serviceProfileTitle">Рабочее место</strong>
<small id="serviceProfileMeta">Доступно после регистрации СТО</small>
</div>
<button class="wide-btn" type="button" data-menu-section="mechanicWorkplaceSection">Открыть рабочее место</button>
</section>
<button class="menu-row" data-menu-section="carsSection">Автомобили</button>
<button class="menu-row" data-menu-section="carFormSection">Добавить авто</button>
<button class="menu-row" data-menu-section="carProfileSection">Параметры авто</button>
@@ -259,7 +267,8 @@
<button class="menu-row" data-menu-section="reviewsSection">Отзывы</button>
<button class="menu-row" data-menu-section="confirmationsSection">Подтверждения</button>
<button class="menu-row" data-menu-section="connectedServicesSection">Подключённые СТО</button>
<button class="menu-row" data-menu-section="stoCalendarSection">Календарь СТО</button>
<button class="menu-row sto-only hidden" data-menu-section="mechanicWorkplaceSection">Рабочее место механика</button>
<button class="menu-row sto-only hidden" data-menu-section="stoCalendarSection">Календарь СТО</button>
<button class="menu-row admin-only hidden" data-menu-section="adminSection">Админ</button>
<button class="menu-row" data-menu-section="settingsSection">Настройки</button>
@@ -534,6 +543,18 @@
<div id="stoCalendarList" class="stack-list"></div>
</section>
<section class="drawer-section hidden" id="mechanicWorkplaceSection">
<div class="section-head">
<div>
<p class="eyebrow">СТО</p>
<h2>Рабочее место механика</h2>
</div>
<select id="mechanicCenterSelect" aria-label="СТО"></select>
</div>
<div id="mechanicDashboardSummary" class="stats mini-stats"></div>
<div id="mechanicWorkplaceList" class="stack-list"></div>
</section>
<section class="drawer-section hidden" id="adminSection">
<h2>Модерация СТО</h2>
<div class="tip-card">Заявки видны только администраторам и модераторам.</div>

View File

@@ -318,6 +318,9 @@ const state = {
allStats: null,
analytics: null,
serviceCenters: [],
activeServiceCenterId: null,
mechanicAppointments: [],
mechanicWorkOrders: [],
publicServiceCenters: [],
appointments: [],
maintenanceRecommendations: [],
@@ -640,6 +643,10 @@ function selectedCar() {
return state.cars.find((car) => car.id === state.selectedCarId) || null;
}
function activeServiceCenter() {
return state.serviceCenters.find((center) => center.id === state.activeServiceCenterId) || state.serviceCenters[0] || null;
}
function numberOrNull(value) {
return value === "" || value == null ? null : Number(value);
}
@@ -1024,17 +1031,44 @@ function openCarProfile() {
openDrawerSection("carProfileSection");
}
async function loadServiceCenters() {
async function loadMyServiceCenters({ withTrust = false } = {}) {
const centers = await api("/service-centers/my");
state.serviceCenters = await Promise.all(
centers.map(async (center) => {
try {
return { ...center, trust_score: await api(`/service-centers/${center.id}/trust-score`) };
} catch (_) {
return center;
}
}),
);
state.serviceCenters = withTrust
? await Promise.all(
centers.map(async (center) => {
try {
return { ...center, trust_score: await api(`/service-centers/${center.id}/trust-score`) };
} catch (_) {
return center;
}
}),
)
: centers;
if (!state.activeServiceCenterId && state.serviceCenters.length) {
state.activeServiceCenterId = state.serviceCenters[0].id;
}
if (state.activeServiceCenterId && !state.serviceCenters.some((center) => center.id === state.activeServiceCenterId)) {
state.activeServiceCenterId = state.serviceCenters[0]?.id || null;
}
renderServiceProfileCard();
return state.serviceCenters;
}
function renderServiceProfileCard() {
const card = document.querySelector("#serviceProfileCard");
if (!card) return;
const hasCenters = state.serviceCenters.length > 0;
card.classList.toggle("hidden", !hasCenters);
document.querySelectorAll(".sto-only").forEach((node) => node.classList.toggle("hidden", !hasCenters));
if (!hasCenters) return;
const center = state.serviceCenters.find((item) => item.id === state.activeServiceCenterId) || state.serviceCenters[0];
const role = serviceRoleLabel(center.employee_role || "owner");
document.querySelector("#serviceProfileTitle").textContent = center.display_name || center.name || "Рабочее место";
document.querySelector("#serviceProfileMeta").textContent = `${role} · ${serviceStatusLabel(center.verification_status)}`;
}
async function loadServiceCenters() {
await loadMyServiceCenters({ withTrust: true });
renderServiceCenters();
}
@@ -1418,6 +1452,226 @@ async function loadStoCalendar() {
}
}
async function loadMechanicWorkplace() {
const centerSelect = document.querySelector("#mechanicCenterSelect");
const summary = document.querySelector("#mechanicDashboardSummary");
const list = document.querySelector("#mechanicWorkplaceList");
if (!centerSelect || !summary || !list) return;
try {
if (!state.serviceCenters.length) await loadMyServiceCenters();
if (!state.serviceCenters.length) {
centerSelect.innerHTML = "";
summary.innerHTML = "";
list.innerHTML = `<div class="empty">Сначала зарегистрируйте СТО или примите приглашение сотрудника.</div>`;
return;
}
centerSelect.innerHTML = state.serviceCenters
.map((center) => `<option value="${center.id}">${escapeHtml(center.display_name || center.name)}</option>`)
.join("");
centerSelect.value = String(state.activeServiceCenterId || state.serviceCenters[0].id);
const serviceCenterId = Number(centerSelect.value);
const center = state.serviceCenters.find((item) => item.id === serviceCenterId) || state.serviceCenters[0];
state.activeServiceCenterId = serviceCenterId;
renderServiceProfileCard();
const [dashboard, appointments, visits] = await Promise.all([
api(`/sto/dashboard?service_center_id=${serviceCenterId}`).catch(() => null),
api(`/sto/appointments?service_center_id=${serviceCenterId}`).catch(() => []),
api(`/service-centers/${serviceCenterId}/visits`).catch(() => []),
]);
state.mechanicAppointments = appointments.filter((item) =>
["requested", "confirmed", "confirmed_by_sto", "proposed_new_time"].includes(item.status),
);
state.mechanicWorkOrders = visits.filter((item) =>
!["completed", "cancelled", "archived", "confirmed", "disputed"].includes(item.status),
);
summary.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="empty">Сводка недоступна</div>`;
const centerNotice = center.verification_status && !["approved", "verified"].includes(center.verification_status)
? `<div class="tip-card">СТО сейчас в статусе «${serviceStatusLabel(center.verification_status)}». Часть действий может быть недоступна до проверки.</div>`
: "";
const appointmentMarkup = state.mechanicAppointments.map(renderMechanicAppointment).join("");
const workOrderMarkup = state.mechanicWorkOrders.map(renderMechanicWorkOrder).join("");
list.innerHTML = `
${centerNotice}
<h3 class="list-heading">Записи</h3>
${appointmentMarkup || `<div class="empty">Новых записей нет</div>`}
<h3 class="list-heading">Заказ-наряды</h3>
${workOrderMarkup || `<div class="empty">Активных заказ-нарядов нет</div>`}
`;
bindMechanicWorkplaceActions(list);
} catch (error) {
summary.innerHTML = "";
list.innerHTML = `<div class="empty">Рабочее место не загрузилось</div>`;
}
}
function renderMechanicAppointment(item) {
const role = activeServiceCenter()?.employee_role || "owner";
const canManageAppointments = ["owner", "manager", "receptionist"].includes(role);
const canCreateWorkOrder = canManageAppointments && ["confirmed", "confirmed_by_sto"].includes(item.status);
return `
<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">${appointmentStatusLabel(item.status)}</span>
<div class="row-actions">
${canManageAppointments && item.status === "requested" ? `<button type="button" data-mechanic-confirm-appointment="${item.id}">Подтвердить</button>` : ""}
${canCreateWorkOrder ? `<button type="button" data-create-work-order="${item.id}">Открыть заказ-наряд</button>` : ""}
${canManageAppointments ? `<button type="button" class="ghost-btn" data-mechanic-reject-appointment="${item.id}">Отклонить</button>` : ""}
</div>
</div>
`;
}
function renderMechanicWorkOrder(item) {
const role = activeServiceCenter()?.employee_role || "owner";
const canEditItems = ["owner", "manager", "mechanic"].includes(role);
const canStart = ["owner", "manager", "mechanic"].includes(role)
&& ["draft", "diagnosis", "approved_by_owner"].includes(item.status);
const canSubmitApproval = ["owner", "manager", "receptionist"].includes(role)
&& ["draft", "diagnosis", "in_progress", "rejected_by_owner"].includes(item.status);
const canComplete = ["owner", "manager"].includes(role)
&& ["draft", "diagnosis", "approved_by_owner", "in_progress"].includes(item.status);
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">${workOrderStatusLabel(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>
${canEditItems ? `<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">
${canStart ? `<button type="button" data-start-work-order="${item.id}">В работу</button>` : ""}
${canSubmitApproval ? `<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>
`;
}
function bindMechanicWorkplaceActions(root) {
root.querySelectorAll("[data-mechanic-confirm-appointment]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Подтверждаю...", async () => {
await api(`/sto/appointments/${button.dataset.mechanicConfirmAppointment}/confirm`, {
method: "POST",
body: JSON.stringify({ comment: "Подтверждено в рабочем месте СТО" }),
});
await loadMechanicWorkplace();
}));
});
root.querySelectorAll("[data-mechanic-reject-appointment]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Отклоняю...", async () => {
await api(`/sto/appointments/${button.dataset.mechanicRejectAppointment}/reject`, {
method: "POST",
body: JSON.stringify({ comment: "Отклонено в рабочем месте СТО" }),
});
await loadMechanicWorkplace();
}));
});
root.querySelectorAll("[data-create-work-order]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Открываю заказ-наряд...", async () => {
const odometerValue = window.prompt("Пробег на приемке, км") || "";
await api(`/sto/appointments/${button.dataset.createWorkOrder}/create-work-order`, {
method: "POST",
body: JSON.stringify({ odometer: numberOrNull(odometerValue), notes: "Создано в рабочем месте СТО" }),
});
await loadMechanicWorkplace();
}));
});
root.querySelectorAll("[data-labor-form]").forEach((form) => {
form.addEventListener("submit", async (event) => {
event.preventDefault();
await runAction(form.querySelector('button[type="submit"]'), "Добавляю работу...", async () => {
const data = formData(form);
await 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",
}),
});
await loadMechanicWorkplace();
});
});
});
root.querySelectorAll("[data-product-form]").forEach((form) => {
form.addEventListener("submit", async (event) => {
event.preventDefault();
await runAction(form.querySelector('button[type="submit"]'), "Добавляю материал...", async () => {
const data = formData(form);
await 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",
}),
});
await loadMechanicWorkplace();
});
});
});
root.querySelectorAll("[data-start-work-order]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Запускаю работу...", async () => {
await api(`/work-orders/${button.dataset.startWorkOrder}/start`, {
method: "POST",
body: JSON.stringify({ comment: "Взято в работу" }),
});
await loadMechanicWorkplace();
}));
});
root.querySelectorAll("[data-submit-work-order]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Отправляю на согласование...", async () => {
await api(`/work-orders/${button.dataset.submitWorkOrder}/submit-approval`, {
method: "POST",
body: JSON.stringify({ comment: "Смета готова к согласованию" }),
});
await loadMechanicWorkplace();
}));
});
root.querySelectorAll("[data-complete-work-order]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Завершаю заказ-наряд...", async () => {
await api(`/work-orders/${button.dataset.completeWorkOrder}/complete`, {
method: "POST",
body: JSON.stringify({ comment: "Работы завершены" }),
});
await loadMechanicWorkplace();
}));
});
}
function trustLabel(level) {
const labels = {
new_service: "Новый сервис",
@@ -1428,6 +1682,71 @@ function trustLabel(level) {
return labels[level] || "Новый сервис";
}
function serviceRoleLabel(role) {
const labels = {
owner: "Владелец",
manager: "Менеджер",
receptionist: "Администратор",
mechanic: "Механик",
};
return labels[role] || role || "Сотрудник";
}
function serviceStatusLabel(status) {
const labels = {
draft: "Черновик",
pending: "На проверке",
needs_changes: "Нужны правки",
rejected: "Отклонено",
approved: "Проверено",
verified: "Проверено",
suspended: "Приостановлено",
};
return labels[status] || status || "Статус не указан";
}
function workOrderStatusLabel(status) {
const labels = {
draft: "Черновик",
diagnosis: "Диагностика",
waiting_owner_approval: "Ждет согласования",
approved_by_owner: "Согласован",
rejected_by_owner: "Отклонен клиентом",
in_progress: "В работе",
completed: "Завершен",
cancelled: "Отменен",
archived: "Архив",
pending_owner_confirmation: "Ждет клиента",
confirmed: "Подтвержден",
disputed: "Спор",
};
return labels[status] || status || "Без статуса";
}
function appointmentStatusLabel(status) {
const labels = {
requested: "Новая заявка",
confirmed: "Подтверждена клиентом",
confirmed_by_sto: "Подтверждена СТО",
proposed_new_time: "Предложено другое время",
converted_to_work_order: "Заказ-наряд создан",
completed: "Завершена",
rejected_by_sto: "Отклонена СТО",
cancelled_by_owner: "Отменена владельцем",
cancelled_by_customer: "Отменена клиентом",
cancelled_by_sto: "Отменена СТО",
};
return labels[status] || status || "Без статуса";
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function renderPlaceholderList(selector, message) {
const root = document.querySelector(selector);
if (root) root.innerHTML = `<div class="empty">${message}</div>`;
@@ -2260,6 +2579,7 @@ async function openDrawerSection(sectionId, options = {}) {
if (sectionId === "appointmentsSection") await loadAppointments();
if (sectionId === "maintenanceRecommendationsSection") await loadMaintenanceRecommendations();
if (sectionId === "stoCalendarSection") await loadStoCalendar();
if (sectionId === "mechanicWorkplaceSection") await loadMechanicWorkplace();
if (sectionId === "reviewsSection") renderServiceReviews();
if (sectionId === "adminSection") await loadAdminPendingServices();
if (options.expenseCategory) {
@@ -2329,6 +2649,11 @@ document.querySelectorAll("[data-menu-section]").forEach((button) => {
});
});
document.querySelector("#mechanicCenterSelect")?.addEventListener("change", async (event) => {
state.activeServiceCenterId = Number(event.currentTarget.value);
await runAction(event.currentTarget, "Обновляю рабочее место...", loadMechanicWorkplace);
});
document.querySelectorAll("[data-expense-preset]").forEach((button) => {
button.addEventListener("click", () => {
openDrawerSection("expensesSection");
@@ -2453,7 +2778,7 @@ Promise.all([loadAuthConfig()])
mountEntryForms();
applyTranslations();
initCarCatalog();
return loadCars();
return Promise.all([loadMyServiceCenters().catch(() => []), loadCars()]);
})
.catch((error) => {
if (error.message === "Требуется вход через Telegram") return;

View File

@@ -1171,6 +1171,31 @@ button.is-busy {
color: #0e604f;
}
.service-profile-card {
display: grid;
gap: 10px;
margin: 4px 0 12px;
padding: 12px;
border: 1px solid rgba(18, 115, 95, 0.24);
border-radius: 8px;
background:
linear-gradient(135deg, rgba(18, 115, 95, 0.1), rgba(47, 111, 159, 0.08)),
#fff;
}
.service-profile-card strong {
display: block;
margin-top: 2px;
}
.service-profile-card small {
color: var(--muted);
}
.service-profile-card.hidden {
display: none;
}
.drawer-cars {
max-height: 320px;
overflow: auto;
@@ -1466,6 +1491,48 @@ select {
color: var(--muted);
}
.list-heading {
margin: 12px 0 2px;
color: var(--text);
font-size: 14px;
}
.work-order-card {
background: #fff;
}
.work-order-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.work-order-totals {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin: 6px 0;
color: var(--muted);
font-size: 12px;
}
.inline-work-form {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(74px, 0.6fr) minmax(86px, 0.8fr) auto;
gap: 8px;
align-items: end;
}
.inline-work-form input {
min-height: 36px;
}
.inline-work-form button {
min-height: 36px;
padding: 0 10px;
}
.row-actions {
display: flex;
flex-wrap: wrap;
@@ -1650,6 +1717,16 @@ select {
overflow-wrap: anywhere;
}
.work-order-head,
.work-order-totals,
.inline-work-form {
grid-template-columns: 1fr;
}
.work-order-head {
display: grid;
}
.auth-overlay {
align-items: stretch;
padding: 14px;