measurements: photo upload + measurement detail page + PDF/print

Wizard: new 'photos' step (6 total) — camera/gallery input, client-side
canvas compression to 1600px @ ~78% JPEG, max 12 photos. Thumbnails
with delete in step; preview in summary.

Backend: POST /api/measurement now decodes data-URL photos and saves
to /app/photos/<id>/N.jpg (volume-mounted). New GET /api/photo/{id}/{n}
serves files with path-traversal protection. New POST /api/measurement_detail
returns full measurement record (walls/openings/photos/notes/...).

Clients page: measurement rows now clickable → renderMeasurement detail
view with key-value grid + photo gallery + 'Скачать PDF / Печать'.
Print stylesheet (@media print) hides navigation/buttons/uploaders and
prints clean A4-friendly layout.

Podbor report: existing 'Печать → PDF' now falls back to inline
window.print() inside Telegram WebApp (popups are blocked there).

Cache bust v=20260513a.
This commit is contained in:
wasrusgen 2026-05-12 18:11:29 +03:00
parent 10bcc75b13
commit a084542bbf
7 changed files with 574 additions and 31 deletions

View File

@ -1,13 +1,17 @@
"""ЗОВ Backend — FastAPI app. Полный порт Apps Script Code.gs.""" """ЗОВ Backend — FastAPI app. Полный порт Apps Script Code.gs."""
from __future__ import annotations from __future__ import annotations
import base64
import json import json
import logging import logging
import os
import re
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path
from typing import Any from typing import Any
from fastapi import FastAPI, Request, Response from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import FileResponse, JSONResponse
from .config import get_config from .config import get_config
from .auth import verify_init_data from .auth import verify_init_data
@ -20,6 +24,16 @@ log = logging.getLogger("zov.backend")
app = FastAPI(title="ZOV Backend", version="2.0") app = FastAPI(title="ZOV Backend", version="2.0")
# Каталог под фото замеров (монтируется как volume в docker-compose)
PHOTOS_DIR = Path(os.environ.get("PHOTOS_DIR", "/app/photos"))
try:
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
except Exception as _e:
logging.getLogger("zov.backend").warning("Не удалось создать PHOTOS_DIR=%s: %s", PHOTOS_DIR, _e)
_SAFE_ID_RE = re.compile(r"^[A-Za-z0-9_\-]{1,64}$")
_SAFE_FILE_RE = re.compile(r"^[A-Za-z0-9_\-.]{1,80}$")
# CORS — MiniApp хостится на github.io, бэкенд на api.wasrusgen1.pro. # CORS — MiniApp хостится на github.io, бэкенд на api.wasrusgen1.pro.
# Простые запросы (text/plain или без Content-Type) не триггерят preflight. # Простые запросы (text/plain или без Content-Type) не триггерят preflight.
app.add_middleware( app.add_middleware(
@ -87,6 +101,7 @@ async def _dispatch_post(request: Request):
"me": _handle_me, "me": _handle_me,
"measurement": _handle_measurement, "measurement": _handle_measurement,
"measurements": _handle_measurements_list, "measurements": _handle_measurements_list,
"measurement_detail": _handle_measurement_detail,
"podbor": _handle_podbor, "podbor": _handle_podbor,
"clients": _handle_clients, "clients": _handle_clients,
"lead": _handle_lead, "lead": _handle_lead,
@ -156,6 +171,33 @@ async def api_measurements(request: Request):
return _handle_measurements_list(body) return _handle_measurements_list(body)
@app.post("/api/measurement_detail")
async def api_measurement_detail(request: Request):
body = await _safe_json(request)
return _handle_measurement_detail(body)
@app.get("/api/photo/{measurement_id}/{filename}")
async def api_photo(measurement_id: str, filename: str):
"""Отдаёт фото замера. Защита от path traversal — только разрешённые id и имена."""
if not _SAFE_ID_RE.match(measurement_id) or not _SAFE_FILE_RE.match(filename):
return JSONResponse({"error": "bad_path"}, status_code=400)
if filename.startswith(".") or ".." in filename:
return JSONResponse({"error": "bad_path"}, status_code=400)
p = PHOTOS_DIR / measurement_id / filename
try:
p_resolved = p.resolve()
if PHOTOS_DIR.resolve() not in p_resolved.parents:
return JSONResponse({"error": "bad_path"}, status_code=400)
except Exception:
return JSONResponse({"error": "bad_path"}, status_code=400)
if not p.exists() or not p.is_file():
return JSONResponse({"error": "not_found"}, status_code=404)
ext = filename.rsplit(".", 1)[-1].lower()
media = {"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png", "webp": "image/webp"}.get(ext, "application/octet-stream")
return FileResponse(str(p), media_type=media)
@app.get("/api/test_ai") @app.get("/api/test_ai")
async def api_test_ai(): async def api_test_ai():
return _handle_test_ai() return _handle_test_ai()
@ -396,6 +438,37 @@ def _handle_me(body: dict[str, Any]) -> dict[str, Any]:
} }
_DATA_URL_RE = re.compile(r"^data:image/(jpeg|jpg|png|webp);base64,(.+)$", re.DOTALL)
def _save_measurement_photo(measurement_id: str, idx: int, data_url: str) -> str | None:
"""Сохраняет фото (data-URL) в `PHOTOS_DIR/<measurement_id>/<idx>.<ext>`.
Возвращает имя файла или None при ошибке."""
if not isinstance(data_url, str):
return None
m = _DATA_URL_RE.match(data_url.strip())
if not m:
return None
ext = "jpg" if m.group(1) in ("jpeg", "jpg") else m.group(1)
try:
raw = base64.b64decode(m.group(2), validate=False)
except Exception:
return None
if len(raw) > 10 * 1024 * 1024: # 10 MB hard cap
return None
if not _SAFE_ID_RE.match(measurement_id):
return None
target_dir = PHOTOS_DIR / measurement_id
try:
target_dir.mkdir(parents=True, exist_ok=True)
name = f"{idx}.{ext}"
(target_dir / name).write_bytes(raw)
return name
except Exception:
log.warning("Не удалось сохранить фото %d для замера %s", idx, measurement_id)
return None
def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]: def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]:
cfg = get_config() cfg = get_config()
auth = verify_init_data(body.get("initData") or "", cfg.bot_token) auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
@ -425,6 +498,19 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]:
if extras: if extras:
notes_full = " · ".join(extras) + ("\n" + notes_full if notes_full else "") notes_full = " · ".join(extras) + ("\n" + notes_full if notes_full else "")
# Сохраняем фотографии (data-URL → файлы), в Sheets кладём только имена
raw_photos = m.get("photos") or []
saved_photos: list[str] = []
if isinstance(raw_photos, list):
for i, p in enumerate(raw_photos[:20]): # хард-кап 20 фото на замер
if isinstance(p, str) and p.startswith("data:"):
fn = _save_measurement_photo(measurement_id, i, p)
if fn:
saved_photos.append(fn)
elif isinstance(p, str) and p and not p.startswith("data:"):
# уже готовое имя/URL — пропускаем как есть
saved_photos.append(p)
sheets.append_row("Measurements", [ sheets.append_row("Measurements", [
measurement_id, _now_iso(), client_tg_id or "", manager_tg_id or "", measurement_id, _now_iso(), client_tg_id or "", manager_tg_id or "",
filled_by, filled_by,
@ -433,7 +519,7 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]:
json.dumps(m.get("openings") or {}, ensure_ascii=False), json.dumps(m.get("openings") or {}, ensure_ascii=False),
json.dumps(m.get("infra") or {}, ensure_ascii=False), json.dumps(m.get("infra") or {}, ensure_ascii=False),
json.dumps(m.get("niches") or {}, ensure_ascii=False), json.dumps(m.get("niches") or {}, ensure_ascii=False),
",".join(m.get("photos") or []), ",".join(saved_photos),
notes_full, notes_full,
"submitted", "submitted",
]) ])
@ -450,7 +536,7 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]:
) )
sheets.log_event("measurement_submitted", tg_id, {"id": measurement_id, "filled_by": filled_by}) sheets.log_event("measurement_submitted", tg_id, {"id": measurement_id, "filled_by": filled_by})
return {"ok": True, "id": measurement_id} return {"ok": True, "id": measurement_id, "photos": saved_photos}
def _handle_podbor(body: dict[str, Any]) -> dict[str, Any]: def _handle_podbor(body: dict[str, Any]) -> dict[str, Any]:
@ -678,6 +764,61 @@ def _handle_lead(body: dict[str, Any]) -> dict[str, Any]:
} }
def _handle_measurement_detail(body: dict[str, Any]) -> dict[str, Any]:
"""Возвращает один замер целиком — для детальной страницы и печати."""
cfg = get_config()
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
if not auth or not auth.get("user"):
return {"error": "invalid_init_data"}
tg_id = auth["user"]["id"]
user = sheets.find_user(tg_id)
if not user:
return {"error": "user_not_found"}
measurement_id = body.get("measurement_id") or body.get("id")
if not measurement_id:
return {"error": "missing_measurement_id"}
row = sheets.find_row("Measurements", "id", measurement_id)
if not row:
return {"error": "measurement_not_found"}
# Только владелец-менеджер или клиент-владелец видит замер
if user.get("role") == "manager":
if str(row.get("manager_tg_id", "")) != str(tg_id):
return {"error": "forbidden"}
else:
if str(row.get("client_tg_id", "")) != str(tg_id):
return {"error": "forbidden"}
def _safe_json(s: str) -> Any:
try:
return json.loads(s) if s else {}
except (ValueError, TypeError):
return {}
photo_files = [p for p in (row.get("photos") or "").split(",") if p]
return {
"ok": True,
"id": row.get("id"),
"created_at": row.get("ts") or row.get("created_at"),
"client_tg_id": row.get("client_tg_id", ""),
"manager_tg_id": row.get("manager_tg_id", ""),
"filled_by": row.get("filled_by", ""),
"layout": row.get("layout", ""),
"area_m2": row.get("area_m2", ""),
"ceiling_mm": row.get("ceiling_mm", ""),
"walls": _safe_json(row.get("walls", "")),
"openings": _safe_json(row.get("openings", "")),
"infra": _safe_json(row.get("infra", "")),
"niches": _safe_json(row.get("niches", "")),
"photos": photo_files,
"notes": row.get("notes", ""),
"status": row.get("status", ""),
}
def _handle_measurements_list(body: dict[str, Any]) -> dict[str, Any]: def _handle_measurements_list(body: dict[str, Any]) -> dict[str, Any]:
"""Список замеров менеджера, опционально отфильтрованный по client_tg_id / client_name.""" """Список замеров менеджера, опционально отфильтрованный по client_tg_id / client_name."""
cfg = get_config() cfg = get_config()
@ -714,6 +855,7 @@ def _handle_measurements_list(body: dict[str, Any]) -> dict[str, Any]:
# Из notes / других JSON-полей вытащим client_name если был передан в measurement # Из notes / других JSON-полей вытащим client_name если был передан в measurement
# (он не сохраняется в отдельной колонке — только в JSON-обвязке) # (он не сохраняется в отдельной колонке — только в JSON-обвязке)
# Для MVP — фильтр по имени делаем после парсинга JSON-полей # Для MVP — фильтр по имени делаем после парсинга JSON-полей
photo_files = [p for p in (row.get("photos") or "").split(",") if p]
out.append({ out.append({
"id": row.get("id", ""), "id": row.get("id", ""),
"created_at": row.get("ts") or row.get("created_at", ""), "created_at": row.get("ts") or row.get("created_at", ""),
@ -725,6 +867,8 @@ def _handle_measurements_list(body: dict[str, Any]) -> dict[str, Any]:
"ceiling_mm": row.get("ceiling_mm", ""), "ceiling_mm": row.get("ceiling_mm", ""),
"notes": row.get("notes", ""), "notes": row.get("notes", ""),
"status": row.get("status", ""), "status": row.get("status", ""),
"photos": photo_files,
"photo_count": len(photo_files),
}) })
# Сортируем по дате desc # Сортируем по дате desc

