Add STO booking and maintenance automation

This commit is contained in:
VPN SaaS Dev
2026-05-15 05:17:54 +09:00
parent 2be7ba2099
commit fec9635079
12 changed files with 2178 additions and 5 deletions

View File

@@ -319,6 +319,9 @@ const state = {
analytics: null,
serviceCenters: [],
publicServiceCenters: [],
appointments: [],
maintenanceRecommendations: [],
stoCalendar: [],
confirmations: null,
connectedServices: [],
adminPendingServices: [],
@@ -1060,7 +1063,7 @@ async function loadPublicServiceCenters() {
const root = document.querySelector("#publicServiceCenters");
if (!root) return;
try {
const centers = await api("/service-centers/public");
const centers = await api("/sto/catalog");
state.publicServiceCenters = centers;
root.innerHTML = centers.length
? centers
@@ -1070,7 +1073,7 @@ async function loadPublicServiceCenters() {
<strong>${center.display_name || center.name}</strong>
<small>${[center.city, center.address].filter(Boolean).join(", ") || "Адрес не указан"}</small>
<small>${center.specializations?.join(", ") || "Специализация не указана"}</small>
<span class="trust-badge">${center.rating_avg ? `${center.rating_avg}` : "Проверка пройдена"}</span>
<span class="trust-badge">${center.nearest_slot_at ? `Окно ${formatDateTime(center.nearest_slot_at)}` : center.rating_avg ? `${center.rating_avg}` : "Проверка пройдена"}</span>
</button>
`,
)
@@ -1124,6 +1127,33 @@ async function openServiceCard(serviceCenterId) {
<div class="service-actions">
<button type="button" class="ghost-btn" id="attachServiceBtn">Привязать выбранное авто</button>
</div>
<form class="grid-form drawer-form" id="serviceBookingForm">
<label>
Услуга
<select name="service_type">
<option value="oil_change">Замена масла</option>
<option value="diagnostics">Диагностика</option>
<option value="maintenance">ТО</option>
<option value="tire_service">Шиномонтаж</option>
<option value="brakes">Тормоза</option>
<option value="repair">Ремонт</option>
<option value="other">Другое</option>
</select>
</label>
<label>
Дата
<input name="date" type="date" value="${today()}" />
</label>
<label>
Свободное окно
<select name="slot" id="bookingSlotSelect"></select>
</label>
<label>
Комментарий
<input name="customer_comment" placeholder="Что нужно сделать" />
</label>
<button type="submit">Записаться</button>
</form>
<form class="grid-form drawer-form" id="serviceReviewForm">
<label>
Оценка
@@ -1182,9 +1212,212 @@ async function openServiceCard(serviceCenterId) {
haptic("success");
});
});
const bookingForm = card.querySelector("#serviceBookingForm");
const reloadSlots = () => loadServiceBookingSlots(serviceCenterId, bookingForm);
bookingForm.querySelector('[name="service_type"]').addEventListener("change", reloadSlots);
bookingForm.querySelector('[name="date"]').addEventListener("change", reloadSlots);
bookingForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (!state.selectedCarId) {
toast("Выбери автомобиль", "error");
return;
}
const data = formData(bookingForm);
if (!data.slot) {
toast("Выбери свободное окно", "error");
return;
}
await runAction(bookingForm.querySelector('button[type="submit"]'), "Создаю запись...", async () => {
await api("/appointments", {
method: "POST",
body: JSON.stringify({
service_center_id: serviceCenterId,
vehicle_id: state.selectedCarId,
service_type: data.service_type,
service_name: bookingServiceName(data.service_type),
requested_start_at: data.slot,
customer_comment: data.customer_comment || null,
}),
});
await loadAppointments();
toast("Заявка отправлена в СТО");
haptic("success");
});
});
await reloadSlots();
card.scrollIntoView({ behavior: "smooth", block: "start" });
}
function bookingServiceName(type) {
const names = {
oil_change: "Замена масла",
diagnostics: "Диагностика",
maintenance: "ТО",
tire_service: "Шиномонтаж",
brakes: "Тормоза",
repair: "Ремонт",
other: "Другое",
};
return names[type] || "Обслуживание";
}
function formatDateTime(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value).slice(0, 16).replace("T", " ");
return date.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
}
async function loadServiceBookingSlots(serviceCenterId, form) {
const select = form.querySelector("#bookingSlotSelect");
const serviceType = form.querySelector('[name="service_type"]').value;
const date = form.querySelector('[name="date"]').value || today();
select.innerHTML = `<option value="">Загружаю...</option>`;
try {
const slots = await api(`/sto/${serviceCenterId}/available-slots?service_type=${encodeURIComponent(serviceType)}&date_from=${date}&date_to=${date}`);
select.innerHTML = slots.length
? slots.map((slot) => `<option value="${slot.start_at}">${formatDateTime(slot.start_at)}</option>`).join("")
: `<option value="">Нет свободных окон</option>`;
} catch (error) {
select.innerHTML = `<option value="">Слоты не загрузились</option>`;
}
}
async function loadAppointments() {
const root = document.querySelector("#appointmentsList");
if (!root) return;
try {
state.appointments = await api("/appointments/my");
root.innerHTML = state.appointments.length
? state.appointments.map((item) => `
<div class="stack-item">
<strong>${item.service_name}</strong>
<small>${formatDateTime(item.confirmed_start_at || item.proposed_start_at || item.requested_start_at)}</small>
<span class="trust-badge">${item.status}</span>
${item.status === "proposed_new_time" ? `
<div class="service-actions">
<button type="button" data-accept-appointment="${item.id}">Принять время</button>
<button type="button" class="ghost-btn" data-reject-appointment="${item.id}">Отклонить</button>
</div>
` : ""}
</div>
`).join("")
: `<div class="empty">Записей пока нет</div>`;
root.querySelectorAll("[data-accept-appointment]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Сохраняю...", async () => {
await api(`/appointments/${button.dataset.acceptAppointment}/accept-proposed-time`, { method: "POST" });
await loadAppointments();
}));
});
root.querySelectorAll("[data-reject-appointment]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Сохраняю...", async () => {
await api(`/appointments/${button.dataset.rejectAppointment}/reject-proposed-time`, {
method: "POST",
body: JSON.stringify({ comment: "Отклонено в Mini App" }),
});
await loadAppointments();
}));
});
} catch (error) {
root.innerHTML = `<div class="empty">Записи не загрузились</div>`;
}
}
async function loadMaintenanceRecommendations() {
const root = document.querySelector("#maintenanceRecommendations");
if (!root) return;
if (!state.selectedCarId) {
root.innerHTML = `<div class="empty">Выбери автомобиль</div>`;
return;
}
try {
state.maintenanceRecommendations = await api(`/vehicles/${state.selectedCarId}/maintenance-recommendations`);
root.innerHTML = state.maintenanceRecommendations.length
? state.maintenanceRecommendations.map((item) => `
<div class="stack-item">
<strong>${item.title}</strong>
<small>${item.description || "Плановое обслуживание"}</small>
<small>${[item.due_odometer_km ? `${item.due_odometer_km} км` : "", item.due_date || ""].filter(Boolean).join(" · ")}</small>
<span class="trust-badge">${item.priority} · ${item.status}</span>
${item.status === "active" ? `<button type="button" class="ghost-btn" data-dismiss-recommendation="${item.id}">Скрыть</button>` : ""}
</div>
`).join("")
: `<div class="empty">Рекомендаций пока нет</div>`;
root.querySelectorAll("[data-dismiss-recommendation]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Скрываю...", async () => {
await api(`/maintenance-recommendations/${button.dataset.dismissRecommendation}/dismiss`, { method: "POST" });
await loadMaintenanceRecommendations();
}));
});
} catch (error) {
root.innerHTML = `<div class="empty">Рекомендации не загрузились</div>`;
}
}
async function loadStoCalendar() {
const summary = document.querySelector("#stoDashboardSummary");
const list = document.querySelector("#stoCalendarList");
if (!summary || !list) return;
try {
if (!state.serviceCenters.length) {
const centers = await api("/service-centers/my");
state.serviceCenters = centers;
}
const center = state.serviceCenters[0];
if (!center) {
summary.innerHTML = "";
list.innerHTML = `<div class="empty">СТО пока не создано</div>`;
return;
}
const [dashboard, appointments] = await Promise.all([
api(`/sto/dashboard?service_center_id=${center.id}`),
api(`/sto/calendar?service_center_id=${center.id}`),
]);
summary.innerHTML = `
<div class="stat-card"><span>Авто</span><strong>${dashboard.connected_vehicles}</strong></div>
<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>${money(dashboard.revenue_month || 0)}</strong></div>
`;
list.innerHTML = appointments.length
? appointments.map((item) => `
<div class="stack-item">
<strong>${item.service_name}</strong>
<small>${formatDateTime(item.confirmed_start_at || item.requested_start_at)} · авто #${item.vehicle_id}</small>
<span class="trust-badge">${item.status}</span>
${item.status === "requested" ? `
<div class="service-actions">
<button type="button" data-confirm-sto-appointment="${item.id}">Подтвердить</button>
<button type="button" class="ghost-btn" data-reject-sto-appointment="${item.id}">Отклонить</button>
</div>
` : ""}
</div>
`).join("")
: `<div class="empty">Записей на ближайший период нет</div>`;
list.querySelectorAll("[data-confirm-sto-appointment]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Подтверждаю...", async () => {
await api(`/sto/appointments/${button.dataset.confirmStoAppointment}/confirm`, {
method: "POST",
body: JSON.stringify({ comment: "Подтверждено в Mini App" }),
});
await loadStoCalendar();
}));
});
list.querySelectorAll("[data-reject-sto-appointment]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Отклоняю...", async () => {
await api(`/sto/appointments/${button.dataset.rejectStoAppointment}/reject`, {
method: "POST",
body: JSON.stringify({ comment: "Отклонено в Mini App" }),
});
await loadStoCalendar();
}));
});
} catch (error) {
summary.innerHTML = "";
list.innerHTML = `<div class="empty">Календарь СТО не загрузился</div>`;
}
}
function trustLabel(level) {
const labels = {
new_service: "Новый сервис",
@@ -2024,6 +2257,9 @@ async function openDrawerSection(sectionId, options = {}) {
if (sectionId === "connectedServicesSection") await loadConnectedServices();
if (sectionId === "servicePanelSection") await loadServiceCenters();
if (sectionId === "publicServicesSection") await loadPublicServiceCenters();
if (sectionId === "appointmentsSection") await loadAppointments();
if (sectionId === "maintenanceRecommendationsSection") await loadMaintenanceRecommendations();
if (sectionId === "stoCalendarSection") await loadStoCalendar();
if (sectionId === "reviewsSection") renderServiceReviews();
if (sectionId === "adminSection") await loadAdminPendingServices();
if (options.expenseCategory) {