diff --git a/backend-py/app/main.py b/backend-py/app/main.py index f534472..4774993 100644 --- a/backend-py/app/main.py +++ b/backend-py/app/main.py @@ -105,6 +105,8 @@ async def _dispatch_post(request: Request): "podbor": _handle_podbor, "clients": _handle_clients, "lead": _handle_lead, + "grant_role": _handle_grant_role, + "staff_list": _handle_staff_list, "ping": lambda b: {"pong": True, "time": _now_iso()}, "seed_admin": lambda b: _handle_seed_admin(), "test_ai": lambda b: _handle_test_ai(), @@ -177,6 +179,20 @@ async def api_measurement_detail(request: Request): return _handle_measurement_detail(body) +@app.post("/api/grant_role") +async def api_grant_role(request: Request): + """Админ выдаёт роль другому пользователю. + body: {initData, target_tg_id, role: 'measurer'|'assembler'|'manager'|'client', action: 'grant'|'revoke'}""" + body = await _safe_json(request) + return _handle_grant_role(body) + + +@app.post("/api/staff_list") +async def api_staff_list(request: Request): + body = await _safe_json(request) + return _handle_staff_list(body) + + @app.get("/api/photo/{measurement_id}/{filename}") async def api_photo(measurement_id: str, filename: str): """Отдаёт фото замера. Защита от path traversal — только разрешённые id и имена.""" @@ -393,16 +409,48 @@ def _handle_me(body: dict[str, Any]) -> dict[str, Any]: tg_user = auth["user"] tg_id = tg_user["id"] start_param = body.get("startParam") or auth.get("start_param") - explicit_role = body.get("role") if body.get("role") in ("manager", "client") else None + explicit_role = body.get("role") if body.get("role") in ("manager", "client", "staff") else None user = sheets.get_or_create_user(tg_user, start_param, explicit_role) + roles = sheets.parse_roles(user.get("role", "")) - if user.get("role") == "manager": + # Staff (замерщик / сборщик) — отдельный кабинет, доступен только тем у кого роль выдана + if explicit_role == "staff": + has_measurer = "measurer" in roles + has_assembler = "assembler" in roles + if not (has_measurer or has_assembler): + return { + "role": "staff", + "roles": roles, + "error": "no_staff_role", + "user": { + "tg_id": tg_id, + "full_name": user.get("full_name", ""), + "avatar_initial": _initial(user.get("full_name") or tg_user.get("first_name", "")), + }, + } + full_name = user.get("full_name", "") or tg_user.get("first_name", "") + return { + "role": "staff", + "roles": roles, + "user": { + "tg_id": tg_id, + "full_name": full_name, + "avatar_initial": _initial(full_name), + }, + "capabilities": { + "measurer": has_measurer, + "assembler": has_assembler, + }, + } + + if "manager" in roles: m = sheets.get_manager_profile(tg_id) or { "full_name": user.get("full_name", ""), "salon": "", "is_zov_employee": False, "status": "lapsed", "active_until": None, } return { "role": "manager", + "roles": roles, "user": { "tg_id": tg_id, "full_name": m.get("full_name") or user.get("full_name", ""), @@ -429,6 +477,7 @@ def _handle_me(body: dict[str, Any]) -> dict[str, Any]: full_name = c.get("full_name") or user.get("full_name", "") return { "role": "client", + "roles": roles, "user": { "tg_id": tg_id, "full_name": full_name, @@ -764,6 +813,63 @@ def _handle_lead(body: dict[str, Any]) -> dict[str, Any]: } +def _handle_grant_role(body: dict[str, Any]) -> dict[str, Any]: + """Только админ может выдавать/отзывать роли.""" + cfg = get_config() + auth = verify_init_data(body.get("initData") or "", cfg.bot_token) + if not auth or not auth.get("user"): + return {"error": "invalid_init_data"} + tg_id = auth["user"]["id"] + if int(tg_id) != int(cfg.admin_tg_id): + return {"error": "admin_only"} + + target = body.get("target_tg_id") + role = body.get("role", "").strip() + action = body.get("action", "grant") + if not target or not role: + return {"error": "missing_fields"} + try: + target_int = int(target) + except (TypeError, ValueError): + return {"error": "bad_target"} + + if role not in sheets.VALID_ROLES: + return {"error": "unknown_role", "valid": sorted(sheets.VALID_ROLES)} + + if action == "revoke": + changed = sheets.revoke_role(target_int, role) + else: + changed = sheets.grant_role(target_int, role) + + sheets.log_event("role_changed", tg_id, {"target": target_int, "role": role, "action": action, "changed": changed}) + + updated_user = sheets.find_user(target_int) or {} + return { + "ok": True, + "target_tg_id": target_int, + "changed": changed, + "roles": sheets.parse_roles(updated_user.get("role", "")), + } + + +def _handle_staff_list(body: dict[str, Any]) -> dict[str, Any]: + """Список сотрудников с указанной ролью — для dropdown «выбрать замерщика».""" + cfg = get_config() + auth = verify_init_data(body.get("initData") or "", cfg.bot_token) + if not auth or not auth.get("user"): + return {"error": "invalid_init_data"} + tg_id = auth["user"]["id"] + user = sheets.find_user(tg_id) + if not user or not sheets.has_role(user, "manager"): + return {"error": "only_manager"} + + role = (body.get("role") or "").strip() + if role not in sheets.VALID_ROLES: + return {"error": "unknown_role"} + + return {"ok": True, "role": role, "staff": sheets.list_users_with_role(role)} + + def _handle_measurement_detail(body: dict[str, Any]) -> dict[str, Any]: """Возвращает один замер целиком — для детальной страницы и печати.""" cfg = get_config() diff --git a/backend-py/app/sheets.py b/backend-py/app/sheets.py index ebbfd0d..f3dea50 100644 --- a/backend-py/app/sheets.py +++ b/backend-py/app/sheets.py @@ -108,7 +108,87 @@ def find_user(tg_id: int) -> dict[str, Any] | None: return None full_name = (f"{row.get('first_name', '')} {row.get('last_name', '')}".strip() or row.get("tg_username", "")) - return {**row, "full_name": full_name} + roles = parse_roles(row.get("role", "")) + return {**row, "full_name": full_name, "roles": roles} + + +# ---- Multi-role helpers ---- + +VALID_ROLES = {"manager", "client", "measurer", "assembler"} + + +def parse_roles(role_str: str) -> list[str]: + """Парсит CSV-роли: 'manager,measurer' → ['manager', 'measurer']. + Старые однострочные значения тоже работают: 'manager' → ['manager'].""" + if not role_str: + return [] + parts = [p.strip() for p in str(role_str).split(",") if p.strip()] + return [p for p in parts if p in VALID_ROLES] + + +def has_role(user: dict[str, Any] | None, role: str) -> bool: + if not user: + return False + return role in parse_roles(user.get("role", "")) + + +def primary_role(user: dict[str, Any] | None) -> str: + """Первая (главная) роль для legacy-кода: manager > measurer > assembler > client.""" + if not user: + return "" + roles = parse_roles(user.get("role", "")) + for r in ("manager", "measurer", "assembler", "client"): + if r in roles: + return r + return roles[0] if roles else "" + + +def grant_role(tg_id: int, role: str) -> bool: + """Добавляет роль пользователю (если её ещё нет). Возвращает True если что-то изменилось.""" + if role not in VALID_ROLES: + return False + user = find_user(tg_id) + if not user: + return False + current = parse_roles(user.get("role", "")) + if role in current: + return False + current.append(role) + return update_cell_by_key("Users", "tg_id", tg_id, "role", ",".join(current)) + + +def revoke_role(tg_id: int, role: str) -> bool: + user = find_user(tg_id) + if not user: + return False + current = parse_roles(user.get("role", "")) + if role not in current: + return False + current.remove(role) + new_val = ",".join(current) if current else "client" # fallback роль + return update_cell_by_key("Users", "tg_id", tg_id, "role", new_val) + + +def list_users_with_role(role: str) -> list[dict[str, Any]]: + """Все пользователи, у которых есть указанная роль (для dropdown «выбрать замерщика»).""" + s = sheet("Users") + rows = s.get_all_values() + if not rows: + return [] + headers = rows[0] + out: list[dict[str, Any]] = [] + for r in rows[1:]: + row = dict(zip(headers, r + [""] * (len(headers) - len(r)))) + if role in parse_roles(row.get("role", "")): + full_name = (f"{row.get('first_name', '')} {row.get('last_name', '')}".strip() + or row.get("tg_username", "")) + out.append({ + "tg_id": row.get("tg_id"), + "full_name": full_name, + "tg_username": row.get("tg_username", ""), + "roles": parse_roles(row.get("role", "")), + }) + return out def get_or_create_user(tg_user: dict[str, Any], start_param: str | None, @@ -122,14 +202,18 @@ def get_or_create_user(tg_user: dict[str, Any], start_param: str | None, if existing: update_cell_by_key("Users", "tg_id", tg_id, "last_seen_at", now_str) - # Админ всегда manager - if tg_id == admin_id and existing.get("role") != "manager": - update_cell_by_key("Users", "tg_id", tg_id, "role", "manager") + # Админ всегда имеет роль manager (могут быть и другие) + if tg_id == admin_id and not has_role(existing, "manager"): + grant_role(tg_id, "manager") ensure_admin_manager(tg_user) - existing["role"] = "manager" - elif explicit_role and tg_id != admin_id and existing.get("role") != explicit_role: - update_cell_by_key("Users", "tg_id", tg_id, "role", explicit_role) - existing["role"] = explicit_role + existing["roles"] = parse_roles((find_user(tg_id) or {}).get("role", "")) + # explicit_role из query (?role=manager|client|staff) — не перетираем уже выданные роли, + # только добавляем если человек впервые открыл эту секцию + elif explicit_role and explicit_role in VALID_ROLES and not has_role(existing, explicit_role): + # client/manager — стандартные роли любой может получить через выбор в боте + if explicit_role in ("manager", "client"): + grant_role(tg_id, explicit_role) + existing["roles"] = parse_roles((find_user(tg_id) or {}).get("role", "")) return existing # Новый пользователь @@ -148,7 +232,7 @@ def get_or_create_user(tg_user: dict[str, Any], start_param: str | None, tg_user.get("username", ""), tg_user.get("first_name", ""), tg_user.get("last_name", ""), - role, + role, # хранится как CSV; для новых = одна роль now_str, now_str, invite_code, diff --git a/bot/handlers/start.py b/bot/handlers/start.py index d38b1cc..a112ee6 100644 --- a/bot/handlers/start.py +++ b/bot/handlers/start.py @@ -36,7 +36,7 @@ def _wapp(miniapp_url: str, role: str) -> WebAppInfo: # ============================================================ -# Reply keyboard — выбор роли. Оба кнопки сразу открывают MiniApp. +# Reply keyboard — выбор роли. Три кнопки, все WebApp. # ============================================================ def role_choice_kb(miniapp_url: str) -> ReplyKeyboardMarkup: @@ -46,6 +46,9 @@ def role_choice_kb(miniapp_url: str) -> ReplyKeyboardMarkup: KeyboardButton(text="👤 Я менеджер", web_app=_wapp(miniapp_url, "manager")), KeyboardButton(text="🏠 Я клиент", web_app=_wapp(miniapp_url, "client")), ], + [ + KeyboardButton(text="🔧 Я сотрудник", web_app=_wapp(miniapp_url, "staff")), + ], ], resize_keyboard=True, is_persistent=True, @@ -61,7 +64,8 @@ def role_choice_kb(miniapp_url: str) -> ReplyKeyboardMarkup: async def cmd_start(message: Message, config: Config) -> None: await message.answer( "👋 Здравствуйте, я бот-помощник от Руслана ВАСИЛЬЕВА.\n\n" - "Выберите, кто вы — кабинет откроется одним тапом.", + "Выберите, кто вы — кабинет откроется одним тапом.\n\n" + "«Сотрудник» — для замерщиков и сборщиков ЗОВ. Если вы менеджер или клиент — выбирайте свою роль.", reply_markup=role_choice_kb(config.miniapp_url), ) @@ -74,3 +78,21 @@ async def cmd_menu(message: Message, config: Config) -> None: @router.message(Command("hide")) async def cmd_hide(message: Message) -> None: await message.answer("Клавиатура скрыта. Вернуть — /menu", reply_markup=ReplyKeyboardRemove()) + + +# ============================================================ +# /whoami — сотрудник присылает свой ID куратору, чтобы тот выдал роль +# ============================================================ + +@router.message(Command("whoami")) +async def cmd_whoami(message: Message) -> None: + user = message.from_user + if not user: + return + await message.answer( + f"Ваш Telegram ID: {user.id}\n" + f"Username: @{user.username or '—'}\n" + f"Имя: {user.first_name or ''} {user.last_name or ''}".strip() + + "\n\n" + "Перешлите это сообщение куратору @wasrusgen чтобы вам выдали роль замерщика/сборщика." + ) diff --git a/miniapp/assets/app.js b/miniapp/assets/app.js index 8cd5aad..32a9a2c 100644 --- a/miniapp/assets/app.js +++ b/miniapp/assets/app.js @@ -343,6 +343,72 @@ function buildMenu(items) { return menu; } +/* ----------------- Staff (замерщик / сборщик) ----------------- */ +function renderStaff(me) { + app.innerHTML = ""; + + if (me.error === "no_staff_role") { + app.appendChild(el(` +
+
🔒
+

