diff --git a/app/api/service_centers.py b/app/api/service_centers.py index 23a502b..584c202 100644 --- a/app/api/service_centers.py +++ b/app/api/service_centers.py @@ -40,6 +40,7 @@ from app.schemas.service_center import ( ServiceCenterVerificationRead, ServiceEmployeeInvite, ServiceEmployeeRead, + ServiceEmployeeUpdate, ServiceInboxCreate, ServiceInboxRead, ServiceVisitCreate, @@ -55,6 +56,41 @@ from app.services.vehicle_identity import mask_license_plate, mask_vin router = APIRouter(prefix="/service-centers", tags=["service-centers"]) APPROVED_SERVICE_STATUSES = {"approved", "verified"} +SERVICE_EMPLOYEE_ROLES = {"owner", "manager", "receptionist", "mechanic"} +SERVICE_EMPLOYEE_STATUSES = {"active", "invited", "inactive", "revoked", "expired"} + + +def validate_employee_role(role: str) -> str: + role = role.strip().lower() + if role not in SERVICE_EMPLOYEE_ROLES: + raise HTTPException(status_code=400, detail="Unsupported service employee role") + return role + + +def validate_employee_status(status_value: str) -> str: + status_value = status_value.strip().lower() + if status_value not in SERVICE_EMPLOYEE_STATUSES: + raise HTTPException(status_code=400, detail="Unsupported service employee status") + return status_value + + +async def attach_employee_user_fields(session: AsyncSession, employees: list[ServiceEmployee]) -> list[ServiceEmployee]: + if not employees: + return employees + user_ids = [employee.user_id for employee in employees] + users = { + user.id: user + for user in (await session.execute(select(User).where(User.id.in_(user_ids)))).scalars() + } + for employee in employees: + user = users.get(employee.user_id) + if user is None: + continue + employee.telegram_id = user.telegram_id + employee.username = user.username + employee.first_name = user.first_name + employee.last_name = user.last_name + return employees @router.post("", response_model=ServiceCenterRead, status_code=status.HTTP_201_CREATED) async def create_service_center( @@ -265,7 +301,9 @@ async def invite_employee( current_user: User = Depends(get_current_telegram_user), ) -> ServiceEmployee: await check_rate_limit(scope="employee_invite", limit=10, window_seconds=3600, request=request, user=current_user, session=session) + await ensure_service_center_approved(session, service_center_id) await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager"}) + role = validate_employee_role(payload.role) user = await get_or_create_telegram_user(session, telegram_id=payload.telegram_id) result = await session.execute( select(ServiceEmployee).where( @@ -278,7 +316,7 @@ async def invite_employee( employee = ServiceEmployee( service_center_id=service_center_id, user_id=user.id, - role=payload.role, + role=role, permissions=payload.permissions, status="invited", invite_token=secrets.token_urlsafe(32), @@ -286,7 +324,9 @@ async def invite_employee( ) session.add(employee) else: - employee.role = payload.role + if employee.role == "owner": + raise HTTPException(status_code=409, detail="Owner role cannot be replaced by invite") + employee.role = role employee.permissions = payload.permissions employee.status = "invited" employee.invite_token = secrets.token_urlsafe(32) @@ -296,9 +336,27 @@ async def invite_employee( await log_audit(session, actor=current_user, action="service_employee.invite", target_type="service_center", target_id=service_center_id, metadata={"telegram_id": payload.telegram_id}) await session.commit() await session.refresh(employee) + await attach_employee_user_fields(session, [employee]) return employee +@router.get("/{service_center_id}/employees", response_model=list[ServiceEmployeeRead]) +async def list_service_employees( + service_center_id: int, + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_telegram_user), +) -> list[ServiceEmployee]: + await ensure_service_center_approved(session, service_center_id) + await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager"}) + result = await session.execute( + select(ServiceEmployee) + .where(ServiceEmployee.service_center_id == service_center_id) + .order_by(ServiceEmployee.role.asc(), ServiceEmployee.created_at.asc()) + ) + employees = list(result.scalars()) + return await attach_employee_user_fields(session, employees) + + @router.post("/employees/invites/{invite_token}/accept", response_model=ServiceEmployeeRead) async def accept_employee_invite( invite_token: str, @@ -340,6 +398,47 @@ async def accept_employee_invite( ) await session.commit() await session.refresh(employee) + await attach_employee_user_fields(session, [employee]) + return employee + + +@router.patch("/employees/{employee_id}", response_model=ServiceEmployeeRead) +async def update_service_employee( + employee_id: int, + payload: ServiceEmployeeUpdate, + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_telegram_user), +) -> ServiceEmployee: + employee = await session.get(ServiceEmployee, employee_id) + if employee is None: + raise HTTPException(status_code=404, detail="Employee not found") + await ensure_service_center_approved(session, employee.service_center_id) + await ensure_service_employee(session, employee.service_center_id, current_user, {"owner", "manager"}) + if employee.role == "owner" and payload.role and payload.role != "owner": + raise HTTPException(status_code=409, detail="Owner role cannot be changed") + if employee.role == "owner" and payload.status and payload.status != "active": + raise HTTPException(status_code=409, detail="Owner cannot be deactivated") + data = payload.model_dump(exclude_unset=True) + if "role" in data and data["role"] is not None: + employee.role = validate_employee_role(data["role"]) + if "status" in data and data["status"] is not None: + employee.status = validate_employee_status(data["status"]) + if employee.status != "invited": + employee.invite_token = None + employee.invite_revoked_at = datetime.now(UTC) if employee.status == "revoked" else employee.invite_revoked_at + if "permissions" in data: + employee.permissions = data["permissions"] + await log_audit( + session, + actor=current_user, + action="service_employee.update", + target_type="service_employee", + target_id=employee.id, + metadata=data, + ) + await session.commit() + await session.refresh(employee) + await attach_employee_user_fields(session, [employee]) return employee @@ -367,6 +466,7 @@ async def revoke_employee_invite( ) await session.commit() await session.refresh(employee) + await attach_employee_user_fields(session, [employee]) return employee diff --git a/app/schemas/service_center.py b/app/schemas/service_center.py index 3fe90d0..f36afdb 100644 --- a/app/schemas/service_center.py +++ b/app/schemas/service_center.py @@ -83,10 +83,20 @@ class ServiceEmployeeInvite(BaseModel): expires_in_hours: int = Field(default=72, ge=0, le=720) +class ServiceEmployeeUpdate(BaseModel): + role: str | None = None + permissions: dict | None = None + status: str | None = None + + class ServiceEmployeeRead(BaseModel): id: int service_center_id: int user_id: int + telegram_id: int | None = None + username: str | None = None + first_name: str | None = None + last_name: str | None = None role: str permissions: dict | None = None status: str diff --git a/bot/api_client.py b/bot/api_client.py index f779943..356fcb4 100644 --- a/bot/api_client.py +++ b/bot/api_client.py @@ -116,6 +116,13 @@ class ApiClient: async def register_service_center(self, telegram_id: int, payload: dict[str, Any]) -> dict[str, Any]: return await self.request("POST", "/api/service-centers", telegram_id=telegram_id, json=payload) + async def accept_sto_invite(self, telegram_id: int, invite_token: str) -> dict[str, Any]: + return await self.request( + "POST", + f"/api/service-centers/employees/invites/{invite_token}/accept", + telegram_id=telegram_id, + ) + async def pending_service_centers(self, telegram_id: int) -> list[dict[str, Any]]: return await self.request("GET", "/api/admin/service-centers/pending", telegram_id=telegram_id) diff --git a/bot/main.py b/bot/main.py index 6548afe..3fd7774 100644 --- a/bot/main.py +++ b/bot/main.py @@ -24,6 +24,33 @@ logging.basicConfig(level=logging.INFO) dp = Dispatcher() api = ApiClient() +APPROVED_SERVICE_STATUSES = {"approved", "verified"} +STO_WORKPLACE_ROLES = {"owner", "mechanic"} + + +def webapp_url(path: str = "") -> str: + base = settings.effective_webapp_url.rstrip("/") + if not path: + return base + return f"{base}/{path.lstrip('/')}" + + +def sto_workplace_url() -> str: + return webapp_url("sto.html") + + +async def sto_workplace_centers(telegram_id: int) -> list[dict]: + try: + centers = await api.my_service_centers(telegram_id) + except httpx.HTTPStatusError: + return [] + return [ + center + for center in centers + if center.get("verification_status") in APPROVED_SERVICE_STATUSES + and (center.get("employee_role") or "owner") in STO_WORKPLACE_ROLES + ] + def main_keyboard() -> ReplyKeyboardMarkup: return ReplyKeyboardMarkup( @@ -35,28 +62,39 @@ def main_keyboard() -> ReplyKeyboardMarkup: ) -def webapp_inline_keyboard(text: str = "Открыть CarPass") -> InlineKeyboardMarkup: +async def main_keyboard_for(telegram_id: int) -> ReplyKeyboardMarkup: + rows = [ + [KeyboardButton(text="Меню"), KeyboardButton(text="Мои авто")], + ] + if await sto_workplace_centers(telegram_id): + rows.append([KeyboardButton(text="Панель СТО")]) + rows.append([KeyboardButton(text="Помощь")]) + return ReplyKeyboardMarkup(keyboard=rows, resize_keyboard=True) + + +def webapp_inline_keyboard(text: str = "Открыть CarPass", path: str = "") -> InlineKeyboardMarkup: return InlineKeyboardMarkup( inline_keyboard=[ - [InlineKeyboardButton(text=text, web_app=WebAppInfo(url=settings.effective_webapp_url))], + [InlineKeyboardButton(text=text, web_app=WebAppInfo(url=webapp_url(path)))], ] ) -def menu_inline_keyboard() -> InlineKeyboardMarkup: - return InlineKeyboardMarkup( - inline_keyboard=[ - [InlineKeyboardButton(text="Открыть Mini App", web_app=WebAppInfo(url=settings.effective_webapp_url))], - [ - InlineKeyboardButton(text="Мои авто", callback_data="menu:garage"), - InlineKeyboardButton(text="Аналитика", callback_data="menu:analytics"), - ], - [ - InlineKeyboardButton(text="Добавить запись", callback_data="menu:add_record"), - InlineKeyboardButton(text="СТО", callback_data="menu:sto"), - ], - ] - ) +def menu_inline_keyboard(include_sto_workplace: bool = False) -> InlineKeyboardMarkup: + rows = [ + [InlineKeyboardButton(text="Открыть Mini App", web_app=WebAppInfo(url=webapp_url()))], + [ + InlineKeyboardButton(text="Мои авто", callback_data="menu:garage"), + InlineKeyboardButton(text="Аналитика", callback_data="menu:analytics"), + ], + [ + InlineKeyboardButton(text="Добавить запись", callback_data="menu:add_record"), + InlineKeyboardButton(text="СТО", callback_data="menu:sto"), + ], + ] + if include_sto_workplace: + rows.append([InlineKeyboardButton(text="Панель СТО", web_app=WebAppInfo(url=sto_workplace_url()))]) + return InlineKeyboardMarkup(inline_keyboard=rows) def admin_card_keyboard(center_id: int) -> InlineKeyboardMarkup: @@ -127,17 +165,19 @@ async def start(message: Message) -> None: "CarPass ведет цифровой паспорт автомобиля: заправки, ТО, страховку, штрафы, стоимость владения и подтвержденную историю СТО.\n\n" "Mini App открывай кнопкой под сообщением: так Telegram передает защищенную авторизацию." ) - await message.answer(text, reply_markup=menu_inline_keyboard()) - await message.answer("Клавиатура ниже открывает команды бота.", reply_markup=main_keyboard()) + centers = await sto_workplace_centers(message.from_user.id) + await message.answer(text, reply_markup=menu_inline_keyboard(include_sto_workplace=bool(centers))) + await message.answer("Клавиатура ниже открывает команды бота.", reply_markup=await main_keyboard_for(message.from_user.id)) @dp.message(F.text == "Меню") @dp.message(Command("menu")) async def menu(message: Message) -> None: await upsert(message) + centers = await sto_workplace_centers(message.from_user.id) await message.answer( "Главное меню CarPass. Быстрые команды доступны здесь, а полный интерфейс работает в Mini App.", - reply_markup=menu_inline_keyboard(), + reply_markup=menu_inline_keyboard(include_sto_workplace=bool(centers)), ) @@ -315,13 +355,9 @@ async def appointments(message: Message) -> None: @dp.message(Command("sto_bookings")) async def sto_bookings(message: Message) -> None: await upsert(message) - try: - centers = await api.my_service_centers(message.from_user.id) - except httpx.HTTPStatusError as error: - await message.answer(f"Кабинет СТО не доступен: {error.response.text}") - return + centers = await sto_workplace_centers(message.from_user.id) if not centers: - await message.answer("У вас пока нет СТО. Подайте заявку через /register_sto или Mini App.") + await message.answer("Панель СТО доступна только владельцу подтвержденного СТО и активным механикам.") return center = centers[0] try: @@ -338,7 +374,40 @@ async def sto_bookings(message: Message) -> None: ] for item in pending[:8]: lines.append(f"#{item['id']} {item['service_name']} — {item['requested_start_at'][:16].replace('T', ' ')}") - await message.answer("\n".join(lines), reply_markup=webapp_inline_keyboard("Кабинет СТО")) + await message.answer("\n".join(lines), reply_markup=webapp_inline_keyboard("Открыть панель СТО", "sto.html")) + + +@dp.message(Command("sto_workplace")) +@dp.message(F.text == "Панель СТО") +async def sto_workplace(message: Message) -> None: + await upsert(message) + centers = await sto_workplace_centers(message.from_user.id) + if not centers: + await message.answer("Панель СТО доступна только владельцу подтвержденного СТО и активным механикам.") + return + center = centers[0] + await message.answer( + f"Панель СТО: {center.get('display_name') or center.get('name')}", + reply_markup=webapp_inline_keyboard("Открыть панель СТО", "sto.html"), + ) + + +@dp.message(Command("accept_sto_invite")) +async def accept_sto_invite(message: Message, command: CommandObject) -> None: + await upsert(message) + token = command.args.strip() if command.args else "" + if not token: + await message.answer("Пришлите команду вместе с токеном: /accept_sto_invite ") + return + try: + employee = await api.accept_sto_invite(message.from_user.id, token) + except httpx.HTTPStatusError as error: + await message.answer(f"Приглашение не принято: {error.response.text}") + return + await message.answer( + f"Приглашение принято. Роль: {employee['role']}.", + reply_markup=webapp_inline_keyboard("Открыть панель СТО", "sto.html"), + ) @dp.message(Command("register_sto")) @@ -508,6 +577,18 @@ async def admin_callback(callback: CallbackQuery) -> None: @dp.message(F.text == "Помощь") @dp.message(Command("help")) async def help_message(message: Message) -> None: + centers = await sto_workplace_centers(message.from_user.id) + sto_workplace_help = ( + "• /sto_bookings или /sto_workplace — панель подтвержденного СТО;\n" + "• /accept_sto_invite — принять приглашение сотрудника;\n" + if centers + else "" + ) + sto_business_help = ( + "СТО: прими заявку, создай заказ-наряд, добавь работы/товары/жидкости, отправь владельцу на согласование и закрой работу после выполнения.\n" + if centers + else "" + ) await message.answer( "CarPass помогает вести цифровой паспорт автомобиля.\n\n" "Главное:\n" @@ -519,13 +600,13 @@ async def help_message(message: Message) -> None: "• /analytics — стоимость владения и расход;\n" "• /sto — каталог проверенных СТО;\n" "• /appointments — мои записи в СТО;\n" - "• /sto_bookings — заявки и календарь для владельца СТО;\n" - "• /register_sto — заявка на СТО.\n\n" + f"{sto_workplace_help}" + "\n" "Владелец: добавь авто, выбери проверенное СТО, создай запись, согласуй заказ-наряд и смотри завершенные работы в истории автомобиля.\n" - "СТО: прими заявку, создай заказ-наряд, добавь работы/товары/жидкости, отправь владельцу на согласование и закрой работу после выполнения.\n" + f"{sto_business_help}" "Безопасность: СТО видит автомобиль только после подтверждения владельца, а спорные изменения VIN, номера и пробега идут через согласование.\n\n" "Mini App открывай только кнопкой под сообщением: Telegram передает initData, и авторизация проходит корректно.", - reply_markup=menu_inline_keyboard(), + reply_markup=menu_inline_keyboard(include_sto_workplace=bool(centers)), ) diff --git a/scripts/deploy.sh b/scripts/deploy.sh index abf136b..2a2a3dd 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -APP_DIR="${APP_DIR:-/opt/carpass/app}" +APP_DIR="${APP_DIR:-/opt/drivers_bot/}" BRANCH="${DEPLOY_BRANCH:-main}" COMPOSE="${COMPOSE:-docker compose}" HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:8000/ready}" diff --git a/web/index.html b/web/index.html index 13599b6..75b6318 100644 --- a/web/index.html +++ b/web/index.html @@ -244,548 +244,570 @@