View File

@ -10,6 +10,7 @@ services:
- .env - .env
volumes: volumes:
- ./credentials.json:/app/credentials.json:ro - ./credentials.json:/app/credentials.json:ro
- ./photos:/app/photos
networks: networks:
- web # внешняя сеть от deploy-стека (Caddy там) - web # внешняя сеть от deploy-стека (Caddy там)
- internal - internal

View File

@ -18,6 +18,9 @@ const Clients = (function () {
if (sub.startsWith("lead/")) { if (sub.startsWith("lead/")) {
const leadId = sub.slice(5); const leadId = sub.slice(5);
renderLead(leadId); renderLead(leadId);
} else if (sub.startsWith("measurement/")) {
const measurementId = sub.slice(12);
renderMeasurement(measurementId);
} else if (sub.startsWith("client/")) { } else if (sub.startsWith("client/")) {
const clientKey = decodeURIComponent(sub.slice(7)); const clientKey = decodeURIComponent(sub.slice(7));
renderClientHistory(clientKey); renderClientHistory(clientKey);
@ -167,14 +170,20 @@ const Clients = (function () {
root.appendChild(el(`<div class="section-head" style="margin-top:24px;"><span class="label">Замеры · ${myMeasurements.length}</span></div>`)); root.appendChild(el(`<div class="section-head" style="margin-top:24px;"><span class="label">Замеры · ${myMeasurements.length}</span></div>`));
const mList = el(`<div class="leads-list"></div>`); const mList = el(`<div class="leads-list"></div>`);
for (const m of myMeasurements) { for (const m of myMeasurements) {
const photoCount = m.photo_count || (m.photos || []).length;
const photoBadge = photoCount ? ` · 📷 ${photoCount}` : "";
const item = el(` const item = el(`
<div class="lead-item" style="cursor:default;"> <button class="lead-item">
<div class="lead-date">${formatDate(m.created_at)}</div> <div class="lead-date">${formatDate(m.created_at)}</div>
<div class="lead-id">${escHtml(layoutLabel(m.layout))}</div> <div class="lead-id">${escHtml(layoutLabel(m.layout))}</div>
<div class="lead-status">${m.area_m2 ? m.area_m2 + " м²" : "—"}</div> <div class="lead-status">${m.area_m2 ? m.area_m2 + " м²" : "—"}${photoBadge}</div>
<div class="lead-arrow"></div> <div class="lead-arrow">${ICONS.chevron || ""}</div>
</div> </button>
`); `);
item.addEventListener("click", () => {
haptic && haptic("impact");
location.hash = `#/clients/measurement/${m.id}`;
});
mList.appendChild(item); mList.appendChild(item);
} }
root.appendChild(mList); root.appendChild(mList);
@ -243,6 +252,94 @@ const Clients = (function () {
} }
} }
/* ===================== Деталь замера ===================== */
async function renderMeasurement(measurementId) {
root.innerHTML = "";
root.appendChild(headerEl("Замер", "back"));
const loading = el(`<div class="loader-inline"><div class="spinner"></div></div>`);
root.appendChild(loading);
let m;
try {
m = await fetchMeasurementDetail(measurementId);
} catch (e) {
loading.remove();
root.appendChild(el(`<div class="error">${e.message}</div>`));
return;
}
loading.remove();
if (m.error) {
root.appendChild(el(`<div class="error">${m.error}</div>`));
return;
}
const walls = m.walls || {};
const wallsText = Object.entries(walls)
.filter(([_, v]) => v)
.map(([k, v]) => `${k.replace("wall", "стена ")}: ${v} мм`)
.join(" · ");
const openings = m.openings || {};
// Шапка + кнопка печати/PDF
root.appendChild(el(`
<div class="measurement-detail-head">
<div class="kicker">Замер #${(m.id || "").slice(0, 8)}</div>
<h2 class="display-title">${escHtml(layoutLabel(m.layout))}</h2>
<div class="measurement-detail-meta">
<span>📅 ${formatDate(m.created_at)}</span>
${m.area_m2 ? `<span>📐 ${escHtml(m.area_m2)} м²</span>` : ""}
${m.ceiling_mm ? `<span>📏 потолок ${escHtml(m.ceiling_mm)} мм</span>` : ""}
</div>
</div>
`));
const printBtn = el(`<button class="report-print-btn">🖨️ Скачать PDF / Печать</button>`);
printBtn.addEventListener("click", () => window.print());
root.appendChild(printBtn);
// Основной блок
const detail = el(`
<div class="block summary-block">
<div class="measurement-kv-grid">
${wallsText ? `<div class="k">Стены</div><div class="v">${escHtml(wallsText)}</div>` : ""}
${openings.window ? `<div class="k">Окно</div><div class="v">${escHtml(openings.window)}</div>` : ""}
${openings.door ? `<div class="k">Дверь</div><div class="v">${escHtml(openings.door)}</div>` : ""}
${m.notes ? `<div class="k">Заметки</div><div class="v">${escHtml(m.notes).replace(/\n/g, "<br>")}</div>` : ""}
</div>
</div>
`);
root.appendChild(detail);
// Фото
const photos = (m.photos || []).filter(Boolean);
if (photos.length) {
root.appendChild(el(`<div class="section-head" style="margin-top:18px;"><span class="label">Фото · ${photos.length}</span></div>`));
const list = el(`<div class="photo-list"></div>`);
for (const fn of photos) {
const url = `${BACKEND_URL}/api/photo/${m.id}/${fn}`;
const tile = el(`
<a class="photo-tile static" href="${url}" target="_blank" rel="noopener">
<img src="${url}" alt="">
</a>
`);
list.appendChild(tile);
}
root.appendChild(list);
}
}
async function fetchMeasurementDetail(measurementId) {
if (!BACKEND_URL) throw new Error("BACKEND_URL не задан");
const res = await fetch(`${BACKEND_URL}/api/measurement_detail`, {
method: "POST",
body: JSON.stringify({ initData: tg?.initData || "", measurement_id: measurementId }),
});
return await res.json();
}
/* ===================== Helpers ===================== */ /* ===================== Helpers ===================== */
function headerEl(title, backHref) { function headerEl(title, backHref) {

View File

@ -4,8 +4,11 @@
const Measurements = (function () { const Measurements = (function () {
const STORAGE_KEY = "zov-measurement-draft"; const STORAGE_KEY = "zov-measurement-draft";
const STEPS = ["client", "layout", "size", "openings", "summary"]; const STEPS = ["client", "layout", "size", "openings", "photos", "summary"];
const STEP_LABELS = ["Клиент", "Форма", "Размеры", "Окна/двери", "Готово"]; const STEP_LABELS = ["Клиент", "Форма", "Размеры", "Окна/двери", "Фото", "Готово"];
// Фото держим только в памяти (data-URL'ы тяжёлые, localStorage не годится)
let photos = []; // Array<{ name: string, dataUrl: string, size: number }>
const LAYOUTS = [ const LAYOUTS = [
{ key: "linear", label: "Прямая", hint: "одна стена", pict: "layout_linear" }, { key: "linear", label: "Прямая", hint: "одна стена", pict: "layout_linear" },
@ -56,6 +59,7 @@ const Measurements = (function () {
function reset() { function reset() {
state = defaultState(); state = defaultState();
saveState(); saveState();
photos = [];
} }
/* ===================== Mount + Render ===================== */ /* ===================== Mount + Render ===================== */
@ -66,6 +70,7 @@ const Measurements = (function () {
const oldNav = document.getElementById("bottom-nav"); const oldNav = document.getElementById("bottom-nav");
if (oldNav) oldNav.remove(); if (oldNav) oldNav.remove();
currentStep = "client"; currentStep = "client";
photos = []; // на старте нового замера — чистый список
render(); render();
} }
@ -90,6 +95,7 @@ const Measurements = (function () {
case "layout": screen.appendChild(renderLayout()); break; case "layout": screen.appendChild(renderLayout()); break;
case "size": screen.appendChild(renderSize()); break; case "size": screen.appendChild(renderSize()); break;
case "openings": screen.appendChild(renderOpenings()); break; case "openings": screen.appendChild(renderOpenings()); break;
case "photos": screen.appendChild(renderPhotos()); break;
case "summary": screen.appendChild(renderSummary()); break; case "summary": screen.appendChild(renderSummary()); break;
} }
} }
@ -322,11 +328,118 @@ const Measurements = (function () {
}); });
}); });
node.querySelector("#back").addEventListener("click", () => go("size")); node.querySelector("#back").addEventListener("click", () => go("size"));
node.querySelector("#next").addEventListener("click", () => go("photos"));
return node;
}
/* ===================== Шаг 5: Фото замера ===================== */
function renderPhotos() {
const node = el(`
<section class="podbor-step">
<h2 class="display-title">Фото<br><span class="accent">кухни</span></h2>
<p class="lede">Сними помещение со всех углов. Минимум: общий вид, окно/дверь, ниши и коммуникации.</p>
<div class="photo-uploader">
<label class="photo-add-btn" for="photoInput">
<span class="photo-add-ico"></span>
<span class="photo-add-label">Добавить фото</span>
<span class="photo-add-hint">камера или галерея · до 12 шт</span>
</label>
<input id="photoInput" type="file" accept="image/*" capture="environment" multiple hidden>
</div>
<div class="photo-list" id="photoList"></div>
<div class="podbor-cta-row">
<button class="btn-secondary" id="back">Назад</button>
<button class="btn-primary" id="next">Дальше</button>
</div>
</section>
`);
const list = node.querySelector("#photoList");
const input = node.querySelector("#photoInput");
function refreshList() {
list.innerHTML = "";
photos.forEach((ph, idx) => {
const tile = el(`
<div class="photo-tile">
<img src="${ph.dataUrl}" alt="фото ${idx + 1}">
<button class="photo-rm" data-idx="${idx}" aria-label="Удалить">×</button>
</div>
`);
tile.querySelector(".photo-rm").addEventListener("click", e => {
const i = +e.currentTarget.dataset.idx;
photos.splice(i, 1);
haptic && haptic("impact");
refreshList();
});
list.appendChild(tile);
});
}
input.addEventListener("change", async (e) => {
const files = Array.from(e.target.files || []);
input.value = ""; // позволяем выбрать тот же файл снова
if (!files.length) return;
for (const f of files) {
if (photos.length >= 12) break;
if (!f.type || !f.type.startsWith("image/")) continue;
try {
const dataUrl = await compressImage(f, 1600, 0.78);
photos.push({ name: f.name || `photo_${photos.length + 1}`, dataUrl, size: dataUrl.length });
} catch (err) {
console.warn("Не удалось сжать фото", err);
}
}
refreshList();
haptic && haptic("success");
});
refreshList();
node.querySelector("#back").addEventListener("click", () => go("openings"));
node.querySelector("#next").addEventListener("click", () => go("summary")); node.querySelector("#next").addEventListener("click", () => go("summary"));
return node; return node;
} }
/* ===================== Шаг 5: Готово + Submit ===================== */ /* Жмём картинку через canvas, возвращаем data-URL jpeg ~75% */
function compressImage(file, maxSide = 1600, quality = 0.78) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = reject;
reader.onload = e => {
const img = new Image();
img.onerror = reject;
img.onload = () => {
let { width, height } = img;
if (width > maxSide || height > maxSide) {
if (width >= height) {
height = Math.round(height * maxSide / width);
width = maxSide;
} else {
width = Math.round(width * maxSide / height);
height = maxSide;
}
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, width, height);
try {
resolve(canvas.toDataURL("image/jpeg", quality));
} catch (err) { reject(err); }
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
});
}
/* ===================== Шаг 6: Готово + Submit ===================== */
function renderSummary() { function renderSummary() {
const layout = LAYOUTS.find(l => l.key === state.layout); const layout = LAYOUTS.find(l => l.key === state.layout);
@ -348,8 +461,15 @@ const Measurements = (function () {
${(state.openings || {}).window ? `<div class="kv"><span>Окно</span><strong>${escHtml(state.openings.window)}</strong></div>` : ""} ${(state.openings || {}).window ? `<div class="kv"><span>Окно</span><strong>${escHtml(state.openings.window)}</strong></div>` : ""}
${(state.openings || {}).door ? `<div class="kv"><span>Дверь</span><strong>${escHtml(state.openings.door)}</strong></div>` : ""} ${(state.openings || {}).door ? `<div class="kv"><span>Дверь</span><strong>${escHtml(state.openings.door)}</strong></div>` : ""}
${state.notes ? `<div class="kv"><span>Заметки</span><strong>${escHtml(state.notes)}</strong></div>` : ""} ${state.notes ? `<div class="kv"><span>Заметки</span><strong>${escHtml(state.notes)}</strong></div>` : ""}
${photos.length ? `<div class="kv"><span>Фото</span><strong>${photos.length} шт</strong></div>` : ""}
</div> </div>
${photos.length ? `
<div class="photo-list">
${photos.map(p => `<div class="photo-tile static"><img src="${p.dataUrl}" alt=""></div>`).join("")}
</div>
` : ""}
<div class="podbor-cta-row"> <div class="podbor-cta-row">
<button class="btn-secondary" id="back">Назад</button> <button class="btn-secondary" id="back">Назад</button>
<button class="btn-primary" id="submitBtn">Сохранить замер</button> <button class="btn-primary" id="submitBtn">Сохранить замер</button>
@ -358,7 +478,7 @@ const Measurements = (function () {
<div id="submitResult" class="submit-result"></div> <div id="submitResult" class="submit-result"></div>
</section> </section>
`); `);
node.querySelector("#back").addEventListener("click", () => go("openings")); node.querySelector("#back").addEventListener("click", () => go("photos"));
node.querySelector("#submitBtn").addEventListener("click", () => onSubmit(node)); node.querySelector("#submitBtn").addEventListener("click", () => onSubmit(node));
return node; return node;
} }
@ -385,7 +505,8 @@ const Measurements = (function () {
openings: state.openings, openings: state.openings,
infra: {}, infra: {},
niches: {}, niches: {},
photos: [], // Бэкенд раскодирует data-URL → файл и сохранит имена в Sheets
photos: photos.map(p => p.dataUrl),
notes: state.notes, notes: state.notes,
// Контакт клиента — заносим в заметки если он не зарегистрирован в системе // Контакт клиента — заносим в заметки если он не зарегистрирован в системе
client_name: state.client_name, client_name: state.client_name,

View File

@ -1988,3 +1988,169 @@
border-radius: 8px; border-radius: 8px;
overflow-x: auto; overflow-x: auto;
} }
/* ===== Фото замера ===== */
.photo-uploader { margin: 12px 0 14px; }
.photo-add-btn {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: var(--warm);
border: 1px dashed var(--walnut);
border-radius: 12px;
cursor: pointer;
transition: background 0.15s;
user-select: none;
}
.photo-add-btn:active { background: rgba(107, 74, 43, 0.08); }
.photo-add-ico {
font-size: 24px;
font-weight: 300;
color: var(--walnut);
line-height: 1;
}
.photo-add-label {
flex: 1;
font-weight: 500;
color: var(--ink);
}
.photo-add-hint {
font-size: 11px;
color: var(--muted);
font-family: var(--font-mono);
}
.photo-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
gap: 8px;
margin: 12px 0 16px;
}
.photo-tile {
position: relative;
aspect-ratio: 1 / 1;
border-radius: 10px;
overflow: hidden;
background: var(--warm);
border: 1px solid rgba(107, 74, 43, 0.15);
}
.photo-tile img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.photo-rm {
position: absolute;
top: 4px;
right: 4px;
width: 22px;
height: 22px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.55);
color: white;
font-size: 16px;
line-height: 1;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.photo-rm:active { background: rgba(0, 0, 0, 0.75); }
.photo-tile.static { cursor: default; }
/* ===== Карточка замера (детальная страница) ===== */
.measurement-detail-head {
margin-bottom: 18px;
}
.measurement-detail-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--muted);
margin-top: 6px;
}
.measurement-kv-grid {
display: grid;
gap: 10px 16px;
grid-template-columns: minmax(120px, 1fr) 2fr;
margin: 14px 0 18px;
font-size: 14px;
}
.measurement-kv-grid .k {
color: var(--muted);
font-family: var(--font-mono);
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.measurement-kv-grid .v { color: var(--ink); }
/* ===== Кнопка "Скачать PDF" / "Печать" ===== */
.report-print-btn {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 12px 0;
padding: 10px 16px;
background: var(--warm);
border: 1px solid var(--walnut);
border-radius: 10px;
color: var(--walnut);
font-weight: 500;
cursor: pointer;
font-size: 13.5px;
}
.report-print-btn:active { background: rgba(107, 74, 43, 0.12); }
/* ===== Печать / PDF ===== */
@media print {
body { background: white !important; color: black !important; }
body.has-bottom-nav, #bottom-nav,
.podbor-header,
.podbor-progress,
.podbor-cta-row,
.podbor-back,
.report-actions,
.report-print-btn,
.util-link,
.photo-add-btn,
.photo-uploader,
.photo-rm,
.btn-primary,
.btn-secondary {
display: none !important;
}
.podbor-screen,
.podbor-step,
.block,
.summary-block,
.client-detail-head,
.lead-detail-head,
.measurement-detail-head {
box-shadow: none !important;
border: none !important;
background: white !important;
page-break-inside: avoid;
break-inside: avoid;
}
.display-title { color: black !important; }
.display-title .accent { color: #6B4A2B !important; }
.photo-list {
grid-template-columns: repeat(3, 1fr);
gap: 6px;
page-break-inside: avoid;
break-inside: avoid;
}
.photo-tile { border: 1px solid #ccc; }
.report-matrix table { font-size: 10pt; }
a { color: black !important; text-decoration: none !important; }
/* Скрываем стрелки и иконки навигации */
.lead-arrow, .client-arrow { display: none !important; }
}

View File

@ -1425,15 +1425,18 @@ ${reportEl.outerHTML}
haptic && haptic("success"); haptic && haptic("success");
} }
/* Экспорт: открываем системный print диалог — пользователь сохраняет как PDF */ /* Экспорт: системный print-диалог. На Telegram WebApp popups часто блокируются,
поэтому печатаем inline `@media print` в podbor.css скрывает всё лишнее. */
function _exportReportPrint(reportNode, leadId) { function _exportReportPrint(reportNode, leadId) {
// Открываем новое окно с чистой версткой отчёта только const isTelegram = !!(window.Telegram && window.Telegram.WebApp);
// Сначала пробуем открыть отдельное окно (на десктопе/Safari чище)
if (!isTelegram) {
const reportEl = document.querySelector(".report"); const reportEl = document.querySelector(".report");
if (!reportEl) return; if (reportEl) {
const stylesheets = []; const stylesheets = [];
document.querySelectorAll("link[rel='stylesheet'], style").forEach(s => stylesheets.push(s.outerHTML)); document.querySelectorAll("link[rel='stylesheet'], style").forEach(s => stylesheets.push(s.outerHTML));
const w = window.open("", "_blank"); const w = window.open("", "_blank");
if (!w) { alert("Браузер заблокировал окно. Разрешите всплывающие окна."); return; } if (w) {
w.document.write(`<!doctype html> w.document.write(`<!doctype html>
<html lang="ru"> <html lang="ru">
<head> <head>
@ -1458,6 +1461,17 @@ ${reportEl.outerHTML}
</html>`); </html>`);
w.document.close(); w.document.close();
haptic && haptic("success"); haptic && haptic("success");
return;
}
}
}
// Inline-печать: помечаем body классом, чтобы скрыть ненужное через @media print
document.body.classList.add("printing-report");
setTimeout(() => {
try { window.print(); } catch (e) { console.warn("Print failed", e); }
document.body.classList.remove("printing-report");
}, 50);
haptic && haptic("success");
} }
function _renderModelCard(m) { function _renderModelCard(m) {

View File

@ -12,8 +12,8 @@
<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=Inter:wght@400;500;600;700&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&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=20260512c"> <link rel="stylesheet" href="assets/styles.css?v=20260513a">
<link rel="stylesheet" href="assets/podbor.css?v=20260512c"> <link rel="stylesheet" href="assets/podbor.css?v=20260513a">
</head> </head>
<body> <body>
<main id="app"> <main id="app">
@ -21,12 +21,12 @@
<div class="spinner"></div> <div class="spinner"></div>
</div> </div>
</main> </main>
<script src="assets/icons.js?v=20260512c"></script> <script src="assets/icons.js?v=20260513a"></script>
<script src="assets/podbor.config.js?v=20260512c"></script> <script src="assets/podbor.config.js?v=20260513a"></script>
<script src="assets/podbor.picts.js?v=20260512c"></script> <script src="assets/podbor.picts.js?v=20260513a"></script>
<script src="assets/podbor.js?v=20260512c"></script> <script src="assets/podbor.js?v=20260513a"></script>
<script src="assets/clients.js?v=20260512c"></script> <script src="assets/clients.js?v=20260513a"></script>
<script src="assets/measurements.js?v=20260512c"></script> <script src="assets/measurements.js?v=20260513a"></script>
<script src="assets/app.js?v=20260512c"></script> <script src="assets/app.js?v=20260513a"></script>
</body> </body>
</html> </html>