Add service center card interactions
This commit is contained in:
@@ -393,6 +393,44 @@ async def request_vehicle_link(
|
|||||||
return 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)
|
@router.post("/links/{link_id}/approve", response_model=CarServiceLinkRead)
|
||||||
async def approve_vehicle_link(
|
async def approve_vehicle_link(
|
||||||
link_id: int,
|
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)
|
refreshed = await client.get(f"/api/service-centers/{center['id']}", headers=auth_headers)
|
||||||
assert refreshed.json()["reviews_count"] == 1
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_ocr_candidates_do_not_write_vehicle_data(client, auth_headers) -> None:
|
async def test_ocr_candidates_do_not_write_vehicle_data(client, auth_headers) -> None:
|
||||||
|
|||||||
@@ -367,6 +367,7 @@
|
|||||||
<div class="tip-card">Обычный профиль не показывает панель СТО. Для бизнеса отправьте заявку на проверку.</div>
|
<div class="tip-card">Обычный профиль не показывает панель СТО. Для бизнеса отправьте заявку на проверку.</div>
|
||||||
<button class="wide-btn" type="button" data-menu-section="servicePanelSection">Зарегистрировать СТО</button>
|
<button class="wide-btn" type="button" data-menu-section="servicePanelSection">Зарегистрировать СТО</button>
|
||||||
<div id="publicServiceCenters" class="stack-list"></div>
|
<div id="publicServiceCenters" class="stack-list"></div>
|
||||||
|
<div id="serviceCard" class="service-card hidden"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="drawer-section hidden" id="reviewsSection">
|
<section class="drawer-section hidden" id="reviewsSection">
|
||||||
|
|||||||
@@ -318,6 +318,7 @@ const state = {
|
|||||||
allStats: null,
|
allStats: null,
|
||||||
analytics: null,
|
analytics: null,
|
||||||
serviceCenters: [],
|
serviceCenters: [],
|
||||||
|
publicServiceCenters: [],
|
||||||
vehicleScore: null,
|
vehicleScore: null,
|
||||||
vehicleTimeline: [],
|
vehicleTimeline: [],
|
||||||
achievements: [],
|
achievements: [],
|
||||||
@@ -873,20 +874,24 @@ async function loadPublicServiceCenters() {
|
|||||||
if (!root) return;
|
if (!root) return;
|
||||||
try {
|
try {
|
||||||
const centers = await api("/service-centers/public");
|
const centers = await api("/service-centers/public");
|
||||||
|
state.publicServiceCenters = centers;
|
||||||
root.innerHTML = centers.length
|
root.innerHTML = centers.length
|
||||||
? centers
|
? centers
|
||||||
.map(
|
.map(
|
||||||
(center) => `
|
(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>
|
<strong>${center.display_name || center.name}</strong>
|
||||||
<small>${[center.city, center.address].filter(Boolean).join(", ") || "Адрес не указан"}</small>
|
<small>${[center.city, center.address].filter(Boolean).join(", ") || "Адрес не указан"}</small>
|
||||||
<small>${center.specializations?.join(", ") || "Специализация не указана"}</small>
|
<small>${center.specializations?.join(", ") || "Специализация не указана"}</small>
|
||||||
<span class="trust-badge">${center.rating_avg ? `★ ${center.rating_avg}` : "Проверка пройдена"}</span>
|
<span class="trust-badge">${center.rating_avg ? `★ ${center.rating_avg}` : "Проверка пройдена"}</span>
|
||||||
</div>
|
</button>
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.join("")
|
.join("")
|
||||||
: `<div class="empty">Проверенных СТО пока нет</div>`;
|
: `<div class="empty">Проверенных СТО пока нет</div>`;
|
||||||
|
root.querySelectorAll("[data-service-card]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => openServiceCard(Number(button.dataset.serviceCard)));
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
root.innerHTML = `<div class="empty">Не удалось загрузить СТО</div>`;
|
root.innerHTML = `<div class="empty">Не удалось загрузить СТО</div>`;
|
||||||
}
|
}
|
||||||
@@ -895,7 +900,102 @@ async function loadPublicServiceCenters() {
|
|||||||
function renderServiceReviews() {
|
function renderServiceReviews() {
|
||||||
const root = document.querySelector("#serviceReviews");
|
const root = document.querySelector("#serviceReviews");
|
||||||
if (!root) return;
|
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) {
|
function trustLabel(level) {
|
||||||
|
|||||||
@@ -1167,6 +1167,39 @@ button.is-busy {
|
|||||||
overflow: auto;
|
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 {
|
.summary-card::after {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user