feat: SignRequest — цифровая подпись акта сборки (ФЗ-63 ПЭП)

Backend:
- _handle_sign_request_create: генерирует 6-значный OTP (72ч TTL),
  отправляет клиенту в Telegram для code-режима
- _handle_sign_request_submit: canvas (PNG→PHOTOS_DIR), code (OTP-верификация),
  proxy (представитель), absent (без подписи + причина)
- Assemblies sheet: +sign_token, sign_token_expires_at, signed_via,
  signed_by_tg_id, signed_by_phone
- assembly_detail: возвращает signed_via, client_tg_id, signed_by_phone

Frontend:
- signrequest.js: overlay-bottom-sheet, 4 таба (canvas/code/proxy/absent),
  canvas с touch-events, OTP-ввод, submit с валидацией
- assembly_detail.js: кнопка «Подписать акт» если не подписано,
  блок подписи с методом (VIA_LABELS) и перезагрузкой после подписания
- styles.css: .signreq-* компоненты

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
wasrusgen 2026-05-18 23:12:34 +03:00
parent ef51ebcb85
commit bd85b30aa5
5 changed files with 722 additions and 12 deletions

View File

@ -7,7 +7,8 @@ import os
import re import re
import time import time
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone, timedelta
import secrets
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from fastapi import FastAPI, Request, Response from fastapi import FastAPI, Request, Response
@ -144,6 +145,8 @@ async def _dispatch_post(request: Request):
"assembly_list": _handle_assembly_list, "assembly_list": _handle_assembly_list,
"assembly_detail": _handle_assembly_detail, "assembly_detail": _handle_assembly_detail,
"assembly_set_kitchen_price": _handle_assembly_set_kitchen_price, "assembly_set_kitchen_price": _handle_assembly_set_kitchen_price,
"sign_request_create": _handle_sign_request_create,
"sign_request_submit": _handle_sign_request_submit,
"proposal_brief": proposals_mod.handle_brief, "proposal_brief": proposals_mod.handle_brief,
"proposal_create": proposals_mod.handle_create, "proposal_create": proposals_mod.handle_create,
"proposal_upsert_variant": proposals_mod.handle_upsert_variant, "proposal_upsert_variant": proposals_mod.handle_upsert_variant,
@ -2220,8 +2223,11 @@ def _assembly_columns() -> list[str]:
"started_at", "completed_at", "started_at", "completed_at",
# Фото-отчёт: списки имён файлов через запятую (внутри PHOTOS_DIR/<assembly_id>/) # Фото-отчёт: списки имён файлов через запятую (внутри PHOTOS_DIR/<assembly_id>/)
"photos_before", "photos_in_progress", "photos_after", "photos_before", "photos_in_progress", "photos_after",
# Приёмка ППР: подпись клиента (PNG dataURL → файл) + ФИО/дата # Приёмка / подпись (SignRequest)
"signature_file", "signed_by_name", "signed_at", "sign_token", "sign_token_expires_at",
"signed_via", # canvas | code | proxy | absent
"signed_by_name", "signed_by_tg_id", "signed_by_phone",
"signature_file", "signed_at",
# Google Calendar # Google Calendar
"gcal_event_id", "gcal_event_url", "gcal_event_id", "gcal_event_url",
# Прочее # Прочее
@ -2455,12 +2461,17 @@ def _handle_assembly_detail(body: dict[str, Any]) -> dict[str, Any]:
"photos_in_progress": _list(row.get("photos_in_progress", "")), "photos_in_progress": _list(row.get("photos_in_progress", "")),
"photos_after": _list(row.get("photos_after", "")), "photos_after": _list(row.get("photos_after", "")),
"signature_file": row.get("signature_file", ""), "signature_file": row.get("signature_file", ""),
"signed_via": row.get("signed_via", ""),
"signed_by_name": row.get("signed_by_name", ""), "signed_by_name": row.get("signed_by_name", ""),
"signed_by_tg_id": row.get("signed_by_tg_id", ""),
"signed_by_phone": row.get("signed_by_phone", ""),
"signed_at": row.get("signed_at", ""), "signed_at": row.get("signed_at", ""),
"sign_token_expires_at": row.get("sign_token_expires_at", ""),
"gcal_event_id": row.get("gcal_event_id", ""), "gcal_event_id": row.get("gcal_event_id", ""),
"gcal_event_url": row.get("gcal_event_url", ""), "gcal_event_url": row.get("gcal_event_url", ""),
"manager_note": row.get("manager_note", ""), "manager_note": row.get("manager_note", ""),
"kitchen_price": row.get("kitchen_price", ""), "kitchen_price": row.get("kitchen_price", ""),
"client_tg_id": row.get("client_tg_id", ""),
} }
@ -2508,6 +2519,193 @@ def _handle_assembly_set_kitchen_price(body: dict[str, Any]) -> dict[str, Any]:
return {"ok": True, "kitchen_price": kitchen_price, "assembly_price": assembly_price} return {"ok": True, "kitchen_price": kitchen_price, "assembly_price": assembly_price}
# =================================================================
# SignRequest — цифровая подпись акта сборки (ФЗ-63 ПЭП)
# =================================================================
def _handle_sign_request_create(body: dict[str, Any]) -> dict[str, Any]:
"""Менеджер/сборщик инициирует подпись акта.
body: {initData, initDataUnsafe, assembly_id, mode: canvas|code|proxy|absent}
Для code-режима: генерирует OTP и отправляет клиенту через бот."""
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 = str(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")
or sheets.has_role(user, "assembler") or sheets.has_role(user, "measurer")):
return {"error": "forbidden"}
assembly_id = (body.get("assembly_id") or "").strip()
if not assembly_id:
return {"error": "missing_assembly_id"}
mode = (body.get("mode") or "canvas").strip()
if mode not in ("canvas", "code", "proxy", "absent"):
return {"error": "invalid_mode"}
_ensure_assemblies_sheet()
row = sheets.find_row("Assemblies", "id", assembly_id)
if not row:
return {"error": "assembly_not_found"}
is_owner = (str(row.get("manager_tg_id")) == tg_id
or str(row.get("assigned_to_tg_id")) == tg_id)
if not is_owner:
return {"error": "forbidden"}
now = datetime.now(timezone.utc)
expires_at = (now + timedelta(hours=72)).isoformat()
# 6-значный OTP (надёжнее строковым генератором)
otp = str(secrets.randbelow(900000) + 100000)
sheets.update_cell_by_key("Assemblies", "id", assembly_id, "sign_token", otp)
sheets.update_cell_by_key("Assemblies", "id", assembly_id, "sign_token_expires_at", expires_at)
client_tg_id = row.get("client_tg_id", "")
client_sent = False
if mode == "code" and client_tg_id:
msg = (
"🔐 <b>Код подтверждения акта сборки</b>\n\n"
f"Адрес: {row.get('address', '')}\n\n"
f"Ваш код: <code>{otp}</code>\n\n"
"Сообщите код мастеру или введите в приложении. "
"Код действителен 72 часа."
)
client_sent = tg.send_message(int(client_tg_id), msg)
sheets.log_event("sign_request_created", tg_id,
{"assembly_id": assembly_id, "mode": mode})
return {
"ok": True,
"sign_token": otp,
"expires_at": expires_at,
"mode": mode,
"client_tg_id": client_tg_id,
"client_name": row.get("client_name", ""),
"client_sent": client_sent,
}
def _handle_sign_request_submit(body: dict[str, Any]) -> dict[str, Any]:
"""Фиксирует подпись. Режимы:
canvas {initData, assembly_id, mode, signature_data(base64 PNG), signed_by_name, signed_by_phone?}
code {assembly_id, mode, code, signed_by_name, signed_by_phone?, initData?}
proxy {initData, assembly_id, mode, signed_by_name, signed_by_phone?}
absent {initData, assembly_id, mode, absent_reason?}
"""
cfg = get_config()
mode = (body.get("mode") or "canvas").strip()
if mode not in ("canvas", "code", "proxy", "absent"):
return {"error": "invalid_mode"}
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"}
now_iso = _now_iso()
signed_by_name = (body.get("signed_by_name") or "").strip()
signed_by_phone = (body.get("signed_by_phone") or "").strip()
signed_by_tg_id = ""
signature_file = ""
if mode == "code":
code = (body.get("code") or "").strip()
stored = (row.get("sign_token") or "").strip()
expires_str = (row.get("sign_token_expires_at") or "").strip()
if not stored:
return {"error": "no_sign_token"}
if not code:
return {"error": "missing_code"}
if code != stored:
return {"error": "invalid_code"}
if expires_str:
try:
# Парсим ISO без dateutil
exp = datetime.fromisoformat(expires_str.replace("Z", "+00:00"))
if datetime.now(timezone.utc) > exp:
return {"error": "code_expired"}
except Exception:
pass # не блокируем если парсинг упал
# Берём tg_id из initData если есть (клиент авторизован в боте)
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
if auth and auth.get("user"):
signed_by_tg_id = str(auth["user"]["id"])
elif mode in ("canvas", "proxy", "absent"):
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 = str(auth["user"]["id"])
is_owner = (str(row.get("manager_tg_id")) == tg_id
or str(row.get("assigned_to_tg_id")) == tg_id)
if not is_owner:
return {"error": "forbidden"}
signed_by_tg_id = tg_id
if mode == "canvas":
sig_data = (body.get("signature_data") or "").strip()
if not sig_data:
return {"error": "missing_signature_data"}
# data:image/png;base64,<data> → bytes
raw = sig_data.split(",", 1)[-1]
try:
sig_bytes = base64.b64decode(raw)
except Exception:
return {"error": "invalid_signature_data"}
sig_dir = PHOTOS_DIR / assembly_id
sig_dir.mkdir(parents=True, exist_ok=True)
sig_filename = f"sign_{int(time.time())}.png"
(sig_dir / sig_filename).write_bytes(sig_bytes)
signature_file = sig_filename
elif mode == "absent":
reason = (body.get("absent_reason") or "Клиент отсутствовал").strip()
signed_by_name = reason
# Пишем все поля за один проход (каждый update_cell — отдельный запрос к Sheets)
updates = {
"signed_via": mode,
"signed_by_name": signed_by_name,
"signed_by_phone": signed_by_phone,
"signed_by_tg_id": signed_by_tg_id,
"signed_at": now_iso,
"signature_file": signature_file,
}
for col, val in updates.items():
sheets.update_cell_by_key("Assemblies", "id", assembly_id, col, val)
sheets.log_event("assembly_signed", signed_by_tg_id or "anon",
{"assembly_id": assembly_id, "mode": mode, "by": signed_by_name})
return {"ok": True, "signed_at": now_iso, "mode": mode, "signed_by_name": signed_by_name}
@app.post("/api/sign_request_create")
async def api_sign_request_create(request: Request):
body = await _safe_json(request)
return JSONResponse(_handle_sign_request_create(body))
@app.post("/api/sign_request_submit")
async def api_sign_request_submit(request: Request):
body = await _safe_json(request)
return JSONResponse(_handle_sign_request_submit(body))
def _normalize_phone(raw: str) -> tuple[str, bool]: def _normalize_phone(raw: str) -> tuple[str, bool]:
"""Нормализует RU-телефон в формат +7XXXXXXXXXX. """Нормализует RU-телефон в формат +7XXXXXXXXXX.
Возвращает (нормализованный, валиден ли).""" Возвращает (нормализованный, валиден ли)."""

