From 21fd0ff3e51c346e652a9e8d10c8b01847b27295 Mon Sep 17 00:00:00 2001 From: wasrusgen Date: Tue, 19 May 2026 12:23:44 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20assembler=20dashboard,=20contracts=20mo?= =?UTF-8?q?dule=20(Act=20=E2=84=963),=20assembly=20rates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend: - assembler_dashboard.js — personal earnings screen for assemblers (#/master/dashboard) - contracts.js — Act №3 preview + edit + SignRequest ПЭП (#/assembly/:id/contract) - assembly_detail.js — add "📄 Акт сдачи-приёмки" button - app.js — routes for #/master/dashboard and #/assembly/:id/contract Backend: - main.py — /api/assembler_earnings (fuzzy name match vs Excel) - main.py — /api/contract_preview, /api/contract_save (Contracts sheet) - main.py — _ensure_contracts_sheet(), _name_match_score() - assembler_parser.py — fix tuple index out of range on short rows (2021 sheet) Co-Authored-By: Claude Sonnet 4.6 --- backend-py/app/assembler_parser.py | 3 + backend-py/app/main.py | 240 ++++++++++++++ miniapp/assets/app.js | 24 +- miniapp/assets/assembler_dashboard.js | 225 +++++++++++++ miniapp/assets/assembly_detail.js | 15 +- miniapp/assets/contracts.js | 436 ++++++++++++++++++++++++++ miniapp/index.html | 6 +- 7 files changed, 944 insertions(+), 5 deletions(-) create mode 100644 miniapp/assets/assembler_dashboard.js create mode 100644 miniapp/assets/contracts.js diff --git a/backend-py/app/assembler_parser.py b/backend-py/app/assembler_parser.py index 7bb8391..0f3542f 100644 --- a/backend-py/app/assembler_parser.py +++ b/backend-py/app/assembler_parser.py @@ -116,6 +116,9 @@ def parse_sheet(ws) -> list[dict]: for ri, row in enumerate(rows): if ri <= date_row_idx: continue + if len(row) < 3: + current_assembler = None + continue col_a = str(row[0] or "").strip().lower() col_b = str(row[1] or "").strip() diff --git a/backend-py/app/main.py b/backend-py/app/main.py index 8accba5..6058382 100644 --- a/backend-py/app/main.py +++ b/backend-py/app/main.py @@ -153,6 +153,9 @@ async def _dispatch_post(request: Request): "assembly_rate_save": _handle_assembly_rate_save, "assembly_rate_delete": _handle_assembly_rate_delete, "assembler_analytics": _handle_assembler_analytics, + "assembler_earnings": _handle_assembler_earnings, + "contract_preview": _handle_contract_preview, + "contract_save": _handle_contract_save, "proposal_brief": proposals_mod.handle_brief, "proposal_create": proposals_mod.handle_create, "proposal_upsert_variant": proposals_mod.handle_upsert_variant, @@ -2637,6 +2640,22 @@ _rates_cache: dict = {"data": None, "ts": 0.0} _RATES_CACHE_TTL = 120 # секунд +def _ensure_contracts_sheet() -> None: + """Создаёт лист Contracts если не существует.""" + HEADERS = [ + "contract_id", "assembly_id", "contract_num", "contract_date", + "travel_spb", "travel_outside", "tech_list", + "created_at", "created_by_tg_id", "updated_at", + ] + try: + wb = sheets._get_workbook() + if "Contracts" not in [ws.title for ws in wb.worksheets()]: + ws = wb.add_worksheet("Contracts", rows=200, cols=len(HEADERS)) + ws.append_row(HEADERS) + except Exception as e: + log.warning("_ensure_contracts_sheet: %s", e) + + def _ensure_rates_sheet() -> None: try: ws = sheets._ws("Assembly_Rates") @@ -2965,6 +2984,227 @@ async def api_assembler_analytics(request: Request): return _handle_assembler_analytics(body) +def _name_match_score(excel_name: str, full_name: str) -> int: + """Возвращает score (0-3) схожести имени из Excel с full_name из Users.""" + en = excel_name.strip().lower() + fn = full_name.strip().lower() + if not en or not fn: + return 0 + if en == fn: + return 3 + # Первое слово (фамилия) совпадает + en_first = en.split()[0] if en.split() else "" + fn_first = fn.split()[0] if fn.split() else "" + if en_first and fn_first and en_first == fn_first: + # Дополнительно: совпадает второе слово или инициал + en_parts = en.split() + fn_parts = fn.split() + if len(en_parts) > 1 and len(fn_parts) > 1: + if en_parts[1] == fn_parts[1] or en_parts[1][:1] == fn_parts[1][:1]: + return 2 + return 1 + return 0 + + +def _handle_assembler_earnings(body: dict[str, Any]) -> dict[str, Any]: + """Личная аналитика сборщика — его заработки из Excel-расписания. + body: {initData, year?: '2026'} + Доступен сборщику, замерщику, менеджеру.""" + 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"} + if not (sheets.has_role(user, "assembler") or sheets.has_role(user, "measurer") or + sheets.has_role(user, "manager") or sheets.has_role(user, "admin")): + return {"error": "forbidden"} + + full_name = (user.get("full_name") or "").strip() + if not full_name: + return {"error": "no_name", "message": "Имя не задано в профиле"} + + data = _get_schedule_data() + if "error" in data: + return data + + year = str(body.get("year") or "").strip() + + # Находим лучшее совпадение по имени + best_name = None + best_score = 0 + for excel_name in data.get("by_assembler", {}).keys(): + score = _name_match_score(excel_name, full_name) + if score > best_score: + best_score = score + best_name = excel_name + + if not best_name or best_score == 0: + return { + "ok": True, + "matched_name": None, + "full_name": full_name, + "months": {}, + "total_amount": 0, + "total_orders": 0, + "message": "Данные по вашему имени не найдены в таблице занятости", + } + + months_raw = data["by_assembler"][best_name] + if year and year.isdigit(): + months_raw = {k: v for k, v in months_raw.items() if k.startswith(year)} + + total_amount = sum(m["total_amount"] for m in months_raw.values()) + total_orders = sum(m["orders"] for m in months_raw.values()) + + # Сортируем по дате desc + months_sorted = dict(sorted(months_raw.items(), reverse=True)) + + return { + "ok": True, + "matched_name": best_name, + "full_name": full_name, + "match_score": best_score, + "months": months_sorted, + "total_amount": total_amount, + "total_orders": total_orders, + "parsed_at": data.get("parsed_at"), + } + + +@app.post("/api/assembler_earnings") +async def api_assembler_earnings(request: Request): + body = await _safe_json(request) + return _handle_assembler_earnings(body) + + +def _handle_contract_preview(body: dict[str, Any]) -> dict[str, Any]: + """Возвращает данные сборки + сохранённые поля контракта для предпросмотра акта. + body: {initData, initDataUnsafe, assembly_id} + Доступен менеджеру и сборщику.""" + 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"} + if not (sheets.has_role(user, "manager") or sheets.has_role(user, "assembler") or + sheets.has_role(user, "admin")): + return {"error": "forbidden"} + + assembly_id = str(body.get("assembly_id") or "").strip() + if not assembly_id: + return {"error": "missing_assembly_id"} + + asm = sheets.find_row("Assemblies", "id", assembly_id) + if not asm: + return {"error": "assembly_not_found"} + + # Загружаем сохранённые поля контракта (если есть) + contract = sheets.find_row("Contracts", "assembly_id", assembly_id) or {} + + return { + "ok": True, + "assembly": { + "id": asm.get("id", ""), + "client_name": asm.get("client_name", ""), + "client_tg_id": asm.get("client_tg_id", ""), + "address": asm.get("address", ""), + "scheduled_at": asm.get("scheduled_at", ""), + "assembly_price_for_client": asm.get("assembly_price_for_client") or asm.get("kitchen_price", ""), + "signed_by_name": asm.get("signed_by_name", ""), + "signed_at": asm.get("signed_at", ""), + "signed_via": asm.get("signed_via", ""), + "status": asm.get("status", ""), + }, + "contract": { + "contract_num": contract.get("contract_num", assembly_id), + "contract_date": contract.get("contract_date", ""), + "travel_spb": contract.get("travel_spb", "0"), + "travel_outside": contract.get("travel_outside", "0"), + "tech_list": contract.get("tech_list", ""), + }, + } + + +@app.post("/api/contract_preview") +async def api_contract_preview(request: Request): + body = await _safe_json(request) + return _handle_contract_preview(body) + + +def _handle_contract_save(body: dict[str, Any]) -> dict[str, Any]: + """Сохраняет дополнительные поля акта в лист Contracts. + body: {initData, initDataUnsafe, assembly_id, contract_num, contract_date, travel_spb, travel_outside, tech_list} + Доступен менеджеру.""" + 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"} + if not (sheets.has_role(user, "manager") or sheets.has_role(user, "admin")): + return {"error": "forbidden"} + + assembly_id = str(body.get("assembly_id") or "").strip() + if not assembly_id: + return {"error": "missing_assembly_id"} + + _ensure_contracts_sheet() + + contract_num = str(body.get("contract_num") or assembly_id).strip() + contract_date = str(body.get("contract_date") or "").strip() + travel_spb = str(body.get("travel_spb") or "0").strip() + travel_outside = str(body.get("travel_outside") or "0").strip() + tech_list = str(body.get("tech_list") or "").strip() + now_iso = datetime.utcnow().isoformat() + + existing = sheets.find_row("Contracts", "assembly_id", assembly_id) + if existing: + # Обновляем существующую запись + sheets.update_cell_by_key("Contracts", "assembly_id", assembly_id, "contract_num", contract_num) + sheets.update_cell_by_key("Contracts", "assembly_id", assembly_id, "contract_date", contract_date) + sheets.update_cell_by_key("Contracts", "assembly_id", assembly_id, "travel_spb", travel_spb) + sheets.update_cell_by_key("Contracts", "assembly_id", assembly_id, "travel_outside", travel_outside) + sheets.update_cell_by_key("Contracts", "assembly_id", assembly_id, "tech_list", tech_list) + sheets.update_cell_by_key("Contracts", "assembly_id", assembly_id, "updated_at", now_iso) + else: + # Создаём новую запись + import uuid + contract_id = str(uuid.uuid4())[:8] + sheets.append_row("Contracts", [ + contract_id, assembly_id, contract_num, contract_date, + travel_spb, travel_outside, tech_list, + now_iso, str(tg_id), "", + ]) + + return {"ok": True} + + +@app.post("/api/contract_save") +async def api_contract_save(request: Request): + body = await _safe_json(request) + return _handle_contract_save(body) + + # ================================================================= # SignRequest — цифровая подпись акта сборки (ФЗ-63 ПЭП) # ================================================================= diff --git a/miniapp/assets/app.js b/miniapp/assets/app.js index f1426de..fe7aa66 100644 --- a/miniapp/assets/app.js +++ b/miniapp/assets/app.js @@ -912,10 +912,23 @@ async function renderStaff(me) { renderStaffAssemblies(assemblySection.querySelector("#assemblyList")); } - // Шпаргалки сборщика — прайс, рейки, полкодержатели + // Шпаргалки + заработки сборщика if (caps.assembler) { - const toolsBtn = el(` + const earningsBtn = el(`
+ +
+ `); + earningsBtn.querySelector("button").addEventListener("click", () => { + haptic && haptic("impact"); + location.hash = "#/master/dashboard"; + }); + app.appendChild(earningsBtn); + + const toolsBtn = el(` +
@@ -1703,6 +1716,13 @@ function routeByHash() { } else if (location.hash.startsWith("#/admin/rates")) { if (typeof AdminRates !== "undefined") AdminRates.mount(app); else init(); + } else if (location.hash === "#/master/dashboard") { + if (typeof AssemblerDashboard !== "undefined") AssemblerDashboard.mount(app); + else init(); + } else if (location.hash.startsWith("#/assembly/") && location.hash.endsWith("/contract")) { + const asmId = location.hash.split("/")[2]; + if (typeof Contracts !== "undefined") Contracts.mount(app, asmId); + else init(); } else if (location.hash.startsWith("#/master/tools")) { if (typeof MasterTools !== "undefined") { const h = location.hash; diff --git a/miniapp/assets/assembler_dashboard.js b/miniapp/assets/assembler_dashboard.js new file mode 100644 index 0000000..6268e14 --- /dev/null +++ b/miniapp/assets/assembler_dashboard.js @@ -0,0 +1,225 @@ +/* ============================================================ + AssemblerDashboard — личная аналитика сборщика + #/master/dashboard + ============================================================ */ + +const AssemblerDashboard = (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 fmtMoney(n) { + return Math.round(n || 0).toLocaleString("ru-RU") + " ₽"; + } + function fmtMonth(ym) { + try { + const d = new Date(ym + "-01"); + return d.toLocaleDateString("ru-RU", { month: "long", year: "numeric" }); + } catch { return ym; } + } + + async function _api(path, body = {}) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 30000); + 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); } + } + + /* ── Главный экран ─────────────────────────────────────────── */ + async function mount(container) { + container.innerHTML = ""; + document.body.classList.remove("has-bottom-nav"); + document.getElementById("bottom-nav")?.remove(); + + const h = el(` +
+ +
Мои заработки
+ +
+ `); + h.querySelector(".podbor-back").addEventListener("click", () => { + haptic && haptic("impact"); + history.back(); + }); + + const yearEl = el(` +
+ + +
+ `); + + const screen = el(`
`); + container.appendChild(h); + container.appendChild(yearEl); + container.appendChild(screen); + + const load = async (year) => { + screen.innerHTML = `
Загружаем данные…
`; + try { + const data = await _api("assembler_earnings", { year }); + if (data.error) { + screen.innerHTML = `
${escHtml(data.error === "no_name" ? "Имя не задано в профиле. Обратитесь к менеджеру." : data.error)}
`; + return; + } + _render(screen, data); + } catch (e) { + screen.innerHTML = `
Ошибка: ${escHtml(e.message)}
`; + } + }; + + yearEl.querySelector("#yearSelect").addEventListener("change", function () { + load(this.value); + }); + h.querySelector("#reloadBtn").addEventListener("click", () => { + haptic && haptic("impact"); + load(yearEl.querySelector("#yearSelect").value); + }); + + load("2026"); + } + + /* ── Рендер ─────────────────────────────────────────────────── */ + function _render(screen, data) { + screen.innerHTML = ""; + + // Имя не нашли + if (!data.matched_name) { + screen.appendChild(el(` +
+
🔍
+
Данные не найдены
+
${escHtml(data.message || "Ваше имя не найдено в таблице занятости")}
+
Имя в профиле: ${escHtml(data.full_name || "—")}
+
+ `)); + return; + } + + const months = data.months || {}; + const monthKeys = Object.keys(months).sort().reverse(); + + // Текущий и прошлый месяц + const now = new Date(); + const curYM = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; + const prevDate = new Date(now.getFullYear(), now.getMonth() - 1, 1); + const prevYM = `${prevDate.getFullYear()}-${String(prevDate.getMonth() + 1).padStart(2, "0")}`; + + const curMonth = months[curYM] || null; + const prevMonth = months[prevYM] || null; + + // === Hero-карточка === + const heroCard = el(` +
+
Всего за период
+
${escHtml(fmtMoney(data.total_amount))}
+
${escHtml(String(data.total_orders))} заказов
+ ${data.match_score < 2 ? `
⚠ Неточное совпадение: «${escHtml(data.matched_name)}»
` : ""} +
+ `); + screen.appendChild(heroCard); + + // === Мини-карточки текущий / прошлый месяц === + if (curMonth || prevMonth) { + const row = el(`
`); + const _miniCard = (label, m) => { + if (!m) return el(`
+
${escHtml(label)}
+
+
`); + return el(`
+
${escHtml(label)}
+
${escHtml(fmtMoney(m.total_amount))}
+
${escHtml(String(m.orders))} заказов
+
`); + }; + row.appendChild(_miniCard("Текущий месяц", curMonth)); + row.appendChild(_miniCard("Прошлый месяц", prevMonth)); + screen.appendChild(row); + } + + // === Таблица по месяцам === + if (monthKeys.length) { + screen.appendChild(el(`
📅 По месяцам
`)); + + const maxAmt = Math.max(...monthKeys.map(k => months[k].total_amount)) || 1; + + monthKeys.forEach(ym => { + const m = months[ym]; + const pct = Math.round((m.total_amount / maxAmt) * 100); + const avgPer = m.orders ? Math.round(m.total_amount / m.orders) : 0; + const isCurrentMonth = ym === curYM; + + const card = el(` +
+
+
+ ${escHtml(fmtMonth(ym))} + ${isCurrentMonth ? `сейчас` : ""} +
+
+
${escHtml(fmtMoney(m.total_amount))}
+
${escHtml(String(m.orders))} зак. · ср. ${escHtml(fmtMoney(avgPer))}
+
+
+
+
+
+
+ `); + screen.appendChild(card); + }); + } else { + screen.appendChild(el(` +
+ Нет данных за выбранный период.
+ Попробуй выбрать другой год. +
+ `)); + } + + // Footer + if (data.parsed_at) { + const parsedAt = new Date(data.parsed_at).toLocaleString("ru-RU"); + screen.appendChild(el(` +
+ Данные обновлены: ${escHtml(parsedAt)} +
+ `)); + } + screen.appendChild(el(`
`)); + } + + return { mount }; +})(); diff --git a/miniapp/assets/assembly_detail.js b/miniapp/assets/assembly_detail.js index db6302c..7168c77 100644 --- a/miniapp/assets/assembly_detail.js +++ b/miniapp/assets/assembly_detail.js @@ -192,6 +192,20 @@ const AssemblyDetailScreen = (function () { screen.innerHTML = statusBanner + mainBlock + noteBlock + photosBlock + signBlock + calBtn + `
`; + // Кнопка «Акт сдачи-приёмки» — для менеджера всегда доступна + const actWrap = document.createElement("div"); + actWrap.style.cssText = "margin:8px 16px 0;"; + const actBtn = document.createElement("button"); + actBtn.className = "btn-secondary"; + actBtn.style.cssText = "width:100%;font-size:14px;padding:11px;"; + actBtn.textContent = "📄 Акт сдачи-приёмки"; + actBtn.addEventListener("click", () => { + haptic && haptic("impact"); + location.hash = `#/assembly/${data.id}/contract`; + }); + actWrap.appendChild(actBtn); + screen.appendChild(actWrap); + // Кнопка «Подписать акт» — только если ещё не подписано if (!data.signed_by_name) { const btnWrap = screen.querySelector("#sr-sign-btn-wrap"); @@ -207,7 +221,6 @@ const AssemblyDetailScreen = (function () { clientName: data.client_name || "", clientTgId: data.client_tg_id || "", onSuccess: () => { - // Перерисовываем экран после подписания mount(container, assemblyId); }, }); diff --git a/miniapp/assets/contracts.js b/miniapp/assets/contracts.js new file mode 100644 index 0000000..bf7f556 --- /dev/null +++ b/miniapp/assets/contracts.js @@ -0,0 +1,436 @@ +/* ============================================================ + Contracts — предпросмотр и подпись акта сдачи-приёмки №3 + mount(container, assemblyId) | route: #/assembly/:id/contract + ============================================================ */ + +const Contracts = (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 fmtMoney(n) { + const num = parseFloat(n) || 0; + return num.toLocaleString("ru-RU", { minimumFractionDigits: 0 }) + " р."; + } + function today() { + return new Date().toISOString().slice(0, 10); + } + function fmtDateParts(dateStr) { + // "2026-05-19" → { day:"19", month:"мая", year:"2026" } + if (!dateStr) { + const d = new Date(); + dateStr = d.toISOString().slice(0, 10); + } + const months = [ + "января","февраля","марта","апреля","мая","июня", + "июля","августа","сентября","октября","ноября","декабря" + ]; + try { + const parts = dateStr.split("-"); + return { + day: String(parseInt(parts[2])), + month: months[parseInt(parts[1]) - 1] || parts[1], + year: parts[0], + }; + } catch { return { day: "—", month: "—", year: "—" }; } + } + + /* ── API ─────────────────────────────────────────────────── */ + 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); } + } + + /* ── Шаблон акта ─────────────────────────────────────────── */ + function buildActHtml(fields) { + const { + contract_num, contract_date, client_name, address, + total_sum, assembly_price, travel_spb, travel_outside, tech_list, + } = fields; + + const dp = fmtDateParts(contract_date); + const totalFmt = fmtMoney(total_sum); + const asmFmt = fmtMoney(assembly_price); + const spbFmt = fmtMoney(travel_spb); + const outsideFmt = fmtMoney(travel_outside); + + const techBlock = tech_list && tech_list.trim() + ? `

Перечень техники, подлежащей бесплатной установке:

+

${escHtml(tech_list.trim())}

` + : ""; + + return ` +
+ +
+ АКТ СДАЧИ-ПРИЁМКИ РАБОТ
+ по договору на сборку и установку мебели
+ №${escHtml(contract_num)} от ${escHtml(dp.day)} ${escHtml(dp.month)} ${escHtml(dp.year)} г. +
+ +
+ г. Санкт-Петербург + «${escHtml(dp.day)}» ${escHtml(dp.month)} ${escHtml(dp.year)} г. +
+ +

+ Индивидуальный предприниматель, именуемый в дальнейшем «Исполнитель», с одной стороны + и ${escHtml(client_name || "—")}, именуемый(ая) в дальнейшем «Заказчик» + с другой стороны, составили настоящий акт сдачи-приёмки работ о нижеследующем: +

+ +

+ Работы по установке мебели на объекте Заказчика по адресу: ${escHtml(address || "—")} + по договору №${escHtml(contract_num)} от ${escHtml(dp.day)} ${escHtml(dp.month)} ${escHtml(dp.year)} г., + на общую сумму ${escHtml(totalFmt)}, + выполнены Исполнителем в полном объёме надлежащего качества. +

+ + + + + + + + + + + + + + +
Стоимость услуг по сборке и установке:${escHtml(asmFmt)}
Стоимость выезда сборщика по СПб:${escHtml(spbFmt)}
Стоимость выезда сборщика за пределы условной границы СПб:${escHtml(outsideFmt)}
+ +

+ Стороны не имеют претензий друг к другу по исполнению Договора, в том числе + по срокам выполнения работ, качеству и объёму работ.
+ Настоящий акт составлен в двух экземплярах. +

+ + ${techBlock} + +

+ ВНИМАНИЕ! Перед подписанием акта тщательно осмотрите мебель на предмет возможных + недостатков. После подписания акта приёмки претензии по качеству не принимаются. +

+ +

+ При наличии вопросов обращайтесь в отдел сервиса: +7-952-379-63-25 +

+ +
+
ЗАКАЗЧИК _________________ / ${escHtml(client_name || "—")}
+
ИСПОЛНИТЕЛЬ _______________ / Васильев Р.Г.
+
+ +
+ +
+
+ `; + } + + /* ── Главный экран ─────────────────────────────────────────── */ + async function mount(container, assemblyId) { + // Читаем id из параметра или из хэша + const asmId = assemblyId || location.hash.split("/").pop(); + + container.innerHTML = ""; + document.body.classList.remove("has-bottom-nav"); + document.getElementById("bottom-nav")?.remove(); + + /* Заголовок */ + const header = el(` +
+ +
Акт сдачи-приёмки
+
+
+ `); + header.querySelector(".podbor-back").addEventListener("click", () => { + haptic && haptic("impact"); + history.back(); + }); + + const screen = el(`
`); + container.appendChild(header); + container.appendChild(screen); + + /* Loader */ + screen.innerHTML = `
+
Загружаем данные…
`; + + /* Загружаем данные */ + let data; + try { + data = await _api("contract_preview", { assembly_id: asmId }); + } catch (e) { + screen.innerHTML = `
+ Ошибка загрузки:
${escHtml(e.message)}
`; + return; + } + if (!data.ok) { + screen.innerHTML = `
+ ${escHtml(data.error || "Не удалось загрузить данные")}
`; + return; + } + + const asm = data.assembly || {}; + const contract = data.contract || {}; + + /* Начальные значения редактируемых полей */ + let extras = { + contract_num: contract.contract_num || String(asmId), + contract_date: contract.contract_date || today(), + travel_spb: contract.travel_spb != null ? contract.travel_spb : 0, + travel_outside: contract.travel_outside != null ? contract.travel_outside : 0, + tech_list: contract.tech_list || "", + }; + + /* Вычисляем total_sum */ + function calcTotal() { + return (parseFloat(asm.assembly_price) || 0) + + (parseFloat(extras.travel_spb) || 0) + + (parseFloat(extras.travel_outside) || 0); + } + + /* Рендерим всё */ + function render() { + screen.innerHTML = ""; + + /* === Блок: Акт === */ + const actSection = el(`
`); + actSection.innerHTML = buildActHtml({ + contract_num: extras.contract_num, + contract_date: extras.contract_date, + client_name: asm.client_name || "", + address: asm.address || "", + total_sum: calcTotal(), + assembly_price: asm.assembly_price || 0, + travel_spb: extras.travel_spb, + travel_outside: extras.travel_outside, + tech_list: extras.tech_list, + }); + actSection.querySelector("#printActBtn")?.addEventListener("click", () => { + window.print(); + }); + screen.appendChild(actSection); + + /* === Блок: Статус подписи === */ + if (asm.signed_by_name) { + const signedBadge = el(` +
+ +
+
Акт подписан
+
+ ${escHtml(asm.signed_by_name)} + ${asm.signed_at ? " · " + escHtml(asm.signed_at) : ""} +
+
+
+ `); + screen.appendChild(signedBadge); + } + + /* === Блок: Дополнительные данные === */ + const extrasSection = el(` +
+
+ ✏️ Дополнительные данные +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + ${!asm.signed_by_name ? ` + ` : ""} +
+ +
+
+ `); + screen.appendChild(extrasSection); + + /* === Обработчики изменений — live-обновление акта === */ + const liveInputs = [ + ["inp_contract_num", "contract_num", false], + ["inp_contract_date", "contract_date", false], + ["inp_travel_spb", "travel_spb", true], + ["inp_travel_outside", "travel_outside", true], + ["inp_tech_list", "tech_list", false], + ]; + liveInputs.forEach(([id, key, isNum]) => { + const inp = screen.querySelector("#" + id); + if (!inp) return; + inp.addEventListener("input", () => { + extras[key] = isNum ? (parseFloat(inp.value) || 0) : inp.value; + // Обновляем только акт, не весь экран (чтобы не потерять фокус ввода) + const actDiv = actSection.querySelector("div"); + if (actDiv) { + const newInner = buildActHtml({ + contract_num: extras.contract_num, + contract_date: extras.contract_date, + client_name: asm.client_name || "", + address: asm.address || "", + total_sum: calcTotal(), + assembly_price: asm.assembly_price || 0, + travel_spb: extras.travel_spb, + travel_outside: extras.travel_outside, + tech_list: extras.tech_list, + }); + const tmp = document.createElement("div"); + tmp.innerHTML = newInner; + const newActDiv = tmp.firstElementChild; + actDiv.replaceWith(newActDiv); + newActDiv.querySelector("#printActBtn")?.addEventListener("click", () => window.print()); + } + }); + }); + + /* === Кнопка: Сохранить === */ + screen.querySelector("#btnSave")?.addEventListener("click", async () => { + haptic && haptic("impact"); + const statusEl = screen.querySelector("#saveStatus"); + statusEl.textContent = "Сохраняем…"; + statusEl.style.color = "var(--muted)"; + try { + const res = await _api("contract_save", { + assembly_id: asmId, + contract_num: extras.contract_num, + contract_date: extras.contract_date, + travel_spb: extras.travel_spb, + travel_outside: extras.travel_outside, + tech_list: extras.tech_list, + }); + if (res.ok) { + statusEl.textContent = "✅ Сохранено"; + statusEl.style.color = "#27ae60"; + setTimeout(() => { statusEl.textContent = ""; }, 3000); + } else { + throw new Error(res.error || "Ошибка сервера"); + } + } catch (e) { + statusEl.textContent = "❌ " + e.message; + statusEl.style.color = "#e74c3c"; + } + }); + + /* === Кнопка: Подписать акт === */ + screen.querySelector("#btnSign")?.addEventListener("click", () => { + haptic && haptic("impact"); + if (typeof SignRequest !== "undefined") { + SignRequest.open(asmId, { + clientName: asm.client_name || "", + clientTgId: asm.client_tg_id || null, + onSuccess: () => { + // Перезагружаем экран после успешной подписи + mount(container, asmId); + }, + }); + } else { + alert("Модуль подписания недоступен"); + } + }); + } // end render() + + render(); + } // end mount() + + return { mount }; +})(); diff --git a/miniapp/index.html b/miniapp/index.html index 4e00ddc..e462f8d 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -55,7 +55,9 @@ - - + + + +