This commit is contained in:
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user