У вас нет
прав сотрудника

+

Чтобы получить роль замерщика или сборщика — отправьте куратору ваш Telegram ID.

+
+
Ваш ID${me.user?.tg_id || "—"}
+
Имя${me.user?.full_name || "—"}
+
+

+ В боте отправьте /whoami и перешлите ответ + @wasrusgen. +

+
+ `)); + return; + } + + const caps = me.capabilities || {}; + const labels = []; + if (caps.measurer) labels.push("замерщик"); + if (caps.assembler) labels.push("сборщик"); + const subtitle = labels.length ? labels.join(" · ") : "сотрудник"; + + app.appendChild(el(` +
+
${me.user?.avatar_initial || "?"}
+
+
${subtitle}
+

${me.user?.full_name || "Сотрудник"}

+
+
+ `)); + + // Заглушка — реальный инбокс заявок будет в следующем коммите + const inbox = el(` +
+
📥 Входящие заявки
+
+ Пока пусто — менеджеры ещё не назначили вам заявки.
+ Здесь появятся ${caps.measurer ? "замеры" : ""}${caps.measurer && caps.assembler ? " и " : ""}${caps.assembler ? "сборки" : ""}. +
+
+ `); + app.appendChild(inbox); + + // Если у сотрудника также есть роль measurer — показываем быструю кнопку «Сделать замер» + if (caps.measurer) { + const quick = el(` +
+ +
+ `); + quick.querySelector("#newMeasure").addEventListener("click", () => { + haptic && haptic("impact"); + location.hash = "#/measure"; + }); + app.appendChild(quick); + } +} + function renderError() { app.innerHTML = ""; app.appendChild(el(` @@ -403,8 +469,13 @@ async function init() { hideSplash(); return; } - if (me.role === "manager") renderManager(me); - else renderClient(me); + if (me.role === "staff") { + renderStaff(me); + } else if (me.role === "manager") { + renderManager(me); + } else { + renderClient(me); + } hideSplash(); } catch (e) { console.error(e); @@ -424,7 +495,8 @@ function routeByHash() { // Главный экран по роли const me = window.__zovMe; if (!me) { init(); return; } - if (me.role === "manager") renderManager(me); + if (me.role === "staff") renderStaff(me); + else if (me.role === "manager") renderManager(me); else renderClient(me); } } diff --git a/miniapp/assets/podbor.css b/miniapp/assets/podbor.css index e24adee..e609a0d 100644 --- a/miniapp/assets/podbor.css +++ b/miniapp/assets/podbor.css @@ -1989,6 +1989,46 @@ overflow-x: auto; } +/* ===== Кабинет сотрудника (замерщик/сборщик) ===== */ +.staff-head { + display: flex; + align-items: center; + gap: 14px; + margin: 16px 4px 22px; +} +.staff-avatar { + width: 52px; + height: 52px; + border-radius: 14px; + background: var(--walnut, #6B4A2B); + color: var(--paper, #FBF7F0); + display: grid; + place-items: center; + font-weight: 700; + font-size: 22px; + font-family: var(--font-display, "Newsreader", serif); +} +.staff-no-role { + padding: 32px 16px; + text-align: center; +} +.staff-no-role-ico { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.6; +} +.staff-no-role .block { + margin-top: 18px; + text-align: left; +} +.staff-no-role code { + background: var(--warm, rgba(107, 74, 43, 0.08)); + padding: 2px 6px; + border-radius: 4px; + font-family: var(--font-mono, "JetBrains Mono", monospace); + font-size: 12px; +} + /* ===== Фото замера ===== */ .photo-uploader { margin: 12px 0 14px; } diff --git a/miniapp/index.html b/miniapp/index.html index e76f82e..59ba03b 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -12,8 +12,8 @@ - - + + @@ -34,12 +34,12 @@
- - - - - - - + + + + + + +