From e8b9c68c5cb72b09d7a431d0c403eacff460bcc5 Mon Sep 17 00:00:00 2001 From: wasrusgen Date: Tue, 19 May 2026 13:05:16 +0300 Subject: [PATCH] feat: staff client list for assembler/measurer (#/master/clients) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/staff_clients — returns clients grouped by client, filtered by role (assembler sees assemblies, measurer sees measurements, both if combined) Supports filter: active | done | all - staff_clients.js — client list with status tags + detail card view (phone link, assembly cards → AssemblyDetailScreen, measurement cards) - app.js — route #/master/clients, button "👥 Мои клиенты" for assembler+measurer Co-Authored-By: Claude Sonnet 4.6 --- backend-py/app/main.py | 142 ++++++++++++++ miniapp/assets/app.js | 19 ++ miniapp/assets/staff_clients.js | 326 ++++++++++++++++++++++++++++++++ miniapp/index.html | 3 +- 4 files changed, 489 insertions(+), 1 deletion(-) create mode 100644 miniapp/assets/staff_clients.js diff --git a/backend-py/app/main.py b/backend-py/app/main.py index 6058382..c970873 100644 --- a/backend-py/app/main.py +++ b/backend-py/app/main.py @@ -154,6 +154,7 @@ async def _dispatch_post(request: Request): "assembly_rate_delete": _handle_assembly_rate_delete, "assembler_analytics": _handle_assembler_analytics, "assembler_earnings": _handle_assembler_earnings, + "staff_clients": _handle_staff_clients, "contract_preview": _handle_contract_preview, "contract_save": _handle_contract_save, "proposal_brief": proposals_mod.handle_brief, @@ -3084,6 +3085,147 @@ async def api_assembler_earnings(request: Request): return _handle_assembler_earnings(body) +def _handle_staff_clients(body: dict[str, Any]) -> dict[str, Any]: + """Список клиентов для сборщика / замерщика. + Assembler: все сборки где assigned_to_tg_id == self. + Measurer: все замеры где assigned_to_tg_id == self. + Оба: объединённый список, сгруппированный по клиенту. + body: {initData, initDataUnsafe, filter?: 'active'|'done'|'all'} + """ + cfg = get_config() + auth = verify_init_data(body.get("initData") or "", cfg.bot_token) + if not auth or not auth.get("user"): + unsafe = body.get("initDataUnsafe") or {} + if isinstance(unsafe, dict) and unsafe.get("user", {}).get("id"): + auth = {"user": unsafe["user"]} + else: + return {"error": "invalid_init_data"} + tg_id = auth["user"]["id"] + user = sheets.find_user(tg_id) + if not user: + return {"error": "user_not_found"} + + is_assembler = sheets.has_role(user, "assembler") or sheets.is_master(user) + is_measurer = sheets.has_role(user, "measurer") + if not is_assembler and not is_measurer and not sheets.has_role(user, "manager"): + return {"error": "forbidden"} + + flt = str(body.get("filter") or "active").lower() # active | done | all + + def _row_visible_active(status: str) -> bool: + if flt == "all": return True + if flt == "done": return status in ("done", "completed", "cancelled") + return status not in ("done", "completed", "cancelled", "archived") + + clients: dict[str, dict] = {} # key → client card + + # ── Сборки ────────────────────────────────────────────────────── + if is_assembler or sheets.has_role(user, "manager"): + try: + _ensure_assemblies_sheet() + ws = sheets.sheet("Assemblies") + rows = ws.get_all_values() + if rows and len(rows) > 1: + headers = rows[0] + for r in rows[1:]: + row = dict(zip(headers, r + [""] * max(0, len(headers) - len(r)))) + if row.get("archived_at"): + continue + if sheets.has_role(user, "manager"): + if str(row.get("manager_tg_id")) != str(tg_id): + continue + else: + if str(row.get("assigned_to_tg_id")) != str(tg_id): + continue + status = row.get("status", "") + if not _row_visible_active(status): + continue + ckey = row.get("client_tg_id") or row.get("client_name", "").lower().strip() + if ckey not in clients: + clients[ckey] = { + "client_name": row.get("client_name", ""), + "client_phone": row.get("client_phone", ""), + "client_tg_id": row.get("client_tg_id", ""), + "assemblies": [], + "measurements": [], + } + clients[ckey]["assemblies"].append({ + "id": row.get("id", ""), + "address": row.get("address", ""), + "status": status, + "scheduled_at": row.get("scheduled_at", ""), + "scope_of_work": row.get("scope_of_work", ""), + "signed_by_name": row.get("signed_by_name", ""), + "manager_tg_id": row.get("manager_tg_id", ""), + }) + except Exception as e: + log.warning("staff_clients assemblies error: %s", e) + + # ── Замеры ─────────────────────────────────────────────────────── + if is_measurer or sheets.has_role(user, "manager"): + try: + ws2 = sheets.sheet("Measurements") + rows2 = ws2.get_all_values() + if rows2 and len(rows2) > 1: + headers2 = rows2[0] + for r in rows2[1:]: + row = dict(zip(headers2, r + [""] * max(0, len(headers2) - len(r)))) + if row.get("archived_at"): + continue + if sheets.has_role(user, "manager"): + if str(row.get("manager_tg_id")) != str(tg_id): + continue + else: + if str(row.get("assigned_to_tg_id")) != str(tg_id): + continue + status = row.get("status", "") + if not _row_visible_active(status): + continue + ckey = row.get("client_tg_id") or row.get("client_name", "").lower().strip() + if ckey not in clients: + clients[ckey] = { + "client_name": row.get("client_name", ""), + "client_phone": row.get("client_phone", ""), + "client_tg_id": row.get("client_tg_id", ""), + "assemblies": [], + "measurements": [], + } + clients[ckey]["measurements"].append({ + "id": row.get("id", ""), + "address": row.get("address", ""), + "status": status, + "scheduled_at": row.get("scheduled_at", ""), + "zamer_no": row.get("zamer_no", ""), + "layout": row.get("layout", ""), + }) + except Exception as e: + log.warning("staff_clients measurements error: %s", e) + + # ── Сортировка: сначала с ближайшей датой ─────────────────────── + def _latest_date(c: dict) -> str: + dates = ( + [a["scheduled_at"] for a in c["assemblies"] if a["scheduled_at"]] + + [m["scheduled_at"] for m in c["measurements"] if m["scheduled_at"]] + ) + return max(dates) if dates else "" + + result = sorted(clients.values(), key=_latest_date, reverse=True) + + return { + "ok": True, + "is_assembler": is_assembler, + "is_measurer": is_measurer, + "count": len(result), + "clients": result, + } + + +@app.post("/api/staff_clients") +async def api_staff_clients(request: Request): + body = await _safe_json(request) + return _handle_staff_clients(body) + + def _handle_contract_preview(body: dict[str, Any]) -> dict[str, Any]: """Возвращает данные сборки + сохранённые поля контракта для предпросмотра акта. body: {initData, initDataUnsafe, assembly_id} diff --git a/miniapp/assets/app.js b/miniapp/assets/app.js index fe7aa66..f0e951d 100644 --- a/miniapp/assets/app.js +++ b/miniapp/assets/app.js @@ -912,6 +912,22 @@ async function renderStaff(me) { renderStaffAssemblies(assemblySection.querySelector("#assemblyList")); } + // Список клиентов — сборщик и/или замерщик + if (caps.assembler || caps.measurer) { + const clientsBtn = el(` +
+ +
+ `); + clientsBtn.querySelector("button").addEventListener("click", () => { + haptic && haptic("impact"); + location.hash = "#/master/clients"; + }); + app.appendChild(clientsBtn); + } + // Шпаргалки + заработки сборщика if (caps.assembler) { const earningsBtn = el(` @@ -1716,6 +1732,9 @@ function routeByHash() { } else if (location.hash.startsWith("#/admin/rates")) { if (typeof AdminRates !== "undefined") AdminRates.mount(app); else init(); + } else if (location.hash === "#/master/clients") { + if (typeof StaffClients !== "undefined") StaffClients.mount(app); + else init(); } else if (location.hash === "#/master/dashboard") { if (typeof AssemblerDashboard !== "undefined") AssemblerDashboard.mount(app); else init(); diff --git a/miniapp/assets/staff_clients.js b/miniapp/assets/staff_clients.js new file mode 100644 index 0000000..a4239cc --- /dev/null +++ b/miniapp/assets/staff_clients.js @@ -0,0 +1,326 @@ +/* ============================================================ + StaffClients — список клиентов для сборщика / замерщика + #/master/clients + ============================================================ */ + +const StaffClients = (function () { + "use strict"; + + function escHtml(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + function el(html) { + const t = document.createElement("template"); + t.innerHTML = html.trim(); + return t.content.firstChild; + } + function fmtDate(iso) { + if (!iso) return null; + try { + return new Date(iso).toLocaleDateString("ru-RU", { + day: "numeric", month: "short", year: "numeric", + }); + } catch { return iso.slice(0, 10); } + } + + async function _api(path, body = {}) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 20000); + try { + const res = await fetch(`${BACKEND_URL}/api/${path}`, { + method: "POST", signal: ctrl.signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + initData: (typeof Platform !== "undefined" ? Platform.initData : (window.tg?.initData || "")), + initDataUnsafe: (typeof Platform !== "undefined" ? Platform.initDataUnsafe : null), + ...body, + }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return await res.json(); + } catch (e) { + if (e.name === "AbortError") throw new Error("Сервер не отвечает"); + throw e; + } finally { clearTimeout(t); } + } + + const ASM_STATUS = { + created: { icon: "🆕", text: "Создана", color: "#8e8e8e" }, + scheduled: { icon: "📅", text: "Запланирована", color: "#2980B9" }, + in_progress: { icon: "🔨", text: "В процессе", color: "#F39C12" }, + done: { icon: "✅", text: "Завершена", color: "#27AE60" }, + cancelled: { icon: "❌", text: "Отменена", color: "#C0392B" }, + }; + const MEAS_STATUS = { + new: { icon: "🆕", text: "Новый", color: "#8e8e8e" }, + scheduled: { icon: "📅", text: "Назначен", color: "#2980B9" }, + done: { icon: "✅", text: "Выполнен", color: "#27AE60" }, + cancelled: { icon: "❌", text: "Отменён", color: "#C0392B" }, + }; + + function _statusBadge(status, map) { + const s = map[status] || { icon: "•", text: status, color: "#aaa" }; + return `${s.icon} ${escHtml(s.text)}`; + } + + /* ── Главный экран ─────────────────────────────────────────── */ + async function mount(container) { + container.innerHTML = ""; + document.body.classList.remove("has-bottom-nav"); + document.getElementById("bottom-nav")?.remove(); + + // Header + const h = el(` +
+ +
Мои клиенты
+ +
+ `); + h.querySelector(".podbor-back").addEventListener("click", () => { + haptic && haptic("impact"); + history.back(); + }); + + // Фильтр + const filterEl = el(` +
+ + + +
+ `); + + const screen = el(`
`); + container.appendChild(h); + container.appendChild(filterEl); + container.appendChild(screen); + + let currentFilter = "active"; + + const load = async (filter) => { + currentFilter = filter; + screen.innerHTML = `
`; + try { + const data = await _api("staff_clients", { filter }); + if (data.error) { + screen.innerHTML = `
${escHtml(data.error)}
`; + return; + } + _render(screen, data, container); + } catch (e) { + screen.innerHTML = `
Ошибка: ${escHtml(e.message)}
`; + } + }; + + filterEl.querySelectorAll(".sc-filter").forEach(btn => { + btn.addEventListener("click", () => { + filterEl.querySelectorAll(".sc-filter").forEach(b => { + b.style.background = "var(--surface)"; + b.style.color = "var(--muted)"; + b.style.borderColor = "var(--border)"; + }); + btn.style.background = "var(--accent)"; + btn.style.color = "#fff"; + btn.style.borderColor = "var(--accent)"; + haptic && haptic("selection"); + load(btn.dataset.f); + }); + }); + + h.querySelector("#reloadBtn").addEventListener("click", () => { + haptic && haptic("impact"); + load(currentFilter); + }); + + load("active"); + } + + /* ── Рендер ─────────────────────────────────────────────────── */ + function _render(screen, data, container) { + screen.innerHTML = ""; + + const clients = data.clients || []; + if (!clients.length) { + screen.appendChild(el(` +
+
📋
+
Клиентов нет
+
По выбранному фильтру ничего не найдено
+
+ `)); + return; + } + + // Роль-бейдж в шапке + const roles = []; + if (data.is_assembler) roles.push("сборщик"); + if (data.is_measurer) roles.push("замерщик"); + if (roles.length) { + screen.appendChild(el(` +
+ ${escHtml(roles.join(" · "))} · ${clients.length} клиентов +
+ `)); + } + + clients.forEach(c => { + const asmCount = c.assemblies.length; + const measCount = c.measurements.length; + + // Ближайшая дата + const dates = [ + ...c.assemblies.map(a => a.scheduled_at), + ...c.measurements.map(m => m.scheduled_at), + ].filter(Boolean).sort(); + const nearestDate = dates[0] || null; + + // Статусы для превью + const asmStatuses = c.assemblies.map(a => a.status); + const measStatuses = c.measurements.map(m => m.status); + + const card = el(` +
+
+
+
+ ${escHtml(c.client_name || "Без имени")} +
+ ${c.client_phone ? `
${escHtml(c.client_phone)}
` : ""} +
+ ${nearestDate ? `
${escHtml(fmtDate(nearestDate))}
` : ""} +
+ + +
+ ${asmCount ? c.assemblies.map(a => ` + + ${_statusBadge(a.status, ASM_STATUS)} + ${a.address ? `${escHtml(a.address.split(",")[0])}` : ""} + + `).join("") : ""} + ${measCount ? c.measurements.map(m => ` + + 📐 ${_statusBadge(m.status, MEAS_STATUS).replace(/<[^>]+>/g,'').trim()} + + `).join("") : ""} +
+
+ `); + + card.addEventListener("click", () => { + haptic && haptic("impact"); + _openClientDetail(container, c, data); + }); + + screen.appendChild(card); + }); + + screen.appendChild(el(`
`)); + } + + /* ── Детальная карточка клиента ────────────────────────────── */ + function _openClientDetail(container, c, listData) { + container.innerHTML = ""; + + const h = el(` +
+ +
+ ${escHtml(c.client_name || "Клиент")} +
+
+
+ `); + h.querySelector(".podbor-back").addEventListener("click", () => { + haptic && haptic("impact"); + mount(container); + }); + + const screen = el(`
`); + container.appendChild(h); + container.appendChild(screen); + + // Контакты + const phone = c.client_phone || ""; + screen.appendChild(el(` +
+
+ ${escHtml(c.client_name || "Без имени")} +
+ ${phone ? ` + + 📞 + ${escHtml(phone)} + + ` : `
Телефон не указан
`} +
+ `)); + + // Сборки + if (c.assemblies.length) { + screen.appendChild(el(`
🔨 Сборки · ${c.assemblies.length}
`)); + c.assemblies.forEach(a => { + const s = ASM_STATUS[a.status] || { icon: "•", text: a.status, color: "#aaa" }; + const asmCard = el(` +
+
+
+
${s.icon} ${escHtml(s.text)}
+ ${a.address ? `
${escHtml(a.address)}
` : ""} + ${a.scope_of_work ? `
${escHtml(a.scope_of_work.slice(0,60))}
` : ""} +
+
+ ${a.scheduled_at ? `
${escHtml(fmtDate(a.scheduled_at))}
` : ""} + ${a.signed_by_name ? `
✅ Подписан
` : ""} +
+
+
+ `); + asmCard.addEventListener("click", () => { + haptic && haptic("impact"); + if (typeof AssemblyDetailScreen !== "undefined") { + location.hash = `#/assembly/${a.id}`; + } + }); + screen.appendChild(asmCard); + }); + } + + // Замеры + if (c.measurements.length) { + screen.appendChild(el(`
📐 Замеры · ${c.measurements.length}
`)); + c.measurements.forEach(m => { + const s = MEAS_STATUS[m.status] || { icon: "•", text: m.status, color: "#aaa" }; + const mCard = el(` +
+
+
+
${s.icon} ${escHtml(s.text)}
+ ${m.address ? `
${escHtml(m.address)}
` : ""} + ${m.zamer_no ? `
Замер №${escHtml(m.zamer_no)}
` : ""} +
+ ${m.scheduled_at ? `
${escHtml(fmtDate(m.scheduled_at))}
` : ""} +
+
+ `); + screen.appendChild(mCard); + }); + } + + screen.appendChild(el(`
`)); + } + + return { mount }; +})(); diff --git a/miniapp/index.html b/miniapp/index.html index e462f8d..ef8f6f0 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -56,8 +56,9 @@ + - +