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(`
+
+ Год:
+
+ 2026
+ 2025
+ 2024
+ Все
+
+
+ `);
+
+ 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 @@
-
-
+
+
+
+