This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
141
bot/main.py
141
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 <token>")
|
||||
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 <token> — принять приглашение сотрудника;\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)),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
1094
web/index.html
1094
web/index.html
File diff suppressed because it is too large
Load Diff
@@ -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 = `<div class="empty">СТО пока не создано</div>`;
|
||||
list.innerHTML = `<div class="empty">Календарь доступен только сотрудникам подтвержденного СТО.</div>`;
|
||||
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 = `<div class="empty">Сначала зарегистрируйте СТО или примите приглашение сотрудника.</div>`;
|
||||
list.innerHTML = `<div class="empty">Рабочее место доступно владельцу подтвержденного СТО и активным механикам.</div>`;
|
||||
return;
|
||||
}
|
||||
centerSelect.innerHTML = state.serviceCenters
|
||||
.filter((center) => centers.some((item) => item.id === center.id))
|
||||
.map((center) => `<option value="${center.id}">${escapeHtml(center.display_name || center.name)}</option>`)
|
||||
.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);
|
||||
|
||||
427
web/static/sto.js
Normal file
427
web/static/sto.js
Normal file
@@ -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, ">")
|
||||
.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 = `<div class="empty">Нет доступного подтвержденного СТО.</div>`;
|
||||
document.querySelector("#workOrdersList").innerHTML = `<div class="empty">Рабочее место недоступно.</div>`;
|
||||
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) => `<option value="${item.id}">${escapeHtml(item.display_name || item.name)}</option>`)
|
||||
.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
|
||||
? `
|
||||
<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="stat-card"><span>Месяц</span><strong>${money(dashboard.revenue_month || 0)}</strong></div>
|
||||
`
|
||||
: `<div class="empty">Сводка недоступна</div>`;
|
||||
}
|
||||
|
||||
function renderAppointments() {
|
||||
const canManage = (activeCenter()?.employee_role || "owner") === "owner";
|
||||
document.querySelector("#appointmentsList").innerHTML = state.appointments.length
|
||||
? state.appointments.map((item) => `
|
||||
<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">${statusLabel(item.status)}</span>
|
||||
${canManage ? `<div class="row-actions">
|
||||
${item.status === "requested" ? `<button type="button" data-confirm-appointment="${item.id}">Подтвердить</button>` : ""}
|
||||
${["confirmed", "confirmed_by_sto"].includes(item.status) ? `<button type="button" data-create-work-order="${item.id}">Открыть заказ-наряд</button>` : ""}
|
||||
<button type="button" class="ghost-btn" data-reject-appointment="${item.id}">Отклонить</button>
|
||||
</div>` : ""}
|
||||
</div>
|
||||
`).join("")
|
||||
: `<div class="empty">Новых записей нет</div>`;
|
||||
}
|
||||
|
||||
function renderWorkOrders() {
|
||||
const role = activeCenter()?.employee_role || "owner";
|
||||
const canComplete = role === "owner";
|
||||
document.querySelector("#workOrdersList").innerHTML = state.workOrders.length
|
||||
? state.workOrders.map((item) => `
|
||||
<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">${statusLabel(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>
|
||||
<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">
|
||||
${["draft", "diagnosis", "approved_by_owner"].includes(item.status) ? `<button type="button" data-start-work-order="${item.id}">В работу</button>` : ""}
|
||||
${role === "owner" ? `<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>
|
||||
`).join("")
|
||||
: `<div class="empty">Активных заказ-нарядов нет</div>`;
|
||||
}
|
||||
|
||||
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) => `
|
||||
<div class="stack-item">
|
||||
<strong>${escapeHtml(employee.first_name || employee.username || `Telegram ${employee.telegram_id || employee.user_id}`)}</strong>
|
||||
<small>${employee.telegram_id ? `Telegram ID: ${employee.telegram_id}` : `User #${employee.user_id}`}</small>
|
||||
<span class="trust-badge">${roleLabel(employee.role)} · ${statusLabel(employee.status)}</span>
|
||||
${employee.invite_token ? `<small>Команда для сотрудника: /accept_sto_invite ${employee.invite_token}</small>` : ""}
|
||||
${employee.role !== "owner" ? `<div class="row-actions">
|
||||
<button type="button" data-role-employee="${employee.id}" data-role="mechanic">Механик</button>
|
||||
<button type="button" data-role-employee="${employee.id}" data-role="receptionist">Администратор</button>
|
||||
<button type="button" class="ghost-btn" data-disable-employee="${employee.id}">Отключить</button>
|
||||
</div>` : ""}
|
||||
</div>
|
||||
`).join("")
|
||||
: `<div class="empty">Сотрудников пока нет</div>`;
|
||||
}
|
||||
|
||||
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");
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
103
web/sto.html
Normal file
103
web/sto.html
Normal file
@@ -0,0 +1,103 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#16806a" />
|
||||
<title>Панель СТО</title>
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
</head>
|
||||
<body class="auth-required sto-page">
|
||||
<div class="auth-overlay" id="authOverlay">
|
||||
<div class="auth-panel">
|
||||
<p class="eyebrow">CarPass</p>
|
||||
<h1>Панель СТО</h1>
|
||||
<p id="authMessage">Откройте страницу через Telegram-бота, чтобы подтвердить доступ к рабочему месту.</p>
|
||||
<div class="auth-actions">
|
||||
<a id="telegramLoginLink" class="telegram-login-link hidden" href="#" rel="noreferrer">Открыть в Telegram</a>
|
||||
<button id="telegramRetryBtn" class="telegram-secondary-btn" type="button">Проверить вход</button>
|
||||
</div>
|
||||
<div id="telegramLoginSlot" class="telegram-login-slot"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="shell sto-shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">CarPass Business</p>
|
||||
<h1>Панель СТО</h1>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<select id="centerSelect" aria-label="СТО"></select>
|
||||
<button class="icon-btn" id="refreshBtn" title="Обновить" aria-label="Обновить">↻</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="passport-panel sto-hero">
|
||||
<div class="passport-head">
|
||||
<div>
|
||||
<p class="eyebrow">Рабочее место</p>
|
||||
<h2 id="centerTitle">СТО</h2>
|
||||
<small id="centerMeta">Загружаю доступ...</small>
|
||||
</div>
|
||||
<span class="trust-badge" id="roleBadge">Проверка</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stats mini-stats" id="dashboardStats"></section>
|
||||
|
||||
<section class="sto-grid">
|
||||
<section class="workspace">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Приемка</p>
|
||||
<h2>Записи клиентов</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div id="appointmentsList" class="stack-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="workspace">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Работы</p>
|
||||
<h2>Заказ-наряды</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div id="workOrdersList" class="stack-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="workspace" id="staffPanel">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Кадры</p>
|
||||
<h2>Сотрудники</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form id="inviteForm" class="grid-form drawer-form staff-form">
|
||||
<label>
|
||||
Telegram ID сотрудника
|
||||
<input name="telegram_id" type="number" required />
|
||||
</label>
|
||||
<label>
|
||||
Роль
|
||||
<select name="role">
|
||||
<option value="mechanic">Механик</option>
|
||||
<option value="receptionist">Администратор</option>
|
||||
<option value="manager">Менеджер</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Пригласить</button>
|
||||
</form>
|
||||
<div id="inviteResult" class="tip-card hidden"></div>
|
||||
<div id="employeesList" class="stack-list"></div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="toast hidden" id="toast" role="status" aria-live="polite"></div>
|
||||
<script src="/static/sto.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user