From 52eb0e4a96768bb12c880b9f5fa59d656ce5b0b5 Mon Sep 17 00:00:00 2001 From: wasrusgen Date: Thu, 14 May 2026 09:53:40 +0300 Subject: [PATCH] =?UTF-8?q?Phase=204=20stage=201:=20=D0=A1=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=D0=BA=D0=B8=20=E2=80=94=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=20+=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20+=20=D1=81=D0=BF=D0=B8=D1=81=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - sheets.is_master(user) — единая роль measurer ∨ assembler - grant_role() автоматически выдаёт парную роль (measurer ↔ assembler) - Новая таблица Assemblies со схемой: client, scope, scheduled_at, status (created|scheduled|in_progress|completed|cancelled), photos_before/in_progress/after, signature_file, gcal_event_id - POST /api/assembly_create — менеджер заводит сборку, при scheduled_at создаётся событие Google Calendar (4 часа) - POST /api/assembly_list — фильтр по роли: менеджер видит свои, мастер — назначенные + неназначенные (created/scheduled) - POST /api/assembly_detail — карточка с правами доступа - /api/photo: добавил MIME для pdf/dwg/dxf (для DWG-блока B+E) Frontend (assembly.js — новый модуль): - Форма /api/assembly_create с валидацией: имя, адрес, scope - Pre-fill из карточки клиента (sessionStorage.prefillAssembly, адрес + measurement_id из последнего замера) - Список сборок + детальная карточка со статусом и составом работ - Маршруты: #/assembly, #/assembly/new, #/assembly/ Frontend (app.js + clients.js): - Кнопка «🔨 Заказать сборку» в карточке клиента - Quick-action «Сборки» на главной менеджера - Блок «🔨 Сборки» в кабинете мастера (caps.measurer ∨ assembler) CSS: .assembly-card / .assembly-card-* (золотой бордер) index.html: cache bump v=20260514c --- backend-py/app/main.py | 278 ++++++++++++++++++++++++++ backend-py/app/sheets.py | 24 ++- miniapp/assets/app.js | 69 +++++++ miniapp/assets/assembly.js | 395 +++++++++++++++++++++++++++++++++++++ miniapp/assets/clients.js | 10 + miniapp/assets/podbor.css | 57 ++++++ miniapp/index.html | 25 +-- 7 files changed, 843 insertions(+), 15 deletions(-) create mode 100644 miniapp/assets/assembly.js diff --git a/backend-py/app/main.py b/backend-py/app/main.py index 575b819..7c3f9fc 100644 --- a/backend-py/app/main.py +++ b/backend-py/app/main.py @@ -120,6 +120,9 @@ async def _dispatch_post(request: Request): "measurement_design_upload": _handle_measurement_design_upload, "measurement_decision": _handle_measurement_decision, "manager_pending": _handle_manager_pending, + "assembly_create": _handle_assembly_create, + "assembly_list": _handle_assembly_list, + "assembly_detail": _handle_assembly_detail, "ping": lambda b: {"pong": True, "time": _now_iso()}, "seed_admin": lambda b: _handle_seed_admin(), "test_ai": lambda b: _handle_test_ai(), @@ -264,6 +267,24 @@ async def api_manager_pending(request: Request): return _handle_manager_pending(body) +@app.post("/api/assembly_create") +async def api_assembly_create(request: Request): + body = await _safe_json(request) + return _handle_assembly_create(body) + + +@app.post("/api/assembly_list") +async def api_assembly_list(request: Request): + body = await _safe_json(request) + return _handle_assembly_list(body) + + +@app.post("/api/assembly_detail") +async def api_assembly_detail(request: Request): + body = await _safe_json(request) + return _handle_assembly_detail(body) + + @app.post("/api/grant_role") async def api_grant_role(request: Request): """Админ выдаёт роль другому пользователю. @@ -1727,6 +1748,263 @@ def _handle_manager_pending(body: dict[str, Any]) -> dict[str, Any]: return {"ok": True, "count": len(out), "pending": out} +# ================================================================= +# Сборки (Phase 4) — workflow от подписанного договора до приёмки +# ================================================================= + +def _assembly_columns() -> list[str]: + return [ + "id", "ts", + # Связи + "manager_tg_id", "assigned_to_tg_id", + "client_name", "client_phone", "address", + "measurement_id", "lead_id", "client_tg_id", + # Скоуп и расписание + "scope_of_work", # текстовое описание + "scheduled_at", # ISO + # Статус: created | scheduled | in_progress | completed | cancelled + "status", + "started_at", "completed_at", + # Фото-отчёт: списки имён файлов через запятую (внутри PHOTOS_DIR//) + "photos_before", "photos_in_progress", "photos_after", + # Приёмка ППР: подпись клиента (PNG dataURL → файл) + ФИО/дата + "signature_file", "signed_by_name", "signed_at", + # Google Calendar + "gcal_event_id", "gcal_event_url", + # Прочее + "manager_note", + "archived_at", + ] + + +def _ensure_assemblies_sheet() -> None: + """Догоняет схему Assemblies (добавляет недостающие колонки).""" + try: + ws = sheets.sheet("Assemblies") + existing = ws.row_values(1) + except Exception: + sheets.ensure_sheet("Assemblies", _assembly_columns()) + return + want = _assembly_columns() + missing = [c for c in want if c not in existing] + if missing: + new_headers = existing + missing + ws.update("A1", [new_headers]) + log.info("Assemblies: дополнили колонки: %s", missing) + + +def _row_for_assembly(assembly_id: str, ts: str, **fields) -> list[str]: + cols = _assembly_columns() + base = {c: "" for c in cols} + base["id"] = assembly_id + base["ts"] = ts + base["status"] = "created" + base.update(fields) + return [str(base.get(c, "")) for c in cols] + + +def _handle_assembly_create(body: dict[str, Any]) -> dict[str, Any]: + """Менеджер заводит сборку. + body: {initData, client_name, client_phone?, address, scope_of_work, + measurement_id?, lead_id?, scheduled_at?, manager_note?}""" + 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 or not sheets.has_role(user, "manager"): + return {"error": "only_manager"} + + _ensure_assemblies_sheet() + + client_name = (body.get("client_name") or "").strip() + address = (body.get("address") or "").strip() + scope = (body.get("scope_of_work") or "").strip() + if not client_name: + return {"error": "missing_client_name"} + if not address: + return {"error": "missing_address"} + if not scope: + return {"error": "missing_scope"} + + phone_raw = (body.get("client_phone") or "").strip() + phone_norm, _ = _normalize_phone(phone_raw) if phone_raw else ("", False) + + assembly_id = _short_id() + ts = _now_iso() + scheduled_at = (body.get("scheduled_at") or "").strip() + status = "scheduled" if scheduled_at else "created" + + fields = { + "manager_tg_id": tg_id, + "client_name": client_name, + "client_phone": phone_norm or phone_raw, + "address": address, + "scope_of_work": scope, + "measurement_id": (body.get("measurement_id") or "").strip(), + "lead_id": (body.get("lead_id") or "").strip(), + "client_tg_id": (body.get("client_tg_id") or "").strip(), + "scheduled_at": scheduled_at, + "status": status, + "manager_note": (body.get("manager_note") or "").strip(), + } + + # Google Calendar — если дата назначена + if scheduled_at: + try: + from . import gcalendar + ev = gcalendar.create_event( + summary=f"🔨 Сборка: {client_name}", + description=f"{scope}\n\nКлиент: {client_name}\nТел: {phone_norm or phone_raw}\nАдрес: {address}", + start_iso=scheduled_at, + duration_min=240, # 4 часа на сборку + location=address, + ) + if ev: + fields["gcal_event_id"] = ev.get("id", "") + fields["gcal_event_url"] = ev.get("html_link", "") + except Exception as e: + log.warning("Не удалось создать событие Calendar для сборки: %s", e) + + sheets.append_row("Assemblies", _row_for_assembly(assembly_id, ts, **fields)) + sheets.log_event("assembly_created", tg_id, {"id": assembly_id, "client": client_name}) + + return {"ok": True, "id": assembly_id, "status": status} + + +def _handle_assembly_list(body: dict[str, Any]) -> dict[str, Any]: + """Список сборок. + Менеджер: видит свои (manager_tg_id == self). + Мастер: видит назначенные ему (assigned_to_tg_id == self) + неназначенные status='created'.""" + 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_manager = sheets.has_role(user, "manager") + is_master = sheets.is_master(user) + if not is_manager and not is_master: + return {"error": "forbidden"} + + _ensure_assemblies_sheet() + try: + ws = sheets.sheet("Assemblies") + rows = ws.get_all_values() + except Exception: + return {"ok": True, "assemblies": []} + if not rows or len(rows) < 2: + return {"ok": True, "assemblies": []} + + headers = rows[0] + out = [] + for r in rows[1:]: + row = dict(zip(headers, r + [""] * (len(headers) - len(r)))) + if row.get("archived_at"): + continue + # Фильтр по роли + visible = False + if is_manager and str(row.get("manager_tg_id")) == str(tg_id): + visible = True + if is_master: + if str(row.get("assigned_to_tg_id")) == str(tg_id): + visible = True + elif not row.get("assigned_to_tg_id") and row.get("status") in ("created", "scheduled"): + visible = True + if not visible: + continue + out.append({ + "id": row.get("id", ""), + "ts": row.get("ts", ""), + "client_name": row.get("client_name", ""), + "client_phone": row.get("client_phone", ""), + "address": row.get("address", ""), + "scope_of_work": row.get("scope_of_work", ""), + "scheduled_at": row.get("scheduled_at", ""), + "status": row.get("status", ""), + "assigned_to_tg_id": row.get("assigned_to_tg_id", ""), + "manager_tg_id": row.get("manager_tg_id", ""), + "gcal_event_url": row.get("gcal_event_url", ""), + "measurement_id": row.get("measurement_id", ""), + "lead_id": row.get("lead_id", ""), + }) + out.sort(key=lambda x: x.get("scheduled_at") or x.get("ts", ""), reverse=True) + return {"ok": True, "count": len(out), "assemblies": out} + + +def _handle_assembly_detail(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"): + 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"} + + assembly_id = (body.get("assembly_id") or "").strip() + if not assembly_id: + return {"error": "missing_assembly_id"} + _ensure_assemblies_sheet() + row = sheets.find_row("Assemblies", "id", assembly_id) + if not row: + return {"error": "assembly_not_found"} + + # Право: менеджер-владелец, назначенный мастер, неназначенная сборка (status=created) + is_owner = str(row.get("manager_tg_id")) == str(tg_id) or \ + str(row.get("assigned_to_tg_id")) == str(tg_id) + is_open_slot = (not row.get("assigned_to_tg_id")) and row.get("status") in ("created", "scheduled") + if not is_owner and not is_open_slot: + return {"error": "forbidden"} + + def _list(s: str) -> list[str]: + return [x for x in (s or "").split(",") if x] + + return { + "ok": True, + "id": row.get("id", ""), + "ts": row.get("ts", ""), + "manager_tg_id": row.get("manager_tg_id", ""), + "assigned_to_tg_id": row.get("assigned_to_tg_id", ""), + "client_name": row.get("client_name", ""), + "client_phone": row.get("client_phone", ""), + "address": row.get("address", ""), + "measurement_id": row.get("measurement_id", ""), + "lead_id": row.get("lead_id", ""), + "scope_of_work": row.get("scope_of_work", ""), + "scheduled_at": row.get("scheduled_at", ""), + "status": row.get("status", ""), + "started_at": row.get("started_at", ""), + "completed_at": row.get("completed_at", ""), + "photos_before": _list(row.get("photos_before", "")), + "photos_in_progress": _list(row.get("photos_in_progress", "")), + "photos_after": _list(row.get("photos_after", "")), + "signature_file": row.get("signature_file", ""), + "signed_by_name": row.get("signed_by_name", ""), + "signed_at": row.get("signed_at", ""), + "gcal_event_id": row.get("gcal_event_id", ""), + "gcal_event_url": row.get("gcal_event_url", ""), + "manager_note": row.get("manager_note", ""), + } + + def _normalize_phone(raw: str) -> tuple[str, bool]: """Нормализует RU-телефон в формат +7XXXXXXXXXX. Возвращает (нормализованный, валиден ли).""" diff --git a/backend-py/app/sheets.py b/backend-py/app/sheets.py index 5eeb9d2..c83c2a5 100644 --- a/backend-py/app/sheets.py +++ b/backend-py/app/sheets.py @@ -132,6 +132,15 @@ def has_role(user: dict[str, Any] | None, role: str) -> bool: return role in parse_roles(user.get("role", "")) +def is_master(user: dict[str, Any] | None) -> bool: + """«Мастер» — единая роль для замерщика+сборщика. + True если у пользователя есть либо measurer, либо assembler.""" + if not user: + return False + roles = parse_roles(user.get("role", "")) + return "measurer" in roles or "assembler" in roles + + def primary_role(user: dict[str, Any] | None) -> str: """Первая (главная) роль для legacy-кода: manager > measurer > assembler > client.""" if not user: @@ -144,16 +153,25 @@ def primary_role(user: dict[str, Any] | None) -> str: def grant_role(tg_id: int, role: str) -> bool: - """Добавляет роль пользователю (если её ещё нет). Возвращает True если что-то изменилось.""" + """Добавляет роль пользователю (если её ещё нет). Возвращает 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: + changed = False + if role not in current: + current.append(role) + changed = True + # Парный «мастер»: measurer ↔ assembler — выдаём вместе + paired = {"measurer": "assembler", "assembler": "measurer"}.get(role) + if paired and paired not in current: + current.append(paired) + changed = True + if not changed: return False - current.append(role) return update_cell_by_key("Users", "tg_id", tg_id, "role", ",".join(current)) diff --git a/miniapp/assets/app.js b/miniapp/assets/app.js index e4864df..aa762c5 100644 --- a/miniapp/assets/app.js +++ b/miniapp/assets/app.js @@ -145,6 +145,7 @@ async function renderManagerHome(me) { { icon: "plus", title: "Новый клиент", subtitle: "Завести карточку", href: "#/clients/new" }, { icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" }, { icon: "ruler", title: "Заказать замер", subtitle: "Назначить замерщика", href: "#/request" }, + { icon: "wrench", title: "Сборки", subtitle: "Заявки на сборку", href: "#/assembly" }, ]; app.appendChild(el(`
Быстрые действия
`)); const grid = el(`
`); @@ -748,6 +749,67 @@ async function renderStaff(me) { }); app.appendChild(quick); } + + // Сборки — отдельный блок, доступен мастеру (measurer ∨ assembler) + if (caps.measurer || caps.assembler) { + const assemblySection = el(` +
+
🔨 Сборки
+
+
+ `); + app.appendChild(assemblySection); + renderStaffAssemblies(assemblySection.querySelector("#assemblyList")); + } +} + +async function renderStaffAssemblies(container) { + try { + const res = await fetch(`${BACKEND_URL}/api/assembly_list`, { + method: "POST", + body: JSON.stringify({ + initData: tg?.initData || "", + initDataUnsafe: tg?.initDataUnsafe || null, + }), + }); + const data = await res.json(); + if (data.error) { + container.innerHTML = `
Ошибка: ${escHtml(data.error)}
`; + return; + } + const items = (data.assemblies || []).filter(a => a.status !== "completed" && a.status !== "cancelled"); + if (!items.length) { + container.innerHTML = `
Сборок нет
`; + return; + } + container.innerHTML = ""; + for (const a of items) { + const dateStr = a.scheduled_at ? formatDateHuman(a.scheduled_at) : "— дата не назначена"; + const statusLabel = { + created: "📝 создана", + scheduled: "📅 назначена", + in_progress: "🔧 в работе", + }[a.status] || a.status; + const card = el(` +
+
+ ${statusLabel} + ${escHtml(dateStr)} +
+
${escHtml(a.client_name || "Без имени")}
+
${escHtml(a.address || "адрес не указан")}
+ ${a.scope_of_work ? `
${escHtml(a.scope_of_work.slice(0, 100))}${a.scope_of_work.length > 100 ? "…" : ""}
` : ""} +
+ `); + card.addEventListener("click", () => { + haptic && haptic("impact"); + location.hash = `#/assembly/${a.id}`; + }); + container.appendChild(card); + } + } catch (e) { + container.innerHTML = `
Сеть: ${escHtml(e.message)}
`; + } } /* ----------------- Группировка инбокса замерщика по дням ----------------- */ @@ -1464,6 +1526,11 @@ async function init() { hideSplash(); return; } + if (location.hash.startsWith("#/assembly")) { + Assembly.mount(app); + hideSplash(); + return; + } if (me.role === "staff") { renderStaff(me); } else if (me.role === "manager") { @@ -1490,6 +1557,8 @@ function routeByHash() { MeasurementRequest.mount(app); } else if (location.hash.startsWith("#/inbox/")) { renderInboxDetail(location.hash.replace("#/inbox/", "")); + } else if (location.hash.startsWith("#/assembly")) { + Assembly.mount(app); } else { // Главный экран по роли const me = window.__zovMe; diff --git a/miniapp/assets/assembly.js b/miniapp/assets/assembly.js new file mode 100644 index 0000000..743c281 --- /dev/null +++ b/miniapp/assets/assembly.js @@ -0,0 +1,395 @@ +/* ============================================================ + Сборка (Phase 4) — менеджер создаёт заявку на сборку, + мастер исполняет, клиент подписывает приёмку. + Этап 1: создание + список + детальная. + ============================================================ */ + +const Assembly = (function () { + let root = null; + let state = { + client_name: "", + client_phone: "", + address: "", + scope_of_work: "", + measurement_id: "", + lead_id: "", + scheduled_at: "", + manager_note: "", + }; + + function mount(container) { + root = container; + document.body.classList.remove("has-bottom-nav"); + const oldNav = document.getElementById("bottom-nav"); + if (oldNav) oldNav.remove(); + + const hash = location.hash || ""; + // #/assembly/new — форма создания + // #/assembly/ — детальная карточка + if (hash === "#/assembly/new" || hash.startsWith("#/assembly/new?")) { + resetState(); + prefillFromSession(); + renderForm(); + } else if (hash.startsWith("#/assembly/")) { + const id = hash.replace("#/assembly/", "").split("?")[0]; + renderDetail(id); + } else { + // Список (для мастера) + renderList(); + } + } + + function resetState() { + state = { + client_name: "", + client_phone: "", + address: "", + scope_of_work: "", + measurement_id: "", + lead_id: "", + scheduled_at: "", + manager_note: "", + }; + } + + function prefillFromSession() { + try { + const raw = sessionStorage.getItem("prefillAssembly"); + if (raw) { + const pre = JSON.parse(raw); + if (pre.name) state.client_name = pre.name; + if (pre.phone) state.client_phone = pre.phone; + if (pre.address) state.address = pre.address; + if (pre.measurement_id) state.measurement_id = pre.measurement_id; + if (pre.lead_id) state.lead_id = pre.lead_id; + sessionStorage.removeItem("prefillAssembly"); + } + } catch (e) {} + } + + function headerEl(title, backHash) { + const h = el(` +
+ +
${escHtml(title)}
+
+
+ `); + h.querySelector(".podbor-back").addEventListener("click", () => { + if (backHash) location.hash = backHash; + else history.back(); + }); + return h; + } + + /* ===================== Форма создания ===================== */ + + function renderForm() { + if (!root) return; + root.innerHTML = ""; + root.appendChild(headerEl("Заказать сборку", "")); + + const form = el(` +
+

