Add service center card interactions
This commit is contained in:
@@ -393,6 +393,44 @@ async def request_vehicle_link(
|
||||
return link
|
||||
|
||||
|
||||
@router.post("/{service_center_id}/vehicle-links/owner-attach", response_model=CarServiceLinkRead)
|
||||
async def owner_attach_vehicle_link(
|
||||
service_center_id: int,
|
||||
payload: ServiceCenterAccessRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> CarServiceLink:
|
||||
await ensure_service_center_approved(session, service_center_id)
|
||||
vehicle = await session.get(Car, payload.car_id)
|
||||
if vehicle is None:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
if vehicle.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
link = await upsert_service_link(
|
||||
session,
|
||||
car_id=payload.car_id,
|
||||
service_center_id=service_center_id,
|
||||
requested_by_user_id=current_user.id,
|
||||
access_level=payload.access_level,
|
||||
external_vehicle_ref=payload.external_vehicle_ref,
|
||||
status_value="approved",
|
||||
)
|
||||
link.approved_by_user_id = current_user.id
|
||||
link.approved_at = datetime.now(UTC)
|
||||
link.revoked_at = None
|
||||
await log_audit(
|
||||
session,
|
||||
actor=current_user,
|
||||
action="car_service_link.owner_attach",
|
||||
target_type="car_service_link",
|
||||
target_id=link.id,
|
||||
metadata={"car_id": payload.car_id, "service_center_id": service_center_id},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(link)
|
||||
return link
|
||||
|
||||
|
||||
@router.post("/links/{link_id}/approve", response_model=CarServiceLinkRead)
|
||||
async def approve_vehicle_link(
|
||||
link_id: int,
|
||||
|
||||
@@ -139,6 +139,21 @@ async def test_public_service_center_and_review_flow(
|
||||
refreshed = await client.get(f"/api/service-centers/{center['id']}", headers=auth_headers)
|
||||
assert refreshed.json()["reviews_count"] == 1
|
||||
|
||||
vehicle = (
|
||||
await client.post(
|
||||
"/api/my/vehicles",
|
||||
headers=auth_headers,
|
||||
json={"name": "Review car"},
|
||||
)
|
||||
).json()
|
||||
link = await client.post(
|
||||
f"/api/service-centers/{center['id']}/vehicle-links/owner-attach",
|
||||
headers=auth_headers,
|
||||
json={"car_id": vehicle["id"], "access_level": "basic"},
|
||||
)
|
||||
assert link.status_code == 200
|
||||
assert link.json()["status"] == "approved"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ocr_candidates_do_not_write_vehicle_data(client, auth_headers) -> None:
|
||||
|
||||
@@ -367,6 +367,7 @@
|
||||
<div class="tip-card">Обычный профиль не показывает панель СТО. Для бизнеса отправьте заявку на проверку.</div>
|
||||
<button class="wide-btn" type="button" data-menu-section="servicePanelSection">Зарегистрировать СТО</button>
|
||||
<div id="publicServiceCenters" class="stack-list"></div>
|
||||
<div id="serviceCard" class="service-card hidden"></div>
|
||||
</section>
|
||||
|
||||
<section class="drawer-section hidden" id="reviewsSection">
|
||||
|
||||
@@ -318,6 +318,7 @@ const state = {
|
||||
allStats: null,
|
||||
analytics: null,
|
||||
serviceCenters: [],
|
||||
publicServiceCenters: [],
|
||||
vehicleScore: null,
|
||||
vehicleTimeline: [],
|
||||
achievements: [],
|
||||
@@ -873,20 +874,24 @@ async function loadPublicServiceCenters() {
|
||||
if (!root) return;
|
||||
try {
|
||||
const centers = await api("/service-centers/public");
|
||||
state.publicServiceCenters = centers;
|
||||
root.innerHTML = centers.length
|
||||
? centers
|
||||
.map(
|
||||
(center) => `
|
||||
<div class="stack-item">
|
||||
<button class="stack-item service-list-card" type="button" data-service-card="${center.id}">
|
||||
<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>
|
||||
</div>
|
||||
</button>
|
||||
`,
|
||||
)
|
||||
.join("")
|
||||
: `<div class="empty">Проверенных СТО пока нет</div>`;
|
||||
root.querySelectorAll("[data-service-card]").forEach((button) => {
|
||||
button.addEventListener("click", () => openServiceCard(Number(button.dataset.serviceCard)));
|
||||
});
|
||||
} catch (error) {
|
||||
root.innerHTML = `<div class="empty">Не удалось загрузить СТО</div>`;
|
||||
}
|
||||
@@ -895,7 +900,102 @@ async function loadPublicServiceCenters() {
|
||||
function renderServiceReviews() {
|
||||
const root = document.querySelector("#serviceReviews");
|
||||
if (!root) return;
|
||||
root.innerHTML = `<div class="empty">Отзывы доступны в карточке проверенного СТО. Выберите сервис в разделе «СТО».</div>`;
|
||||
root.innerHTML = state.publicServiceCenters.length
|
||||
? state.publicServiceCenters
|
||||
.map((center) => `<button class="menu-row" type="button" data-service-card="${center.id}">${center.display_name || center.name}</button>`)
|
||||
.join("")
|
||||
: `<div class="empty">Откройте раздел «СТО», чтобы загрузить проверенные сервисы.</div>`;
|
||||
root.querySelectorAll("[data-service-card]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
await openDrawerSection("publicServicesSection");
|
||||
await openServiceCard(Number(button.dataset.serviceCard));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openServiceCard(serviceCenterId) {
|
||||
const card = document.querySelector("#serviceCard");
|
||||
if (!card) return;
|
||||
const [center, reviews] = await Promise.all([
|
||||
api(`/service-centers/${serviceCenterId}`),
|
||||
api(`/service-centers/${serviceCenterId}/reviews?limit=20`),
|
||||
]);
|
||||
card.classList.remove("hidden");
|
||||
card.innerHTML = `
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">СТО</p>
|
||||
<h2>${center.display_name || center.name}</h2>
|
||||
</div>
|
||||
<span class="trust-badge">${center.rating_avg ? `★ ${center.rating_avg} · ${center.reviews_count}` : "Проверенный сервис"}</span>
|
||||
</div>
|
||||
<div class="tip-card">
|
||||
<strong>${[center.city, center.address].filter(Boolean).join(", ") || "Адрес не указан"}</strong><br />
|
||||
<small>${center.phone || "Телефон не указан"}</small><br />
|
||||
<span>${center.description || "Описание появится после заполнения карточки сервисом."}</span>
|
||||
</div>
|
||||
<div class="service-actions">
|
||||
<button type="button" class="ghost-btn" id="attachServiceBtn">Привязать выбранное авто</button>
|
||||
</div>
|
||||
<form class="grid-form drawer-form" id="serviceReviewForm">
|
||||
<label>
|
||||
Оценка
|
||||
<select name="rating">
|
||||
<option value="5">5 · Отлично</option>
|
||||
<option value="4">4 · Хорошо</option>
|
||||
<option value="3">3 · Нормально</option>
|
||||
<option value="2">2 · Есть проблемы</option>
|
||||
<option value="1">1 · Плохо</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Отзыв
|
||||
<input name="text" placeholder="Что понравилось или что улучшить" />
|
||||
</label>
|
||||
<button type="submit">Оставить отзыв</button>
|
||||
</form>
|
||||
<div class="stack-list">
|
||||
${reviews.length
|
||||
? reviews.map((review) => `
|
||||
<div class="stack-item">
|
||||
<strong>★ ${review.rating}</strong>
|
||||
<small>${review.text || "Без текста"}</small>
|
||||
${review.service_response ? `<small>Ответ СТО: ${review.service_response}</small>` : ""}
|
||||
</div>
|
||||
`).join("")
|
||||
: `<div class="empty">Отзывов еще нет</div>`}
|
||||
</div>
|
||||
`;
|
||||
card.querySelector("#serviceReviewForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
|
||||
const data = formData(form);
|
||||
await api(`/service-centers/${serviceCenterId}/reviews`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ rating: Number(data.rating), text: data.text || null }),
|
||||
});
|
||||
await openServiceCard(serviceCenterId);
|
||||
await loadPublicServiceCenters();
|
||||
toast("Сохранено");
|
||||
haptic("success");
|
||||
});
|
||||
});
|
||||
card.querySelector("#attachServiceBtn").addEventListener("click", async (event) => {
|
||||
if (!state.selectedCarId) {
|
||||
toast("Выбери автомобиль", "error");
|
||||
return;
|
||||
}
|
||||
await runAction(event.currentTarget, "Сохраняю...", async () => {
|
||||
await api(`/service-centers/${serviceCenterId}/vehicle-links/owner-attach`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ car_id: state.selectedCarId, access_level: "basic" }),
|
||||
});
|
||||
toast("Авто привязано к СТО");
|
||||
haptic("success");
|
||||
});
|
||||
});
|
||||
card.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
function trustLabel(level) {
|
||||
|
||||
@@ -1167,6 +1167,39 @@ button.is-busy {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.service-list-card,
|
||||
.service-card {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.service-list-card {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-height: auto;
|
||||
padding: 12px;
|
||||
color: var(--text);
|
||||
background: var(--soft);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.service-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.service-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.summary-card::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user