Меню

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - + - - + + + + + + diff --git a/web/static/app.js b/web/static/app.js index d57264a..2706c8f 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -423,11 +423,12 @@ function formData(form) { } async function api(path, options = {}) { - const headers = { "Content-Type": "application/json", ...authHeaders(options.headers || {}) }; + const { headers: optionHeaders = {}, ...fetchOptions } = options; + const headers = { "Content-Type": "application/json", ...authHeaders(optionHeaders) }; if (options.body instanceof FormData) delete headers["Content-Type"]; const response = await fetch(`/api${path}`, { + ...fetchOptions, headers, - ...options, }); if (!response.ok) { const text = await response.text(); @@ -541,9 +542,47 @@ function hideAuthOverlay() { document.body.classList.remove("auth-required"); } +const APPROVED_SERVICE_STATUSES = new Set(["approved", "verified"]); +const STO_WORKPLACE_ROLES = new Set(["owner", "mechanic"]); +const STO_CALENDAR_ROLES = new Set(["owner", "manager", "receptionist"]); + +function isPlatformAdmin() { + return ["admin", "verifier", "moderator"].includes(state.user?.platform_role); +} + +function approvedServiceCenters() { + return state.serviceCenters.filter((center) => APPROVED_SERVICE_STATUSES.has(center.verification_status)); +} + +function stoWorkplaceCenters() { + return approvedServiceCenters().filter((center) => STO_WORKPLACE_ROLES.has(center.employee_role || "owner")); +} + +function stoCalendarCenters() { + return approvedServiceCenters().filter((center) => STO_CALENDAR_ROLES.has(center.employee_role || "owner")); +} + +function canUseServiceProfile() { + return state.serviceCenters.length > 0 || state.user?.platform_role === "service_owner" || isPlatformAdmin(); +} + +function canOpenDrawerSection(sectionId) { + if (sectionId === "adminSection") return isPlatformAdmin(); + if (sectionId === "mechanicWorkplaceSection") return stoWorkplaceCenters().length > 0; + if (sectionId === "stoCalendarSection") return stoCalendarCenters().length > 0; + if (sectionId === "servicePanelSection") return canUseServiceProfile(); + return true; +} + function updateRoleVisibility() { - const isAdmin = ["admin", "verifier", "moderator"].includes(state.user?.platform_role); + const isAdmin = isPlatformAdmin(); + const hasWorkplace = stoWorkplaceCenters().length > 0; + const hasCalendar = stoCalendarCenters().length > 0; + const hasServiceProfile = canUseServiceProfile(); document.querySelectorAll(".admin-only").forEach((node) => node.classList.toggle("hidden", !isAdmin)); + document.querySelectorAll(".sto-workplace-only").forEach((node) => node.classList.toggle("hidden", !hasWorkplace)); + document.querySelectorAll(".sto-calendar-only").forEach((node) => node.classList.toggle("hidden", !hasCalendar)); + document.querySelectorAll(".service-owner-only").forEach((node) => node.classList.toggle("hidden", !hasServiceProfile)); } function showTelegramOpenHint() { @@ -1051,17 +1090,20 @@ async function loadMyServiceCenters({ withTrust = false } = {}) { state.activeServiceCenterId = state.serviceCenters[0]?.id || null; } renderServiceProfileCard(); + updateRoleVisibility(); 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]; + updateRoleVisibility(); + const centers = stoWorkplaceCenters(); + const hasWorkplace = centers.length > 0; + card.classList.toggle("hidden", !hasWorkplace); + if (!hasWorkplace) return; + const center = centers.find((item) => item.id === state.activeServiceCenterId) || centers[0]; + state.activeServiceCenterId = center.id; 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)}`; @@ -1394,13 +1436,12 @@ async function loadStoCalendar() { if (!summary || !list) return; try { if (!state.serviceCenters.length) { - const centers = await api("/service-centers/my"); - state.serviceCenters = centers; + await loadMyServiceCenters(); } - const center = state.serviceCenters[0]; + const center = stoCalendarCenters()[0]; if (!center) { summary.innerHTML = ""; - list.innerHTML = `
СТО пока не создано
`; + list.innerHTML = `
Календарь доступен только сотрудникам подтвержденного СТО.
`; return; } const [dashboard, appointments] = await Promise.all([ @@ -1459,18 +1500,21 @@ async function loadMechanicWorkplace() { if (!centerSelect || !summary || !list) return; try { if (!state.serviceCenters.length) await loadMyServiceCenters(); - if (!state.serviceCenters.length) { + const centers = stoWorkplaceCenters(); + if (!centers.length) { centerSelect.innerHTML = ""; summary.innerHTML = ""; - list.innerHTML = `
Сначала зарегистрируйте СТО или примите приглашение сотрудника.
`; + list.innerHTML = `
Рабочее место доступно владельцу подтвержденного СТО и активным механикам.
`; return; } centerSelect.innerHTML = state.serviceCenters + .filter((center) => centers.some((item) => item.id === center.id)) .map((center) => ``) .join(""); - centerSelect.value = String(state.activeServiceCenterId || state.serviceCenters[0].id); + const selectedCenter = centers.find((item) => item.id === state.activeServiceCenterId) || centers[0]; + centerSelect.value = String(selectedCenter.id); const serviceCenterId = Number(centerSelect.value); - const center = state.serviceCenters.find((item) => item.id === serviceCenterId) || state.serviceCenters[0]; + const center = centers.find((item) => item.id === serviceCenterId) || centers[0]; state.activeServiceCenterId = serviceCenterId; renderServiceProfileCard(); @@ -2552,7 +2596,13 @@ function mountEntryForms() { } async function openDrawerSection(sectionId, options = {}) { + if (!canOpenDrawerSection(sectionId)) { + toast("Этот раздел недоступен для вашей роли", "error"); + haptic("error"); + sectionId = "carsSection"; + } document.querySelector("#userDrawer").classList.remove("hidden"); + const drawerContent = document.querySelector(".drawer-content"); document.querySelectorAll(".drawer-section").forEach((section) => { section.classList.toggle("hidden", section.id !== sectionId); }); @@ -2583,11 +2633,11 @@ async function openDrawerSection(sectionId, options = {}) { if (sectionId === "reviewsSection") renderServiceReviews(); if (sectionId === "adminSection") await loadAdminPendingServices(); if (options.expenseCategory) { - openDrawerSection("expensesSection"); + await openDrawerSection("expensesSection"); presetExpense(options.expenseCategory); return; } - document.querySelector(`#${sectionId}`)?.scrollIntoView({ behavior: "smooth", block: "start" }); + if (drawerContent) drawerContent.scrollTo({ top: 0, behavior: "smooth" }); } function presetExpense(category) { @@ -2649,6 +2699,17 @@ document.querySelectorAll("[data-menu-section]").forEach((button) => { }); }); +document.querySelectorAll("[data-open-sto-page]").forEach((button) => { + button.addEventListener("click", () => { + if (!stoWorkplaceCenters().length) { + toast("Панель СТО доступна владельцу подтвержденного СТО и активным механикам", "error"); + haptic("error"); + return; + } + window.location.href = "/sto.html"; + }); +}); + document.querySelector("#mechanicCenterSelect")?.addEventListener("change", async (event) => { state.activeServiceCenterId = Number(event.currentTarget.value); await runAction(event.currentTarget, "Обновляю рабочее место...", loadMechanicWorkplace); diff --git a/web/static/sto.js b/web/static/sto.js new file mode 100644 index 0000000..55dd29d --- /dev/null +++ b/web/static/sto.js @@ -0,0 +1,427 @@ +const tg = window.Telegram?.WebApp; +tg?.ready(); +tg?.expand(); + +const APPROVED_SERVICE_STATUSES = new Set(["approved", "verified"]); +const STO_WORKPLACE_ROLES = new Set(["owner", "mechanic"]); + +const state = { + user: null, + authConfig: null, + centers: [], + activeCenterId: null, + appointments: [], + workOrders: [], + employees: [], +}; + +function authHeaders(extra = {}) { + const headers = { ...extra }; + if (tg?.initData) headers["X-Telegram-Init-Data"] = tg.initData; + if (!tg?.initData && state.authConfig?.allow_dev_auth) { + headers["X-Dev-Telegram-Id"] = localStorage.getItem("driversDevTelegramId") || "1"; + } + return headers; +} + +async function api(path, options = {}) { + const { headers: optionHeaders = {}, ...fetchOptions } = options; + const headers = { "Content-Type": "application/json", ...authHeaders(optionHeaders) }; + const response = await fetch(`/api${path}`, { ...fetchOptions, headers }); + if (!response.ok) throw new Error(await response.text() || response.statusText); + if (response.status === 204) return null; + return response.json(); +} + +async function loadAuthConfig() { + state.authConfig = await api("/users/auth/config"); +} + +async function ensureUser() { + if (tg?.initData) { + state.user = await api("/users/webapp-auth", { + method: "POST", + body: JSON.stringify({ init_data: tg.initData }), + }); + } else if (state.authConfig?.allow_dev_auth) { + state.user = await api("/users/me"); + } else { + showAuthOverlay(); + throw new Error("Требуется вход через Telegram"); + } + document.body.classList.remove("auth-required"); + document.querySelector("#authOverlay")?.classList.add("hidden"); +} + +function showAuthOverlay() { + document.body.classList.add("auth-required"); + const botUsername = state.authConfig?.bot_username; + const link = document.querySelector("#telegramLoginLink"); + if (botUsername && link) { + link.href = `https://t.me/${botUsername}`; + link.classList.remove("hidden"); + } +} + +function toast(message, tone = "success") { + const node = document.querySelector("#toast"); + if (!node) return; + node.textContent = message; + node.className = `toast ${tone}`; + window.clearTimeout(toast.timer); + toast.timer = window.setTimeout(() => node.classList.add("hidden"), 2600); +} + +function money(value) { + const currency = state.user?.currency || "RUB"; + return Number(value || 0).toLocaleString("ru-RU", { + style: "currency", + currency, + maximumFractionDigits: currency === "KRW" ? 0 : 2, + }); +} + +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" }); +} + +function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function formData(form) { + return Object.fromEntries(new FormData(form).entries()); +} + +function numberOrNull(value) { + return value === "" || value == null ? null : Number(value); +} + +function activeCenter() { + return state.centers.find((center) => center.id === state.activeCenterId) || state.centers[0] || null; +} + +function roleLabel(role) { + return { + owner: "Владелец", + manager: "Менеджер", + receptionist: "Администратор", + mechanic: "Механик", + }[role] || role || "Сотрудник"; +} + +function statusLabel(status) { + return { + draft: "Черновик", + diagnosis: "Диагностика", + waiting_owner_approval: "Ждет согласования", + approved_by_owner: "Согласован", + rejected_by_owner: "Отклонен клиентом", + in_progress: "В работе", + completed: "Завершен", + requested: "Новая заявка", + confirmed: "Подтверждена", + confirmed_by_sto: "Подтверждена СТО", + proposed_new_time: "Предложено другое время", + invited: "Приглашен", + active: "Активен", + inactive: "Отключен", + revoked: "Отозван", + expired: "Истекло", + }[status] || status || "Без статуса"; +} + +async function loadCenters() { + const centers = await api("/service-centers/my"); + state.centers = centers.filter((center) => + APPROVED_SERVICE_STATUSES.has(center.verification_status) && STO_WORKPLACE_ROLES.has(center.employee_role || "owner"), + ); + if (!state.activeCenterId && state.centers.length) state.activeCenterId = state.centers[0].id; + if (state.activeCenterId && !state.centers.some((center) => center.id === state.activeCenterId)) { + state.activeCenterId = state.centers[0]?.id || null; + } +} + +function renderAccessDenied() { + document.querySelector("#centerTitle").textContent = "Доступ закрыт"; + document.querySelector("#centerMeta").textContent = "Страница доступна владельцу подтвержденного СТО и активным механикам."; + document.querySelector("#roleBadge").textContent = "Нет доступа"; + document.querySelector("#centerSelect").innerHTML = ""; + document.querySelector("#dashboardStats").innerHTML = ""; + document.querySelector("#appointmentsList").innerHTML = `
Нет доступного подтвержденного СТО.
`; + document.querySelector("#workOrdersList").innerHTML = `
Рабочее место недоступно.
`; + document.querySelector("#staffPanel").classList.add("hidden"); +} + +async function loadWorkplace() { + await loadCenters(); + if (!state.centers.length) { + renderAccessDenied(); + return; + } + const center = activeCenter(); + document.querySelector("#centerSelect").innerHTML = state.centers + .map((item) => ``) + .join(""); + document.querySelector("#centerSelect").value = String(center.id); + document.querySelector("#centerTitle").textContent = center.display_name || center.name; + document.querySelector("#centerMeta").textContent = [center.city, center.address].filter(Boolean).join(", ") || "Адрес не указан"; + document.querySelector("#roleBadge").textContent = roleLabel(center.employee_role || "owner"); + + const [dashboard, appointments, visits, employees] = await Promise.all([ + api(`/sto/dashboard?service_center_id=${center.id}`).catch(() => null), + api(`/sto/appointments?service_center_id=${center.id}`).catch(() => []), + api(`/service-centers/${center.id}/visits`).catch(() => []), + center.employee_role === "owner" ? api(`/service-centers/${center.id}/employees`).catch(() => []) : Promise.resolve([]), + ]); + state.appointments = appointments.filter((item) => ["requested", "confirmed", "confirmed_by_sto", "proposed_new_time"].includes(item.status)); + state.workOrders = visits.filter((item) => !["completed", "cancelled", "archived", "confirmed", "disputed"].includes(item.status)); + state.employees = employees; + renderDashboard(dashboard); + renderAppointments(); + renderWorkOrders(); + renderStaff(); +} + +function renderDashboard(dashboard) { + document.querySelector("#dashboardStats").innerHTML = dashboard + ? ` +
Заявки${dashboard.pending_appointments}
+
Подтверждено${dashboard.confirmed_appointments}
+
Заказ-наряды${dashboard.active_work_orders}
+
Авто${dashboard.connected_vehicles}
+
Месяц${money(dashboard.revenue_month || 0)}
+ ` + : `
Сводка недоступна
`; +} + +function renderAppointments() { + const canManage = (activeCenter()?.employee_role || "owner") === "owner"; + document.querySelector("#appointmentsList").innerHTML = state.appointments.length + ? state.appointments.map((item) => ` +
+ ${escapeHtml(item.service_name)} + ${formatDateTime(item.confirmed_start_at || item.requested_start_at)} · авто #${item.vehicle_id} + ${statusLabel(item.status)} + ${canManage ? `
+ ${item.status === "requested" ? `` : ""} + ${["confirmed", "confirmed_by_sto"].includes(item.status) ? `` : ""} + +
` : ""} +
+ `).join("") + : `
Новых записей нет
`; +} + +function renderWorkOrders() { + const role = activeCenter()?.employee_role || "owner"; + const canComplete = role === "owner"; + document.querySelector("#workOrdersList").innerHTML = state.workOrders.length + ? state.workOrders.map((item) => ` +
+
+
+ ${escapeHtml(item.work_order_number || `Заказ-наряд #${item.id}`)} + ${item.visit_date} · авто #${item.vehicle_id} · ${item.odometer || "-"} км +
+ ${statusLabel(item.status)} +
+ ${item.customer_complaint ? `Жалоба: ${escapeHtml(item.customer_complaint)}` : ""} + ${item.diagnosis ? `Диагностика: ${escapeHtml(item.diagnosis)}` : ""} +
+ Работы: ${money(item.labor_total || 0)} + Запчасти: ${money(item.product_total || 0)} + Итого: ${money(item.final_total || item.total_cost || 0)} +
+
+ + + + +
+
+ + + + +
+
+ ${["draft", "diagnosis", "approved_by_owner"].includes(item.status) ? `` : ""} + ${role === "owner" ? `` : ""} + ${canComplete ? `` : ""} +
+
+ `).join("") + : `
Активных заказ-нарядов нет
`; +} + +function renderStaff() { + const center = activeCenter(); + const panel = document.querySelector("#staffPanel"); + if (!center || center.employee_role !== "owner") { + panel.classList.add("hidden"); + return; + } + panel.classList.remove("hidden"); + document.querySelector("#employeesList").innerHTML = state.employees.length + ? state.employees.map((employee) => ` +
+ ${escapeHtml(employee.first_name || employee.username || `Telegram ${employee.telegram_id || employee.user_id}`)} + ${employee.telegram_id ? `Telegram ID: ${employee.telegram_id}` : `User #${employee.user_id}`} + ${roleLabel(employee.role)} · ${statusLabel(employee.status)} + ${employee.invite_token ? `Команда для сотрудника: /accept_sto_invite ${employee.invite_token}` : ""} + ${employee.role !== "owner" ? `
+ + + +
` : ""} +
+ `).join("") + : `
Сотрудников пока нет
`; +} + +async function runAction(button, callback) { + if (button) button.disabled = true; + try { + await callback(); + toast("Готово"); + await loadWorkplace(); + } catch (error) { + console.error(error); + toast(error.message || "Ошибка", "error"); + } finally { + if (button) button.disabled = false; + } +} + +document.querySelector("#centerSelect").addEventListener("change", async (event) => { + state.activeCenterId = Number(event.currentTarget.value); + await loadWorkplace(); +}); + +document.querySelector("#refreshBtn").addEventListener("click", () => loadWorkplace()); + +document.querySelector("#inviteForm").addEventListener("submit", async (event) => { + event.preventDefault(); + const form = event.currentTarget; + const center = activeCenter(); + const data = formData(form); + await runAction(form.querySelector('button[type="submit"]'), async () => { + const employee = await api(`/service-centers/${center.id}/employees/invite`, { + method: "POST", + body: JSON.stringify({ + telegram_id: Number(data.telegram_id), + role: data.role, + }), + }); + const result = document.querySelector("#inviteResult"); + result.classList.remove("hidden"); + result.textContent = `Передайте сотруднику команду: /accept_sto_invite ${employee.invite_token}`; + form.reset(); + }); +}); + +document.body.addEventListener("click", async (event) => { + const button = event.target.closest("button"); + if (!button) return; + const center = activeCenter(); + if (button.dataset.confirmAppointment) { + await runAction(button, () => api(`/sto/appointments/${button.dataset.confirmAppointment}/confirm`, { + method: "POST", + body: JSON.stringify({ comment: "Подтверждено в панели СТО" }), + })); + } + if (button.dataset.rejectAppointment) { + await runAction(button, () => api(`/sto/appointments/${button.dataset.rejectAppointment}/reject`, { + method: "POST", + body: JSON.stringify({ comment: "Отклонено в панели СТО" }), + })); + } + if (button.dataset.createWorkOrder) { + const odometer = window.prompt("Пробег на приемке, км") || ""; + await runAction(button, () => api(`/sto/appointments/${button.dataset.createWorkOrder}/create-work-order`, { + method: "POST", + body: JSON.stringify({ odometer: numberOrNull(odometer), notes: "Создано в панели СТО" }), + })); + } + if (button.dataset.startWorkOrder) { + await runAction(button, () => api(`/work-orders/${button.dataset.startWorkOrder}/start`, { + method: "POST", + body: JSON.stringify({ comment: "Взято в работу" }), + })); + } + if (button.dataset.submitWorkOrder) { + await runAction(button, () => api(`/work-orders/${button.dataset.submitWorkOrder}/submit-approval`, { + method: "POST", + body: JSON.stringify({ comment: "Смета готова к согласованию" }), + })); + } + if (button.dataset.completeWorkOrder) { + await runAction(button, () => api(`/work-orders/${button.dataset.completeWorkOrder}/complete`, { + method: "POST", + body: JSON.stringify({ comment: "Работы завершены" }), + })); + } + if (button.dataset.roleEmployee) { + await runAction(button, () => api(`/service-centers/employees/${button.dataset.roleEmployee}`, { + method: "PATCH", + body: JSON.stringify({ role: button.dataset.role, status: "active" }), + })); + } + if (button.dataset.disableEmployee) { + await runAction(button, () => api(`/service-centers/employees/${button.dataset.disableEmployee}`, { + method: "PATCH", + body: JSON.stringify({ status: "inactive" }), + })); + } +}); + +document.body.addEventListener("submit", async (event) => { + const form = event.target; + if (form.matches("[data-labor-form]")) { + event.preventDefault(); + const data = formData(form); + await runAction(form.querySelector('button[type="submit"]'), () => 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", + }), + })); + } + if (form.matches("[data-product-form]")) { + event.preventDefault(); + const data = formData(form); + await runAction(form.querySelector('button[type="submit"]'), () => 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", + }), + })); + } +}); + +document.querySelector("#telegramRetryBtn").addEventListener("click", () => window.location.reload()); + +Promise.all([loadAuthConfig()]) + .then(() => ensureUser()) + .then(() => loadWorkplace()) + .catch((error) => { + if (error.message === "Требуется вход через Telegram") return; + console.error(error); + toast(error.message || "Ошибка", "error"); + }); diff --git a/web/static/styles.css b/web/static/styles.css index c3c3c8d..e1c8ff8 100644 --- a/web/static/styles.css +++ b/web/static/styles.css @@ -261,7 +261,6 @@ body.auth-required .shell { .drawer-panel, .sheet-panel { max-height: 88vh; - overflow: auto; padding: 18px; border-radius: 16px 16px 0 0; background: var(--surface); @@ -270,14 +269,40 @@ body.auth-required .shell { } .drawer-panel { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + gap: 12px; width: min(520px, 100%); + height: min(88vh, 860px); margin-left: auto; + overflow: hidden; +} + +.sheet-panel { + overflow: auto; +} + +.drawer-menu, +.drawer-content { + min-height: 0; + overflow: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.drawer-menu { + display: grid; + gap: 10px; + max-height: min(38vh, 360px); + padding: 2px 4px 2px 0; +} + +.drawer-content { + padding-right: 4px; } .drawer-section { - margin-top: 14px; - padding-top: 14px; - border-top: 1px solid var(--line); + padding-top: 2px; } .drawer-form { @@ -288,7 +313,7 @@ body.auth-required .shell { .menu-row { display: block; width: 100%; - margin-bottom: 8px; + margin: 0; background: var(--soft); color: var(--text); text-align: left; @@ -1171,10 +1196,57 @@ button.is-busy { color: #0e604f; } +.menu-group { + display: grid; + gap: 6px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; +} + +.menu-group summary { + display: flex; + min-height: 28px; + align-items: center; + justify-content: space-between; + color: var(--muted); + cursor: pointer; + font-size: 12px; + font-weight: 850; + list-style: none; + text-transform: uppercase; +} + +.menu-group summary::-webkit-details-marker { + display: none; +} + +.menu-group summary::after { + content: "+"; + display: grid; + width: 22px; + height: 22px; + place-items: center; + border: 1px solid var(--line); + border-radius: 50%; + color: var(--muted); + font-size: 14px; + line-height: 1; +} + +.menu-group[open] summary::after { + content: "-"; +} + +.menu-group .menu-row { + margin-top: 6px; +} + .service-profile-card { display: grid; gap: 10px; - margin: 4px 0 12px; + margin: 0; padding: 12px; border: 1px solid rgba(18, 115, 95, 0.24); border-radius: 8px; @@ -1561,6 +1633,98 @@ select { font-weight: 700; } +.sto-page { + background: + linear-gradient(180deg, #ffffff 0, #f2f6f4 220px), + var(--bg); +} + +.sto-shell { + width: min(1380px, 100%); +} + +.sto-page .topbar { + align-items: center; +} + +.sto-page .top-actions { + align-items: center; + min-width: min(520px, 48vw); +} + +.sto-page #centerSelect { + min-width: 0; +} + +.sto-hero { + margin-bottom: 14px; +} + +.sto-hero .trust-badge { + color: #103f35; + background: #dff7ef; +} + +.mini-stats { + grid-template-columns: repeat(5, minmax(130px, 1fr)); +} + +.stat-card { + display: grid; + gap: 7px; + min-height: 88px; + padding: 13px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; + box-shadow: 0 10px 24px rgba(27, 38, 34, 0.06); +} + +.stat-card span { + color: var(--muted); + font-size: 12px; + font-weight: 750; + text-transform: uppercase; +} + +.stat-card strong { + color: var(--text); + font-size: clamp(20px, 2.6vw, 28px); + line-height: 1.05; +} + +.sto-grid { + display: grid; + grid-template-columns: minmax(320px, 0.95fr) minmax(420px, 1.35fr); + gap: 14px; + align-items: start; +} + +.sto-grid .workspace { + min-width: 0; +} + +#staffPanel { + grid-column: 1 / -1; +} + +.staff-form { + grid-template-columns: minmax(220px, 1fr) minmax(160px, 220px) auto; + margin: 0 0 10px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fbfdfc; +} + +.staff-form button { + min-width: 136px; +} + +.stack-item small { + overflow-wrap: anywhere; +} + @keyframes toastIn { from { opacity: 0; @@ -1627,6 +1791,37 @@ select { .sheet-panel { max-height: 92vh; } + + .drawer-panel { + height: 92vh; + } + + .sto-page .topbar { + align-items: stretch; + flex-direction: column; + } + + .sto-page .top-actions { + width: 100%; + min-width: 0; + } + + .sto-grid, + .staff-form { + grid-template-columns: 1fr; + } + + .mini-stats { + display: flex; + overflow-x: auto; + scroll-snap-type: x mandatory; + padding-bottom: 4px; + } + + .stat-card { + min-width: 70vw; + scroll-snap-align: start; + } } @media (max-width: 640px) { @@ -1727,6 +1922,19 @@ select { display: grid; } + .sto-page .passport-head { + grid-template-columns: 1fr; + } + + .sto-page .top-actions { + display: grid; + grid-template-columns: minmax(0, 1fr) 44px; + } + + .staff-form button { + width: 100%; + } + .auth-overlay { align-items: stretch; padding: 14px; diff --git a/web/sto.html b/web/sto.html new file mode 100644 index 0000000..a77bdce --- /dev/null +++ b/web/sto.html @@ -0,0 +1,103 @@ + + + + + + + Панель СТО + + + + + +
+
+

CarPass

+

Панель СТО

+

Откройте страницу через Telegram-бота, чтобы подтвердить доступ к рабочему месту.

+
+ + +
+ +
+
+ +
+
+
+

CarPass Business

+

Панель СТО

+
+
+ + +
+
+ +
+
+
+

Рабочее место

+

СТО

+ Загружаю доступ... +
+ Проверка +
+
+ +
+ +
+
+
+
+

Приемка

+

Записи клиентов

+
+
+
+
+ +
+
+
+

Работы

+

Заказ-наряды

+
+
+
+
+ +
+
+
+

Кадры

+

Сотрудники

+
+
+
+ + + +
+ +
+
+
+
+ + + + +