feat: загрузка фото к замеру из карточки менеджера

- backend: _handle_measurement_add_photos — дозагрузка фото (до 20 шт за раз)
  сохраняет в PHOTOS_DIR, дописывает в колонку photos через update_cell_by_key
- clients.js: renderPhotoUploadBlock — выбор файлов, превью сетка 3 колонки,
  конвертация в dataURL, POST /api/measurement_add_photos, счётчик в заголовке
- podbor.css: .photo-upload-block, .photo-preview-grid, .photo-upload-actions
- index.html: cache bump → v=20260514p

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
wasrusgen 2026-05-15 22:44:45 +03:00
parent 5186afe0e0
commit 7a25ee3d36
4 changed files with 193 additions and 13 deletions

View File

@ -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}

View File

@ -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(`
<section class="block photo-upload-block">
<div class="block-head">📷 Фото${m.photos && m.photos.length ? ` · уже ${m.photos.length} шт.` : ""}</div>
<div class="photo-preview-grid" id="photoPreviewGrid"></div>
<label class="design-upload-label" style="margin-top:8px;">
Добавить фото (до/после/общий план)
</label>
<input type="file" class="design-upload-input" id="photoFileInput"
accept="image/jpeg,image/png,image/webp,image/heic,image/*"
multiple capture="environment">
<div class="photo-upload-actions" id="photoUploadActions" style="display:none;margin-top:10px;">
<button class="btn-primary" id="photoUploadBtn" type="button">Загрузить фото</button>
<button class="btn-secondary" id="photoClearBtn" type="button">Очистить</button>
</div>
<div class="design-upload-status" id="photoUploadStatus"></div>
</section>
`);
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(`<div class="photo-tile static"><img src="${url}" alt=""></div>`);
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 = `<span class="ct-ok">✓ Загружено ${data.saved.length} фото. Всего: ${data.total}</span>`;
pendingFiles = []; fileInput.value = "";
previewGrid.innerHTML = "";
actionsRow.style.display = "none";
uploadBtn.disabled = false; uploadBtn.textContent = "Загрузить фото";
// Обновляем заголовок блока
section.querySelector(".block-head").textContent = `📷 Фото · всего ${data.total} шт.`;
} else {
statusEl.innerHTML = `<span class="ct-err">Ошибка: ${escHtml(data.msg || data.error)}</span>`;
uploadBtn.disabled = false; uploadBtn.textContent = "Загрузить фото";
}
} catch (e) {
statusEl.innerHTML = `<span class="ct-err">Сеть: ${escHtml(e.message)}</span>`;
uploadBtn.disabled = false; uploadBtn.textContent = "Загрузить фото";
}
});
return section;
}
/* ===================== Смена статуса замера ===================== */
async function setMeasurementStatus(measurementId, newStatus, container) {

View File

@ -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;

View File

@ -12,14 +12,14 @@
<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;800&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&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>
<link rel="stylesheet" href="assets/styles.css?v=20260514o">
<link rel="stylesheet" href="assets/podbor.css?v=20260514o">
<link rel="stylesheet" href="assets/styles.css?v=20260514p">
<link rel="stylesheet" href="assets/podbor.css?v=20260514p">
</head>
<body>
<!-- Splash — лого @wasrusgen1 + опилки (16) + вращающийся диск -->
<div class="loader splash" id="splash">
<div class="brand-logo-wrap">
<img class="brand-logo" src="assets/wasrusgen-logo.svg?v=20260514o" alt="@wasrusgen1">
<img class="brand-logo" src="assets/wasrusgen-logo.svg?v=20260514p" alt="@wasrusgen1">
<div class="splash-dust" aria-hidden="true">
<span class="dust d1"></span> <span class="dust d2"></span>
<span class="dust d3"></span> <span class="dust d4"></span>
@ -35,15 +35,15 @@
<div class="brand-tagline-gold">CRM</div>
</div>
<main id="app"></main>
<script src="assets/icons.js?v=20260514o"></script>
<script src="assets/podbor.config.js?v=20260514o"></script>
<script src="assets/podbor.picts.js?v=20260514o"></script>
<script src="assets/podbor.js?v=20260514o"></script>
<script src="assets/clients.js?v=20260514o"></script>
<script src="assets/zamer-picts.js?v=20260514o"></script>
<script src="assets/measurements.js?v=20260514o"></script>
<script src="assets/request.js?v=20260514o"></script>
<script src="assets/assembly.js?v=20260514o"></script>
<script src="assets/app.js?v=20260514o"></script>
<script src="assets/icons.js?v=20260514p"></script>
<script src="assets/podbor.config.js?v=20260514p"></script>
<script src="assets/podbor.picts.js?v=20260514p"></script>
<script src="assets/podbor.js?v=20260514p"></script>
<script src="assets/clients.js?v=20260514p"></script>
<script src="assets/zamer-picts.js?v=20260514p"></script>
<script src="assets/measurements.js?v=20260514p"></script>
<script src="assets/request.js?v=20260514p"></script>
<script src="assets/assembly.js?v=20260514p"></script>
<script src="assets/app.js?v=20260514p"></script>
</body>
</html>