diff --git a/backend-py/app/main.py b/backend-py/app/main.py index 8a48789..d3af753 100644 --- a/backend-py/app/main.py +++ b/backend-py/app/main.py @@ -119,6 +119,7 @@ async def _dispatch_post(request: Request): "client_update": _handle_client_update, "client_delete": _handle_client_delete, "measurement_design_upload": _handle_measurement_design_upload, + "measurement_add_photos": _handle_measurement_add_photos, "measurement_decision": _handle_measurement_decision, "measurement_set_status": _handle_measurement_set_status, "manager_pending": _handle_manager_pending, @@ -275,6 +276,12 @@ async def api_measurement_set_status(request: Request): return _handle_measurement_set_status(body) +@app.post("/api/measurement_add_photos") +async def api_measurement_add_photos(request: Request): + body = await _safe_json(request) + return _handle_measurement_add_photos(body) + + @app.post("/api/manager_pending") async def api_manager_pending(request: Request): body = await _safe_json(request) @@ -1755,6 +1762,63 @@ def _handle_measurement_decision(body: dict[str, Any]) -> dict[str, Any]: return {"ok": True, "id": measurement_id, "decision": decision} +def _handle_measurement_add_photos(body: dict[str, Any]) -> dict[str, Any]: + """Дозагрузка фото к существующему замеру. + body: {initData, measurement_id, photos: [{data_url, label?}, ...]} + label: before | after | general | extra (необязательно).""" + cfg = get_config() + auth = verify_init_data(body.get("initData") or "", cfg.bot_token) + if not auth or not auth.get("user"): + unsafe = body.get("initDataUnsafe") or {} + if isinstance(unsafe, dict) and unsafe.get("user", {}).get("id"): + auth = {"user": unsafe["user"]} + else: + return {"error": "invalid_init_data"} + tg_id = auth["user"]["id"] + user = sheets.find_user(tg_id) + if not user: + return {"error": "user_not_found"} + + measurement_id = (body.get("measurement_id") or "").strip() + if not measurement_id or not _SAFE_ID_RE.match(measurement_id): + return {"error": "missing_measurement_id"} + + row = sheets.find_row("Measurements", "id", measurement_id) + if not row: + return {"error": "measurement_not_found"} + + is_owner = (str(row.get("manager_tg_id")) == str(tg_id) + or str(row.get("requested_by_tg_id")) == str(tg_id) + or str(row.get("assigned_to_tg_id")) == str(tg_id)) + if not is_owner and not sheets.has_role(user, "manager"): + return {"error": "forbidden"} + + photos_input = body.get("photos") or [] + if not isinstance(photos_input, list) or not photos_input: + return {"error": "no_photos"} + + existing = [p for p in (row.get("photos") or "").split(",") if p] + saved: list[str] = [] + for i, p in enumerate(photos_input[:20]): + if not isinstance(p, dict): + continue + data_url = p.get("data_url") or "" + label = (p.get("label") or "extra").strip() + if not isinstance(data_url, str) or not data_url.startswith("data:"): + continue + fn = _save_measurement_photo(measurement_id, len(existing) + i, data_url, kind=label) + if fn: + saved.append(fn) + + if not saved: + return {"error": "no_photos_saved"} + + all_photos = existing + saved + sheets.update_cell_by_key("Measurements", "id", measurement_id, "photos", ",".join(all_photos)) + sheets.log_event("measurement_photos_added", tg_id, {"id": measurement_id, "count": len(saved)}) + return {"ok": True, "id": measurement_id, "saved": saved, "total": len(all_photos)} + + def _handle_measurement_set_status(body: dict[str, Any]) -> dict[str, Any]: """Менеджер меняет статус замера из карточки. body: {initData, measurement_id, status} diff --git a/miniapp/assets/clients.js b/miniapp/assets/clients.js index b9128d7..23150e7 100644 --- a/miniapp/assets/clients.js +++ b/miniapp/assets/clients.js @@ -1326,10 +1326,111 @@ const Clients = (function () { root.appendChild(list); } + // Фото: загрузка дополнительных фото + root.appendChild(renderPhotoUploadBlock(m)); + // Чертежи / DWG root.appendChild(renderDesignFilesBlock(m)); } + /* ===================== Загрузка фото замера ===================== */ + + function renderPhotoUploadBlock(m) { + const section = el(` +
+
📷 Фото${m.photos && m.photos.length ? ` · уже ${m.photos.length} шт.` : ""}
+
+ + + +
+
+ `); + + const previewGrid = section.querySelector("#photoPreviewGrid"); + const fileInput = section.querySelector("#photoFileInput"); + const actionsRow = section.querySelector("#photoUploadActions"); + const uploadBtn = section.querySelector("#photoUploadBtn"); + const clearBtn = section.querySelector("#photoClearBtn"); + const statusEl = section.querySelector("#photoUploadStatus"); + let pendingFiles = []; + + fileInput.addEventListener("change", () => { + pendingFiles = Array.from(fileInput.files || []); + previewGrid.innerHTML = ""; + if (!pendingFiles.length) { actionsRow.style.display = "none"; return; } + actionsRow.style.display = ""; + pendingFiles.forEach(f => { + const url = URL.createObjectURL(f); + const tile = el(`
`); + previewGrid.appendChild(tile); + }); + }); + + clearBtn.addEventListener("click", () => { + pendingFiles = []; + fileInput.value = ""; + previewGrid.innerHTML = ""; + actionsRow.style.display = "none"; + statusEl.textContent = ""; + }); + + uploadBtn.addEventListener("click", async () => { + if (!pendingFiles.length) return; + uploadBtn.disabled = true; uploadBtn.textContent = `Загружаем (0/${pendingFiles.length})…`; + statusEl.textContent = ""; + const photos = []; + for (let i = 0; i < pendingFiles.length; i++) { + const f = pendingFiles[i]; + const dataUrl = await new Promise((res, rej) => { + const r = new FileReader(); + r.onload = () => res(r.result); + r.onerror = rej; + r.readAsDataURL(f); + }); + photos.push({ data_url: dataUrl, label: "extra" }); + uploadBtn.textContent = `Загружаем (${i + 1}/${pendingFiles.length})…`; + } + try { + const res = await fetch(`${BACKEND_URL}/api/measurement_add_photos`, { + method: "POST", + body: JSON.stringify({ + initData: tg?.initData || "", + initDataUnsafe: tg?.initDataUnsafe || null, + measurement_id: m.id, + photos, + }), + }); + const data = await res.json(); + if (data.ok) { + haptic && haptic("success"); + statusEl.innerHTML = `✓ Загружено ${data.saved.length} фото. Всего: ${data.total}`; + pendingFiles = []; fileInput.value = ""; + previewGrid.innerHTML = ""; + actionsRow.style.display = "none"; + uploadBtn.disabled = false; uploadBtn.textContent = "Загрузить фото"; + // Обновляем заголовок блока + section.querySelector(".block-head").textContent = `📷 Фото · всего ${data.total} шт.`; + } else { + statusEl.innerHTML = `Ошибка: ${escHtml(data.msg || data.error)}`; + uploadBtn.disabled = false; uploadBtn.textContent = "Загрузить фото"; + } + } catch (e) { + statusEl.innerHTML = `Сеть: ${escHtml(e.message)}`; + uploadBtn.disabled = false; uploadBtn.textContent = "Загрузить фото"; + } + }); + + return section; + } + /* ===================== Смена статуса замера ===================== */ async function setMeasurementStatus(measurementId, newStatus, container) { diff --git a/miniapp/assets/podbor.css b/miniapp/assets/podbor.css index d879066..89eb0d4 100644 --- a/miniapp/assets/podbor.css +++ b/miniapp/assets/podbor.css @@ -3412,6 +3412,21 @@ .geo-ok { color: #27AE60; } .geo-warn { color: #C0392B; } +/* ===== Загрузка фото замера ===== */ +.photo-upload-block {} +.photo-preview-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + margin-top: 6px; +} +.photo-upload-actions { + display: flex; + gap: 8px; +} +.photo-upload-actions .btn-primary { flex: 2; } +.photo-upload-actions .btn-secondary { flex: 1; } + /* ===== Статус замера ===== */ .mz-status-badge { font-size: 12px; diff --git a/miniapp/index.html b/miniapp/index.html index 2dc3164..3b9a6cb 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -12,14 +12,14 @@ - - + +
- +
- - - - - - - - - - + + + + + + + + + +