View File

@ -141,16 +141,25 @@ const AssemblyDetailScreen = (function () {
</div>` : ""; </div>` : "";
// Подпись // Подпись
const VIA_LABELS = {
canvas: "✍️ Подпись пальцем",
code: "📱 Код подтверждения",
proxy: "👤 Представитель",
absent: "🚫 Без подписи",
};
const signBlock = data.signed_by_name ? ` const signBlock = data.signed_by_name ? `
<div style="margin:12px 16px 0;padding:10px 12px;background:var(--surface); <div style="margin:12px 16px 0;padding:10px 12px;background:var(--surface);
border:1px solid var(--border);border-radius:12px; border:1px solid var(--border);border-radius:12px;">
display:flex;justify-content:space-between;align-items:center;"> <div style="font-size:11px;font-weight:700;text-transform:uppercase;
<div> letter-spacing:.06em;color:var(--muted);margin-bottom:6px;">
<div style="font-size:12px;color:var(--muted);">Принято клиентом</div> ${escHtml(VIA_LABELS[data.signed_via] || "Принято")}
<div style="font-size:13px;font-weight:600;color:var(--ink);">${escHtml(data.signed_by_name)}</div>
</div> </div>
<div style="font-size:12px;color:var(--muted);">${escHtml(fmtDate(data.signed_at) || "")}</div> <div style="display:flex;justify-content:space-between;align-items:center;">
</div>` : ""; <div style="font-size:13px;font-weight:600;color:var(--ink);">${escHtml(data.signed_by_name)}</div>
<div style="font-size:12px;color:var(--muted);">${escHtml(fmtDate(data.signed_at) || "")}</div>
</div>
${data.signed_by_phone ? `<div style="font-size:12px;color:var(--muted);margin-top:2px;">${escHtml(data.signed_by_phone)}</div>` : ""}
</div>` : `<div id="sr-sign-btn-wrap" style="margin:12px 16px 0;"></div>`;
// Кнопка Google Calendar // Кнопка Google Calendar
const calBtn = data.gcal_event_url ? ` const calBtn = data.gcal_event_url ? `
@ -167,6 +176,31 @@ const AssemblyDetailScreen = (function () {
screen.innerHTML = statusBanner + mainBlock + noteBlock + photosBlock + signBlock + calBtn + screen.innerHTML = statusBanner + mainBlock + noteBlock + photosBlock + signBlock + calBtn +
`<div style="height:32px;"></div>`; `<div style="height:32px;"></div>`;
// Кнопка «Подписать акт» — только если ещё не подписано
if (!data.signed_by_name) {
const btnWrap = screen.querySelector("#sr-sign-btn-wrap");
if (btnWrap) {
const signBtn = document.createElement("button");
signBtn.className = "btn-primary";
signBtn.style.cssText = "width:100%;font-size:15px;padding:13px;";
signBtn.textContent = "✍️ Подписать акт приёмки";
signBtn.addEventListener("click", () => {
haptic && haptic("impact");
if (typeof SignRequest !== "undefined") {
SignRequest.open(data.id, {
clientName: data.client_name || "",
clientTgId: data.client_tg_id || "",
onSuccess: () => {
// Перерисовываем экран после подписания
mount(container, assemblyId);
},
});
}
});
btnWrap.appendChild(signBtn);
}
}
} catch (e) { } catch (e) {
screen.innerHTML = `<div class="error" style="margin:16px;">Ошибка: ${escHtml(e.message)}</div>`; screen.innerHTML = `<div class="error" style="margin:16px;">Ошибка: ${escHtml(e.message)}</div>`;
} }

View File

@ -0,0 +1,412 @@
/* ============================================================
SignRequest цифровая подпись акта сборки (ФЗ-63 ПЭП)
Два метода: canvas (палец) и code (OTP через Telegram/SMS).
Дополнительно: proxy (представитель) и absent (клиент отсутствовал).
============================================================ */
const SignRequest = (function () {
"use strict";
function escHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
async function _api(path, body = {}) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 15000);
try {
const res = await fetch(`${BACKEND_URL}/api/${path}`, {
method: "POST", signal: ctrl.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
initData: Platform.initData,
initDataUnsafe: Platform.initDataUnsafe,
...body,
}),
});
if (!res.ok) throw new Error(`Ошибка сервера (${res.status})`);
return await res.json();
} catch (e) {
if (e.name === "AbortError") throw new Error("Сервер не отвечает");
throw e;
} finally { clearTimeout(t); }
}
/* ── Canvas signature ─────────────────────────────────────── */
function initCanvas(canvas) {
const ctx = canvas.getContext("2d");
let drawing = false;
let hasStrokes = false;
function resize() {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
ctx.strokeStyle = "#111";
ctx.lineWidth = 2.5;
ctx.lineCap = "round";
ctx.lineJoin = "round";
}
resize();
function pos(e) {
const rect = canvas.getBoundingClientRect();
const src = e.touches ? e.touches[0] : e;
return { x: src.clientX - rect.left, y: src.clientY - rect.top };
}
function start(e) {
e.preventDefault();
drawing = true;
const { x, y } = pos(e);
ctx.beginPath();
ctx.moveTo(x, y);
}
function move(e) {
e.preventDefault();
if (!drawing) return;
hasStrokes = true;
const { x, y } = pos(e);
ctx.lineTo(x, y);
ctx.stroke();
}
function end(e) {
e.preventDefault();
drawing = false;
}
canvas.addEventListener("mousedown", start, { passive: false });
canvas.addEventListener("mousemove", move, { passive: false });
canvas.addEventListener("mouseup", end, { passive: false });
canvas.addEventListener("touchstart", start, { passive: false });
canvas.addEventListener("touchmove", move, { passive: false });
canvas.addEventListener("touchend", end, { passive: false });
return {
clear() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
hasStrokes = false;
},
isEmpty() { return !hasStrokes; },
toDataURL() { return canvas.toDataURL("image/png"); },
};
}
/* ── Overlay builder ──────────────────────────────────────── */
function open(assemblyId, opts = {}) {
const clientName = opts.clientName || "";
const clientTgId = opts.clientTgId || "";
const onSuccess = opts.onSuccess || null;
// Удалить предыдущий если есть
document.getElementById("signreq-overlay")?.remove();
const overlay = document.createElement("div");
overlay.id = "signreq-overlay";
overlay.className = "signreq-overlay";
overlay.innerHTML = `
<div class="signreq-sheet">
<div class="signreq-header">
<div class="signreq-title">Подписание акта</div>
<button class="signreq-close" aria-label="Закрыть"></button>
</div>
<!-- Режим: tabs -->
<div class="signreq-tabs">
<button class="signreq-tab active" data-tab="canvas"> Пальцем</button>
<button class="signreq-tab" data-tab="code">📱 Код</button>
<button class="signreq-tab" data-tab="proxy">👤 Представитель</button>
<button class="signreq-tab" data-tab="absent">🚫 Отсутствует</button>
</div>
<!-- Canvas -->
<div class="signreq-panel" id="sr-panel-canvas">
<div style="font-size:12px;color:var(--muted);margin-bottom:8px;text-align:center;">
Клиент подписывает пальцем на экране
</div>
<div class="signreq-canvas-wrap">
<canvas id="sr-canvas"></canvas>
<div class="signreq-canvas-hint">Подпись здесь</div>
</div>
<div style="display:flex;gap:8px;margin-top:10px;">
<button class="btn-secondary signreq-clear">Очистить</button>
</div>
<div class="signreq-name-row">
<label class="field">
<span class="field-label">ФИО подписанта</span>
<input id="sr-canvas-name" type="text" placeholder="${escHtml(clientName || "Имя клиента")}" value="${escHtml(clientName)}">
</label>
<label class="field">
<span class="field-label">Телефон (необяз.)</span>
<input id="sr-canvas-phone" type="tel" placeholder="+7 ...">
</label>
</div>
<div class="signreq-cta">
<button class="btn-primary signreq-submit-canvas">Подписать</button>
</div>
<div class="signreq-err" id="sr-canvas-err"></div>
</div>
<!-- Code -->
<div class="signreq-panel" id="sr-panel-code" style="display:none;">
<div class="signreq-code-info">
${clientTgId
? `Код будет отправлен клиенту <b>${escHtml(clientName || "")}</b> в Telegram`
: `<span style="color:var(--warn,#F39C12);">⚠️ Telegram клиента не привязан — продиктуйте код по телефону</span>`}
</div>
<div id="sr-code-send-block">
<button class="btn-secondary signreq-send-code">📤 Отправить код клиенту</button>
</div>
<div id="sr-code-input-block" style="display:none;">
<div style="font-size:12px;color:var(--muted);margin:10px 0 4px;">Код отправлен. Введите 6 цифр:</div>
<input id="sr-code-input" type="text" inputmode="numeric" pattern="[0-9]{6}"
maxlength="6" placeholder="000000"
style="font-size:28px;letter-spacing:8px;text-align:center;width:100%;padding:12px;">
<div class="signreq-name-row" style="margin-top:10px;">
<label class="field">
<span class="field-label">ФИО подписанта</span>
<input id="sr-code-name" type="text" placeholder="${escHtml(clientName || "Имя клиента")}" value="${escHtml(clientName)}">
</label>
<label class="field">
<span class="field-label">Телефон (необяз.)</span>
<input id="sr-code-phone" type="tel" placeholder="+7 ...">
</label>
</div>
<div class="signreq-cta">
<button class="btn-primary signreq-submit-code">Подтвердить</button>
</div>
</div>
<div class="signreq-err" id="sr-code-err"></div>
</div>
<!-- Proxy -->
<div class="signreq-panel" id="sr-panel-proxy" style="display:none;">
<div style="font-size:13px;color:var(--muted);margin-bottom:12px;">
Акт подписывает уполномоченный представитель клиента.
Укажите его данные.
</div>
<div class="signreq-name-row">
<label class="field">
<span class="field-label">ФИО представителя</span>
<input id="sr-proxy-name" type="text" placeholder="Иванов Иван Иванович">
</label>
<label class="field">
<span class="field-label">Телефон представителя</span>
<input id="sr-proxy-phone" type="tel" placeholder="+7 ...">
</label>
</div>
<div class="signreq-cta">
<button class="btn-primary signreq-submit-proxy">Зафиксировать подпись</button>
</div>
<div class="signreq-err" id="sr-proxy-err"></div>
</div>
<!-- Absent -->
<div class="signreq-panel" id="sr-panel-absent" style="display:none;">
<div style="font-size:13px;color:var(--muted);margin-bottom:12px;">
Клиент отсутствовал при сдаче работ. Укажите причину.
</div>
<div class="field">
<span class="field-label">Причина</span>
<select id="sr-absent-reason" style="width:100%;padding:10px;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:var(--ink);font-size:14px;">
<option value="Клиент отсутствовал">Клиент отсутствовал (общая)</option>
<option value="Клиент недоступен по телефону">Клиент недоступен по телефону</option>
<option value="Клиент перенёс приёмку">Клиент перенёс приёмку</option>
<option value="Акт отправлен на email">Акт отправлен на email / мессенджер</option>
</select>
</div>
<div class="field" style="margin-top:8px;">
<span class="field-label">Примечание (необяз.)</span>
<input id="sr-absent-note" type="text" placeholder="доп. комментарий">
</div>
<div class="signreq-cta">
<button class="btn-primary signreq-submit-absent">Отметить как «без подписи»</button>
</div>
<div class="signreq-err" id="sr-absent-err"></div>
</div>
</div>
`;
document.body.appendChild(overlay);
// Закрытие
overlay.querySelector(".signreq-close").addEventListener("click", close);
overlay.addEventListener("click", (e) => {
if (e.target === overlay) close();
});
// Tabs
const tabs = overlay.querySelectorAll(".signreq-tab");
tabs.forEach(tab => {
tab.addEventListener("click", () => {
tabs.forEach(t => t.classList.remove("active"));
tab.classList.add("active");
overlay.querySelectorAll(".signreq-panel").forEach(p => p.style.display = "none");
overlay.querySelector(`#sr-panel-${tab.dataset.tab}`).style.display = "";
});
});
// Canvas setup
const canvas = overlay.querySelector("#sr-canvas");
const canvasCtrl = initCanvas(canvas);
overlay.querySelector(".signreq-clear").addEventListener("click", () => {
canvasCtrl.clear();
Platform.haptic("impact");
});
// ── Отправить код ────────────────────────────────────────────
let codeSent = false;
overlay.querySelector(".signreq-send-code").addEventListener("click", async () => {
const btn = overlay.querySelector(".signreq-send-code");
const errEl = overlay.querySelector("#sr-code-err");
errEl.textContent = "";
btn.disabled = true;
btn.textContent = "Отправляем...";
try {
const res = await _api("sign_request_create", { assembly_id: assemblyId, mode: "code" });
if (res.error) throw new Error(res.error);
codeSent = true;
overlay.querySelector("#sr-code-send-block").style.display = "none";
overlay.querySelector("#sr-code-input-block").style.display = "";
Platform.haptic("success");
} catch (e) {
btn.disabled = false;
btn.textContent = "📤 Отправить код клиенту";
errEl.textContent = "Ошибка: " + e.message;
}
});
// ── Submit: canvas ───────────────────────────────────────────
overlay.querySelector(".signreq-submit-canvas").addEventListener("click", async () => {
const errEl = overlay.querySelector("#sr-canvas-err");
errEl.textContent = "";
if (canvasCtrl.isEmpty()) {
errEl.textContent = "Нарисуйте подпись на поле выше";
return;
}
const name = overlay.querySelector("#sr-canvas-name").value.trim();
if (!name) {
errEl.textContent = "Укажите ФИО подписанта";
return;
}
await _submitSigned({
mode: "canvas",
signature_data: canvasCtrl.toDataURL(),
signed_by_name: name,
signed_by_phone: overlay.querySelector("#sr-canvas-phone").value.trim(),
errEl,
onSuccess,
});
});
// ── Submit: code ─────────────────────────────────────────────
overlay.querySelector(".signreq-submit-code").addEventListener("click", async () => {
const errEl = overlay.querySelector("#sr-code-err");
errEl.textContent = "";
const code = overlay.querySelector("#sr-code-input").value.trim();
if (!/^\d{6}$/.test(code)) {
errEl.textContent = "Введите 6-значный код";
return;
}
const name = overlay.querySelector("#sr-code-name").value.trim();
if (!name) {
errEl.textContent = "Укажите ФИО подписанта";
return;
}
await _submitSigned({
mode: "code",
code,
signed_by_name: name,
signed_by_phone: overlay.querySelector("#sr-code-phone").value.trim(),
errEl,
onSuccess,
}, assemblyId);
});
// ── Submit: proxy ─────────────────────────────────────────────
overlay.querySelector(".signreq-submit-proxy").addEventListener("click", async () => {
const errEl = overlay.querySelector("#sr-proxy-err");
errEl.textContent = "";
const name = overlay.querySelector("#sr-proxy-name").value.trim();
if (!name) {
errEl.textContent = "Укажите ФИО представителя";
return;
}
await _submitSigned({
mode: "proxy",
signed_by_name: name,
signed_by_phone: overlay.querySelector("#sr-proxy-phone").value.trim(),
errEl,
onSuccess,
}, assemblyId);
});
// ── Submit: absent ────────────────────────────────────────────
overlay.querySelector(".signreq-submit-absent").addEventListener("click", async () => {
const errEl = overlay.querySelector("#sr-absent-err");
errEl.textContent = "";
const reason = overlay.querySelector("#sr-absent-reason").value;
const note = overlay.querySelector("#sr-absent-note").value.trim();
await _submitSigned({
mode: "absent",
absent_reason: note ? `${reason} · ${note}` : reason,
errEl,
onSuccess,
}, assemblyId);
});
// --- helper -------------------------------------------------------
async function _submitSigned(params, asmId = assemblyId) {
const { errEl, onSuccess: cb, ...apiParams } = params;
const btn = overlay.querySelector(`.signreq-submit-${apiParams.mode}`);
if (btn) { btn.disabled = true; btn.textContent = "Сохраняем..."; }
try {
const res = await _api("sign_request_submit", {
assembly_id: asmId,
...apiParams,
});
if (res.error) throw new Error(res.error);
Platform.haptic("success");
close();
if (typeof cb === "function") cb(res);
} catch (e) {
if (btn) { btn.disabled = false; btn.textContent = _btnLabel(apiParams.mode); }
errEl.textContent = _errMsg(e.message);
}
}
}
function _btnLabel(mode) {
return {
canvas: "Подписать",
code: "Подтвердить",
proxy: "Зафиксировать подпись",
absent: "Отметить как «без подписи»",
}[mode] || "Подтвердить";
}
function _errMsg(raw) {
return ({
invalid_code: "Неверный код — проверьте и попробуйте снова",
code_expired: "Код устарел (72 ч) — отправьте новый",
no_sign_token: "Сначала отправьте код клиенту",
missing_code: "Введите 6-значный код",
forbidden: "Нет прав на подпись этой сборки",
invalid_init_data: "Ошибка авторизации — перезапустите приложение",
})[raw] || "Ошибка: " + raw;
}
function close() {
document.getElementById("signreq-overlay")?.remove();
}
return { open, close };
})();

