mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 15:04:50 +00:00
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:
parent
10bcc75b13
commit
a084542bbf
@ -1,13 +1,17 @@
|
||||
"""ЗОВ Backend — FastAPI app. Полный порт Apps Script Code.gs."""
|
||||
from __future__ import annotations
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
from .config import get_config
|
||||
from .auth import verify_init_data
|
||||
@ -20,6 +24,16 @@ log = logging.getLogger("zov.backend")
|
||||
|
||||
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.
|
||||
# Простые запросы (text/plain или без Content-Type) не триггерят preflight.
|
||||
app.add_middleware(
|
||||
@ -87,6 +101,7 @@ async def _dispatch_post(request: Request):
|
||||
"me": _handle_me,
|
||||
"measurement": _handle_measurement,
|
||||
"measurements": _handle_measurements_list,
|
||||
"measurement_detail": _handle_measurement_detail,
|
||||
"podbor": _handle_podbor,
|
||||
"clients": _handle_clients,
|
||||
"lead": _handle_lead,
|
||||
@ -156,6 +171,33 @@ async def api_measurements(request: Request):
|
||||
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")
|
||||
async def api_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]:
|
||||
cfg = get_config()
|
||||
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:
|
||||
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", [
|
||||
measurement_id, _now_iso(), client_tg_id or "", manager_tg_id or "",
|
||||
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("infra") or {}, ensure_ascii=False),
|
||||
json.dumps(m.get("niches") or {}, ensure_ascii=False),
|
||||
",".join(m.get("photos") or []),
|
||||
",".join(saved_photos),
|
||||
notes_full,
|
||||
"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})
|
||||
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]:
|
||||
@ -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]:
|
||||
"""Список замеров менеджера, опционально отфильтрованный по client_tg_id / client_name."""
|
||||
cfg = get_config()
|
||||
@ -714,6 +855,7 @@ def _handle_measurements_list(body: dict[str, Any]) -> dict[str, Any]:
|
||||
# Из notes / других JSON-полей вытащим client_name если был передан в measurement
|
||||
# (он не сохраняется в отдельной колонке — только в JSON-обвязке)
|
||||
# Для MVP — фильтр по имени делаем после парсинга JSON-полей
|
||||
photo_files = [p for p in (row.get("photos") or "").split(",") if p]
|
||||
out.append({
|
||||
"id": row.get("id", ""),
|
||||
"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", ""),
|
||||
"notes": row.get("notes", ""),
|
||||
"status": row.get("status", ""),
|
||||
"photos": photo_files,
|
||||
"photo_count": len(photo_files),
|
||||
})
|
||||
|
||||
# Сортируем по дате desc
|
||||
|
||||
@ -10,6 +10,7 @@ services:
|
||||
- .env
|
||||
volumes:
|
||||
- ./credentials.json:/app/credentials.json:ro
|
||||
- ./photos:/app/photos
|
||||
networks:
|
||||
- web # внешняя сеть от deploy-стека (Caddy там)
|
||||
- internal
|
||||
|
||||
@ -18,6 +18,9 @@ const Clients = (function () {
|
||||
if (sub.startsWith("lead/")) {
|
||||
const leadId = sub.slice(5);
|
||||
renderLead(leadId);
|
||||
} else if (sub.startsWith("measurement/")) {
|
||||
const measurementId = sub.slice(12);
|
||||
renderMeasurement(measurementId);
|
||||
} else if (sub.startsWith("client/")) {
|
||||
const clientKey = decodeURIComponent(sub.slice(7));
|
||||
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>`));
|
||||
const mList = el(`<div class="leads-list"></div>`);
|
||||
for (const m of myMeasurements) {
|
||||
const photoCount = m.photo_count || (m.photos || []).length;
|
||||
const photoBadge = photoCount ? ` · 📷 ${photoCount}` : "";
|
||||
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-id">${escHtml(layoutLabel(m.layout))}</div>
|
||||
<div class="lead-status">${m.area_m2 ? m.area_m2 + " м²" : "—"}</div>
|
||||
<div class="lead-arrow"></div>
|
||||
</div>
|
||||
<div class="lead-status">${m.area_m2 ? m.area_m2 + " м²" : "—"}${photoBadge}</div>
|
||||
<div class="lead-arrow">${ICONS.chevron || "›"}</div>
|
||||
</button>
|
||||
`);
|
||||
item.addEventListener("click", () => {
|
||||
haptic && haptic("impact");
|
||||
location.hash = `#/clients/measurement/${m.id}`;
|
||||
});
|
||||
mList.appendChild(item);
|
||||
}
|
||||
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 ===================== */
|
||||
|
||||
function headerEl(title, backHref) {
|
||||
|
||||
@ -4,8 +4,11 @@
|
||||
|
||||
const Measurements = (function () {
|
||||
const STORAGE_KEY = "zov-measurement-draft";
|
||||
const STEPS = ["client", "layout", "size", "openings", "summary"];
|
||||
const STEP_LABELS = ["Клиент", "Форма", "Размеры", "Окна/двери", "Готово"];
|
||||
const STEPS = ["client", "layout", "size", "openings", "photos", "summary"];
|
||||
const STEP_LABELS = ["Клиент", "Форма", "Размеры", "Окна/двери", "Фото", "Готово"];
|
||||
|
||||
// Фото держим только в памяти (data-URL'ы тяжёлые, localStorage не годится)
|
||||
let photos = []; // Array<{ name: string, dataUrl: string, size: number }>
|
||||
|
||||
const LAYOUTS = [
|
||||
{ key: "linear", label: "Прямая", hint: "одна стена", pict: "layout_linear" },
|
||||
@ -56,6 +59,7 @@ const Measurements = (function () {
|
||||
function reset() {
|
||||
state = defaultState();
|
||||
saveState();
|
||||
photos = [];
|
||||
}
|
||||
|
||||
/* ===================== Mount + Render ===================== */
|
||||
@ -66,6 +70,7 @@ const Measurements = (function () {
|
||||
const oldNav = document.getElementById("bottom-nav");
|
||||
if (oldNav) oldNav.remove();
|
||||
currentStep = "client";
|
||||
photos = []; // на старте нового замера — чистый список
|
||||
render();
|
||||
}
|
||||
|
||||
@ -90,6 +95,7 @@ const Measurements = (function () {
|
||||
case "layout": screen.appendChild(renderLayout()); break;
|
||||
case "size": screen.appendChild(renderSize()); break;
|
||||
case "openings": screen.appendChild(renderOpenings()); break;
|
||||
case "photos": screen.appendChild(renderPhotos()); break;
|
||||
case "summary": screen.appendChild(renderSummary()); break;
|
||||
}
|
||||
}
|
||||
@ -322,11 +328,118 @@ const Measurements = (function () {
|
||||
});
|
||||
});
|
||||
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"));
|
||||
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() {
|
||||
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 || {}).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>` : ""}
|
||||
${photos.length ? `<div class="kv"><span>Фото</span><strong>${photos.length} шт</strong></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">
|
||||
<button class="btn-secondary" id="back">Назад</button>
|
||||
<button class="btn-primary" id="submitBtn">Сохранить замер</button>
|
||||
@ -358,7 +478,7 @@ const Measurements = (function () {
|
||||
<div id="submitResult" class="submit-result"></div>
|
||||
</section>
|
||||
`);
|
||||
node.querySelector("#back").addEventListener("click", () => go("openings"));
|
||||
node.querySelector("#back").addEventListener("click", () => go("photos"));
|
||||
node.querySelector("#submitBtn").addEventListener("click", () => onSubmit(node));
|
||||
return node;
|
||||
}
|
||||
@ -385,7 +505,8 @@ const Measurements = (function () {
|
||||
openings: state.openings,
|
||||
infra: {},
|
||||
niches: {},
|
||||
photos: [],
|
||||
// Бэкенд раскодирует data-URL → файл и сохранит имена в Sheets
|
||||
photos: photos.map(p => p.dataUrl),
|
||||
notes: state.notes,
|
||||
// Контакт клиента — заносим в заметки если он не зарегистрирован в системе
|
||||
client_name: state.client_name,
|
||||
|
||||
@ -1988,3 +1988,169 @@
|
||||
border-radius: 8px;
|
||||
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; }
|
||||
}
|
||||
|
||||
@ -1425,16 +1425,19 @@ ${reportEl.outerHTML}
|
||||
haptic && haptic("success");
|
||||
}
|
||||
|
||||
/* Экспорт: открываем системный print диалог — пользователь сохраняет как PDF */
|
||||
/* Экспорт: системный print-диалог. На Telegram WebApp popups часто блокируются,
|
||||
поэтому печатаем inline — `@media print` в podbor.css скрывает всё лишнее. */
|
||||
function _exportReportPrint(reportNode, leadId) {
|
||||
// Открываем новое окно с чистой версткой отчёта только
|
||||
const reportEl = document.querySelector(".report");
|
||||
if (!reportEl) return;
|
||||
const stylesheets = [];
|
||||
document.querySelectorAll("link[rel='stylesheet'], style").forEach(s => stylesheets.push(s.outerHTML));
|
||||
const w = window.open("", "_blank");
|
||||
if (!w) { alert("Браузер заблокировал окно. Разрешите всплывающие окна."); return; }
|
||||
w.document.write(`<!doctype html>
|
||||
const isTelegram = !!(window.Telegram && window.Telegram.WebApp);
|
||||
// Сначала пробуем открыть отдельное окно (на десктопе/Safari чище)
|
||||
if (!isTelegram) {
|
||||
const reportEl = document.querySelector(".report");
|
||||
if (reportEl) {
|
||||
const stylesheets = [];
|
||||
document.querySelectorAll("link[rel='stylesheet'], style").forEach(s => stylesheets.push(s.outerHTML));
|
||||
const w = window.open("", "_blank");
|
||||
if (w) {
|
||||
w.document.write(`<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
@ -1456,7 +1459,18 @@ ${reportEl.outerHTML}
|
||||
<script>setTimeout(() => window.print(), 500);<\/script>
|
||||
</body>
|
||||
</html>`);
|
||||
w.document.close();
|
||||
w.document.close();
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
@ -12,8 +12,8 @@
|
||||
<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">
|
||||
<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/podbor.css?v=20260512c">
|
||||
<link rel="stylesheet" href="assets/styles.css?v=20260513a">
|
||||
<link rel="stylesheet" href="assets/podbor.css?v=20260513a">
|
||||
</head>
|
||||
<body>
|
||||
<main id="app">
|
||||
@ -21,12 +21,12 @@
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</main>
|
||||
<script src="assets/icons.js?v=20260512c"></script>
|
||||
<script src="assets/podbor.config.js?v=20260512c"></script>
|
||||
<script src="assets/podbor.picts.js?v=20260512c"></script>
|
||||
<script src="assets/podbor.js?v=20260512c"></script>
|
||||
<script src="assets/clients.js?v=20260512c"></script>
|
||||
<script src="assets/measurements.js?v=20260512c"></script>
|
||||
<script src="assets/app.js?v=20260512c"></script>
|
||||
<script src="assets/icons.js?v=20260513a"></script>
|
||||
<script src="assets/podbor.config.js?v=20260513a"></script>
|
||||
<script src="assets/podbor.picts.js?v=20260513a"></script>
|
||||
<script src="assets/podbor.js?v=20260513a"></script>
|
||||
<script src="assets/clients.js?v=20260513a"></script>
|
||||
<script src="assets/measurements.js?v=20260513a"></script>
|
||||
<script src="assets/app.js?v=20260513a"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user