Новая
сборка

+

Опишите состав работ — мастер получит карточку с адресом и датой.

+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+ `); + + bindInputs(form); + form.querySelector("#submitBtn").addEventListener("click", () => onSubmit(form)); + root.appendChild(form); + } + + function bindInputs(node) { + node.querySelectorAll("[data-bind]").forEach(input => { + input.addEventListener("input", () => { + state[input.dataset.bind] = input.value; + }); + }); + } + + async function onSubmit(form) { + const btn = form.querySelector("#submitBtn"); + const result = form.querySelector("#submitResult"); + result.innerHTML = ""; + form.querySelectorAll(".field-error").forEach(e => e.textContent = ""); + + let ok = true; + if (!state.client_name.trim()) { + form.querySelector("#errName").textContent = "Укажите имя клиента"; + ok = false; + } + if (!state.address.trim()) { + form.querySelector("#errAddress").textContent = "Укажите адрес сборки"; + ok = false; + } + if (!state.scope_of_work.trim()) { + form.querySelector("#errScope").textContent = "Опишите состав работ"; + ok = false; + } + if (!ok) return; + + btn.disabled = true; + btn.innerHTML = ' сохраняем...'; + + const body = { + initData: tg?.initData || "", + initDataUnsafe: tg?.initDataUnsafe || null, + client_name: state.client_name.trim(), + client_phone: state.client_phone.trim(), + address: state.address.trim(), + scope_of_work: state.scope_of_work.trim(), + measurement_id: state.measurement_id, + lead_id: state.lead_id, + scheduled_at: state.scheduled_at ? new Date(state.scheduled_at).toISOString() : "", + manager_note: state.manager_note.trim(), + }; + + try { + const res = await fetch(`${BACKEND_URL}/api/assembly_create`, { + method: "POST", + body: JSON.stringify(body), + }); + const data = await res.json(); + if (data.error) { + result.innerHTML = `
Ошибка: ${escHtml(data.error)}
`; + btn.disabled = false; + btn.textContent = "Заказать сборку"; + return; + } + haptic && haptic("success"); + result.innerHTML = ` +
+
${ICONS.check || "✓"}
+
+
Сборка заведена
+
ID #${(data.id || "").slice(0, 6)} · ${data.status === "scheduled" ? "дата назначена" : "без даты"}
+
+
+
+ + +
+ `; + btn.style.display = "none"; + result.querySelector("#toHome")?.addEventListener("click", () => { + location.hash = ""; + location.reload(); + }); + result.querySelector("#toDetail")?.addEventListener("click", () => { + location.hash = `#/assembly/${data.id}`; + }); + } catch (e) { + result.innerHTML = `
Сеть: ${escHtml(e.message)}
`; + btn.disabled = false; + btn.textContent = "Заказать сборку"; + } + } + + /* ===================== Список сборок ===================== */ + + async function renderList() { + if (!root) return; + root.innerHTML = ""; + root.appendChild(headerEl("Сборки", "")); + const loading = el(`
`); + root.appendChild(loading); + try { + const res = await fetch(`${BACKEND_URL}/api/assembly_list`, { + method: "POST", + body: JSON.stringify({ + initData: tg?.initData || "", + initDataUnsafe: tg?.initDataUnsafe || null, + }), + }); + const data = await res.json(); + loading.remove(); + if (data.error) { + root.appendChild(el(`
${escHtml(data.error)}
`)); + return; + } + const items = data.assemblies || []; + if (!items.length) { + root.appendChild(el(`
Сборок пока нет
`)); + return; + } + const list = el(`
`); + for (const a of items) { + const dateStr = a.scheduled_at ? formatDateHuman(a.scheduled_at) : "—"; + const statusLabel = { + created: "📝 создана", + scheduled: "📅 назначена", + in_progress: "🔧 в работе", + completed: "✅ завершена", + cancelled: "❌ отменена", + }[a.status] || a.status; + const card = el(` +
+
+ ${statusLabel} + ${escHtml(dateStr)} +
+
${escHtml(a.client_name || "Без имени")}
+
${escHtml(a.address || "адрес не указан")}
+ ${a.scope_of_work ? `
${escHtml(a.scope_of_work.slice(0, 120))}${a.scope_of_work.length > 120 ? "…" : ""}
` : ""} +
+ `); + card.addEventListener("click", () => { + haptic && haptic("impact"); + location.hash = `#/assembly/${a.id}`; + }); + list.appendChild(card); + } + root.appendChild(list); + } catch (e) { + loading.remove(); + root.appendChild(el(`
Сеть: ${escHtml(e.message)}
`)); + } + } + + /* ===================== Детальная карточка ===================== */ + + async function renderDetail(id) { + if (!root) return; + root.innerHTML = ""; + root.appendChild(headerEl("Сборка", "")); + const loading = el(`
`); + root.appendChild(loading); + let a; + try { + const res = await fetch(`${BACKEND_URL}/api/assembly_detail`, { + method: "POST", + body: JSON.stringify({ + initData: tg?.initData || "", + initDataUnsafe: tg?.initDataUnsafe || null, + assembly_id: id, + }), + }); + a = await res.json(); + } catch (e) { + loading.remove(); + root.appendChild(el(`
Сеть: ${escHtml(e.message)}
`)); + return; + } + loading.remove(); + if (a.error) { + root.appendChild(el(`
${escHtml(a.error)}
`)); + return; + } + + const dateStr = a.scheduled_at ? formatDateHuman(a.scheduled_at) : "Не назначена"; + const statusLabel = { + created: "📝 создана", + scheduled: "📅 назначена", + in_progress: "🔧 в работе", + completed: "✅ завершена", + cancelled: "❌ отменена", + }[a.status] || a.status; + + root.appendChild(el(` +
+
Сборка #${(a.id || "").slice(0, 8)} · ${statusLabel}
+