View File

@ -1568,4 +1568,69 @@ html[data-variant="d"] .section-head .label {
font-size: 13px; font-size: 13px;
padding: 7px 14px; padding: 7px 14px;
} }
/* ── SignRequest overlay ─────────────────────────────────────── */
.signreq-overlay {
position: fixed; inset: 0; z-index: 1000;
background: rgba(0,0,0,.55);
display: flex; align-items: flex-end;
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
}
.signreq-sheet {
width: 100%; max-height: 92dvh; overflow-y: auto;
background: var(--surface, #fff);
border-radius: 20px 20px 0 0;
padding: 0 0 env(safe-area-inset-bottom, 16px);
}
.signreq-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 16px 8px;
position: sticky; top: 0;
background: var(--surface, #fff);
border-bottom: 1px solid var(--border, #eee);
z-index: 1;
}
.signreq-title { font-size: 16px; font-weight: 700; color: var(--ink); }
.signreq-close {
background: none; border: none; cursor: pointer;
font-size: 18px; color: var(--muted); padding: 4px 8px;
}
.signreq-tabs {
display: flex; gap: 4px; padding: 10px 12px 0; flex-wrap: wrap;
}
.signreq-tab {
background: var(--surface-2, var(--surface));
border: 1px solid var(--border); border-radius: 20px;
font-size: 12px; font-weight: 600; color: var(--muted);
padding: 6px 12px; cursor: pointer;
transition: background .15s, color .15s;
}
.signreq-tab.active {
background: var(--accent, #003E7E); color: #fff; border-color: transparent;
}
.signreq-panel { padding: 14px 16px 8px; }
.signreq-canvas-wrap {
position: relative; width: 100%; height: 160px;
border: 2px solid var(--border); border-radius: 10px;
background: var(--bg, #f9f9f9); overflow: hidden;
cursor: crosshair; touch-action: none;
}
.signreq-canvas-wrap canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.signreq-canvas-hint {
position: absolute; inset: 0; display: flex;
align-items: center; justify-content: center;
font-size: 13px; color: var(--muted); pointer-events: none;
opacity: .6;
}
.signreq-name-row { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
.signreq-cta { margin-top: 12px; }
.signreq-cta .btn-primary { width: 100%; }
.signreq-err {
color: #C0392B; font-size: 12px; margin-top: 6px; min-height: 16px;
}
.signreq-code-info {
font-size: 13px; color: var(--ink); margin-bottom: 12px;
padding: 10px 12px; background: var(--surface-2, var(--surface));
border-radius: 8px; border: 1px solid var(--border);
}
/* ─────────────────────────────────────────────────────────────── */ /* ─────────────────────────────────────────────────────────────── */

View File

@ -12,7 +12,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700&family=Inter:wght@400;500;600;700;800&family=Geist:wght@400;500;600&family=Manrope:wght@400;500;600;700&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&family=Cormorant+Garamond:ital,wght@1,400;1,500;1,600&family=Caveat:wght@500;700&display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700&family=Inter:wght@400;500;600;700;800&family=Geist:wght@400;500;600&family=Manrope:wght@400;500;600;700&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&family=Cormorant+Garamond:ital,wght@1,400;1,500;1,600&family=Caveat:wght@500;700&display=swap">
<script src="https://telegram.org/js/telegram-web-app.js"></script> <script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="stylesheet" href="assets/styles.css?v=20260518l"> <link rel="stylesheet" href="assets/styles.css?v=20260518o">
<link rel="stylesheet" href="assets/podbor.css?v=20260517j"> <link rel="stylesheet" href="assets/podbor.css?v=20260517j">
</head> </head>
<body> <body>
@ -51,7 +51,8 @@
<script src="assets/cabinet.js?v=20260518j"></script> <script src="assets/cabinet.js?v=20260518j"></script>
<script src="assets/selfmeasure.js?v=20260518k"></script> <script src="assets/selfmeasure.js?v=20260518k"></script>
<script src="assets/orders.js?v=20260518l"></script> <script src="assets/orders.js?v=20260518l"></script>
<script src="assets/assembly_detail.js?v=20260518m"></script> <script src="assets/signrequest.js?v=20260518o"></script>
<script src="assets/assembly_detail.js?v=20260518o"></script>
<script src="assets/app.js?v=20260518n"></script> <script src="assets/app.js?v=20260518n"></script>
</body> </body>
</html> </html>