mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 15:44:47 +00:00
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:
parent
ef51ebcb85
commit
bd85b30aa5
@ -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.
|
||||||
Возвращает (нормализованный, валиден ли)."""
|
Возвращает (нормализованный, валиден ли)."""
|
||||||
|
|||||||
@ -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="display:flex;justify-content:space-between;align-items:center;">
|
||||||
|
<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 style="font-size:12px;color:var(--muted);">${escHtml(fmtDate(data.signed_at) || "")}</div>
|
||||||
</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>`;
|
||||||
}
|
}
|
||||||
|
|||||||
412
miniapp/assets/signrequest.js
Normal file
412
miniapp/assets/signrequest.js
Normal 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, "&").replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
})();
|
||||||
@ -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);
|
||||||
|
}
|
||||||
/* ─────────────────────────────────────────────────────────────── */
|
/* ─────────────────────────────────────────────────────────────── */
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user