${escHtml(a.client_name || "Без имени")}

+
+ ${a.client_phone ? `📞 ${escHtml(a.client_phone)}` : ""} + 📍 ${escHtml(a.address || "адрес не указан")} + 📅 ${escHtml(dateStr)} +
+
+ `)); + + if (a.gcal_event_url) { + root.appendChild(el(` +
+ 📅 Открыть в Google Calendar +
+ `)); + } + + root.appendChild(el(` +
+
🛠 Состав работ
+
${escHtml(a.scope_of_work || "—")}
+
+ `)); + + if (a.manager_note) { + root.appendChild(el(` +
+
📝 Заметка от менеджера
+
${escHtml(a.manager_note)}
+
+ `)); + } + + // Этапы 2-3 (фото / подпись) — добавим в следующем коммите + root.appendChild(el(` +
+ Фото-отчёт и приёмка появятся в следующем обновлении. +
+ `)); + } + + /* ===================== Helpers ===================== */ + + function escHtml(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + function escAttr(s) { return escHtml(s); } + + return { mount }; +})(); diff --git a/miniapp/assets/clients.js b/miniapp/assets/clients.js index 8ae486e..1054f43 100644 --- a/miniapp/assets/clients.js +++ b/miniapp/assets/clients.js @@ -413,6 +413,7 @@ const Clients = (function () {
+
`); @@ -428,6 +429,15 @@ const Clients = (function () { name: client.client_name, phone: client.client_phone, })); location.hash = "#/request"; + } else if (act === "assembly") { + // Pre-fill assembly with client info + address из последнего замера + sessionStorage.setItem("prefillAssembly", JSON.stringify({ + name: client.client_name, + phone: client.client_phone, + address: (myMeasurements[0] && myMeasurements[0].address) || "", + measurement_id: (myMeasurements[0] && myMeasurements[0].id) || "", + })); + location.hash = "#/assembly/new"; } else if (act === "copy") { const txt = `${client.client_name || ""} ${client.client_phone || ""}`.trim(); (navigator.clipboard?.writeText(txt) || Promise.resolve()) diff --git a/miniapp/assets/podbor.css b/miniapp/assets/podbor.css index 5574bcc..5b00ecb 100644 --- a/miniapp/assets/podbor.css +++ b/miniapp/assets/podbor.css @@ -3052,6 +3052,63 @@ flex-shrink: 0; } +/* ===== Сборки (Phase 4) ===== */ +.assembly-list { + display: flex; + flex-direction: column; + gap: 10px; + padding: 8px 0; +} +.assembly-card { + background: var(--card, #FFFFFF); + border: 1px solid var(--line, rgba(15,15,14,0.08)); + border-left: 3px solid #B07E00; + border-radius: 12px; + padding: 12px 14px; + cursor: pointer; + transition: transform 0.15s, box-shadow 0.15s; +} +.assembly-card:active { + transform: scale(0.98); +} +.assembly-card-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} +.assembly-card-status { + font-size: 11px; + font-weight: 600; + color: var(--muted, #998877); + text-transform: lowercase; +} +.assembly-card-date { + font-size: 12px; + color: var(--muted); + font-family: var(--font-mono, monospace); +} +.assembly-card-name { + font-weight: 600; + font-size: 15px; + color: var(--ink); + margin-bottom: 2px; +} +.assembly-card-address { + font-size: 13px; + color: var(--muted); + margin-bottom: 4px; +} +.assembly-card-scope { + font-size: 12.5px; + color: var(--ink-2, #2A2622); + line-height: 1.35; + margin-top: 4px; + padding-top: 6px; + border-top: 1px dashed var(--line); +} + /* ===== Печать / PDF ===== */ @media print { body { background: white !important; color: black !important; } diff --git a/miniapp/index.html b/miniapp/index.html index bde7eb3..14ed76c 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -12,14 +12,14 @@ - - + +
- +
- - - - - - - - - + + + + + + + + + +