mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 17:04:48 +00:00
B+E: DWG/чертежи + карточка «Замер готов — нужен подбор?»
Backend (main.py): - Колонки в Measurements: design_files, podbor_decision, podbor_decision_at, podbor_lead_id - _save_design_file() сохраняет DWG/DXF/PDF/PNG/JPG в PHOTOS_DIR/<id>/design_*.ext - POST /api/measurement_design_upload — загрузка чертежей (до 10 файлов, 30МБ каждый) - POST /api/measurement_decision — фиксация решения (needed|not_needed|later|done) - POST /api/manager_pending — список завершённых замеров без решения про подбор - /api/photo расширен MIME-типами pdf/dwg/dxf - /api/measurement_detail отдаёт design_files, podbor_decision, podbor_lead_id Frontend (app.js + clients.js + podbor.css): - На главной менеджера: карточки «✅ Замеры готовы» с вопросом «Клиенту потребуется помощь с подбором техники?» и кнопками Да/Нет/Позже - «Да» → переход в #/podbor с pre-fill клиента - «Нет/Позже» → фиксация решения, анимация удаления карточки - В карточке замера (renderMeasurement) — секция «📐 Чертёж / DWG» с inline-загрузкой файлов и списком прикреплённых чертежей - index.html: cache bump v=20260514b
This commit is contained in:
parent
34b83899b5
commit
5e6746e676
@ -117,6 +117,9 @@ async def _dispatch_post(request: Request):
|
||||
"client_note": _handle_client_note,
|
||||
"client_create": _handle_client_create,
|
||||
"client_delete": _handle_client_delete,
|
||||
"measurement_design_upload": _handle_measurement_design_upload,
|
||||
"measurement_decision": _handle_measurement_decision,
|
||||
"manager_pending": _handle_manager_pending,
|
||||
"ping": lambda b: {"pong": True, "time": _now_iso()},
|
||||
"seed_admin": lambda b: _handle_seed_admin(),
|
||||
"test_ai": lambda b: _handle_test_ai(),
|
||||
@ -243,6 +246,24 @@ async def api_client_delete(request: Request):
|
||||
return _handle_client_delete(body)
|
||||
|
||||
|
||||
@app.post("/api/measurement_design_upload")
|
||||
async def api_measurement_design_upload(request: Request):
|
||||
body = await _safe_json(request)
|
||||
return _handle_measurement_design_upload(body)
|
||||
|
||||
|
||||
@app.post("/api/measurement_decision")
|
||||
async def api_measurement_decision(request: Request):
|
||||
body = await _safe_json(request)
|
||||
return _handle_measurement_decision(body)
|
||||
|
||||
|
||||
@app.post("/api/manager_pending")
|
||||
async def api_manager_pending(request: Request):
|
||||
body = await _safe_json(request)
|
||||
return _handle_manager_pending(body)
|
||||
|
||||
|
||||
@app.post("/api/grant_role")
|
||||
async def api_grant_role(request: Request):
|
||||
"""Админ выдаёт роль другому пользователю.
|
||||
@ -274,7 +295,13 @@ async def api_photo(measurement_id: str, filename: str):
|
||||
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")
|
||||
media = {
|
||||
"jpg": "image/jpeg", "jpeg": "image/jpeg",
|
||||
"png": "image/png", "webp": "image/webp",
|
||||
"pdf": "application/pdf",
|
||||
"dwg": "application/acad",
|
||||
"dxf": "application/dxf",
|
||||
}.get(ext, "application/octet-stream")
|
||||
return FileResponse(str(p), media_type=media)
|
||||
|
||||
|
||||
@ -657,6 +684,12 @@ def _measurement_columns() -> list[str]:
|
||||
"client_no", "contract_no", "contract_date",
|
||||
# Soft-delete
|
||||
"archived_at",
|
||||
# Чертёж/документы — DWG, PDF, PNG превью (B)
|
||||
"design_files",
|
||||
# Решение менеджера про подбор техники после замера (E)
|
||||
# podbor_decision: pending | needed | not_needed | later | done
|
||||
# podbor_decision_at — когда зафиксировано решение
|
||||
"podbor_decision", "podbor_decision_at", "podbor_lead_id",
|
||||
]
|
||||
|
||||
|
||||
@ -694,6 +727,8 @@ def _row_for_measurement(measurement_id: str, ts: str, **fields) -> list[str]:
|
||||
"gcal_event_id": "", "gcal_event_url": "",
|
||||
"client_no": "", "contract_no": "", "contract_date": "",
|
||||
"archived_at": "",
|
||||
"design_files": "",
|
||||
"podbor_decision": "", "podbor_decision_at": "", "podbor_lead_id": "",
|
||||
}
|
||||
base.update(fields)
|
||||
return [str(base.get(c, "")) for c in cols]
|
||||
@ -859,8 +894,9 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]:
|
||||
f"✅ <b>Замер выполнен</b>\n"
|
||||
f"Клиент: <b>{client_name or '—'}</b>\n"
|
||||
f"Замерщик: {user.get('full_name') or tg_id}\n"
|
||||
f"Фото: {len(saved_photos)} шт · площадь {m.get('area_m2') or '—'} м²\n\n"
|
||||
f"Откройте кабинет — можно запускать подбор техники."
|
||||
f"Фото: {len(saved_photos)} шт\n\n"
|
||||
f"❓ <b>Клиенту потребуется помощь с подбором техники?</b>\n"
|
||||
f"Откройте кабинет — на главной появится карточка с этим вопросом."
|
||||
)
|
||||
elif filled_by == "client_self" and manager_tg_id:
|
||||
tg.send_message(
|
||||
@ -1474,6 +1510,223 @@ def _handle_measurement_logistics(body: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"ok": True, "id": measurement_id, "logistics": updates}
|
||||
|
||||
|
||||
_DESIGN_DATA_URL_RE = re.compile(r"^data:([\w/\-+.]+);base64,(.+)$", re.DOTALL)
|
||||
_DESIGN_ALLOWED_EXT = {"dwg", "dxf", "pdf", "png", "jpg", "jpeg", "webp"}
|
||||
_DESIGN_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9_.\-]+")
|
||||
|
||||
|
||||
def _save_design_file(measurement_id: str, data_url: str, raw_filename: str = "") -> str | None:
|
||||
"""Сохраняет чертёж/документ (DWG, PDF, PNG) в PHOTOS_DIR/<id>/design_<n>.<ext>.
|
||||
Возвращает имя файла или None."""
|
||||
if not isinstance(data_url, str):
|
||||
return None
|
||||
m = _DESIGN_DATA_URL_RE.match(data_url.strip())
|
||||
if not m:
|
||||
return None
|
||||
mime = m.group(1).lower()
|
||||
try:
|
||||
raw = base64.b64decode(m.group(2), validate=False)
|
||||
except Exception:
|
||||
return None
|
||||
if len(raw) > 30 * 1024 * 1024: # 30 MB cap (DWG могут быть тяжёлыми)
|
||||
return None
|
||||
if not _SAFE_ID_RE.match(measurement_id):
|
||||
return None
|
||||
|
||||
# Расширение из mime, fallback на filename
|
||||
ext_map = {
|
||||
"application/x-autocad": "dwg",
|
||||
"image/vnd.dwg": "dwg",
|
||||
"application/acad": "dwg",
|
||||
"application/dxf": "dxf",
|
||||
"application/pdf": "pdf",
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/jpg": "jpg",
|
||||
"image/webp": "webp",
|
||||
}
|
||||
ext = ext_map.get(mime, "")
|
||||
if not ext and raw_filename:
|
||||
rname = raw_filename.lower().rsplit(".", 1)
|
||||
if len(rname) == 2 and rname[1] in _DESIGN_ALLOWED_EXT:
|
||||
ext = rname[1]
|
||||
if not ext or ext not in _DESIGN_ALLOWED_EXT:
|
||||
return None
|
||||
|
||||
target_dir = PHOTOS_DIR / measurement_id
|
||||
try:
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Подбираем уникальное имя design_1.ext, design_2.ext...
|
||||
n = 1
|
||||
while (target_dir / f"design_{n}.{ext}").exists():
|
||||
n += 1
|
||||
name = f"design_{n}.{ext}"
|
||||
# Если был передан осмысленный filename — используем его (sanitized)
|
||||
if raw_filename:
|
||||
base = raw_filename.rsplit(".", 1)[0]
|
||||
safe = _DESIGN_SAFE_NAME_RE.sub("_", base)[:60].strip("_")
|
||||
if safe:
|
||||
name = f"design_{safe}.{ext}"
|
||||
k = 1
|
||||
while (target_dir / name).exists():
|
||||
k += 1
|
||||
name = f"design_{safe}_{k}.{ext}"
|
||||
(target_dir / name).write_bytes(raw)
|
||||
return name
|
||||
except Exception:
|
||||
log.warning("Не удалось сохранить design-файл для %s", measurement_id)
|
||||
return None
|
||||
|
||||
|
||||
def _handle_measurement_design_upload(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Загрузка чертежа/документа к замеру.
|
||||
body: {initData, measurement_id, files: [{name, data_url}, ...]}"""
|
||||
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:
|
||||
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"}
|
||||
|
||||
files = body.get("files") or []
|
||||
if not isinstance(files, list) or not files:
|
||||
return {"error": "no_files"}
|
||||
|
||||
saved = []
|
||||
for f in files[:10]: # хард-кап 10 файлов за раз
|
||||
if not isinstance(f, dict):
|
||||
continue
|
||||
data_url = f.get("data_url") or ""
|
||||
name = f.get("name") or ""
|
||||
fn = _save_design_file(measurement_id, data_url, name)
|
||||
if fn:
|
||||
saved.append(fn)
|
||||
|
||||
if not saved:
|
||||
return {"error": "no_valid_files"}
|
||||
|
||||
# Объединяем с уже сохранёнными
|
||||
existing = [s for s in (row.get("design_files") or "").split(",") if s]
|
||||
combined = existing + saved
|
||||
sheets.update_cell_by_key("Measurements", "id", measurement_id, "design_files", ",".join(combined))
|
||||
|
||||
sheets.log_event("design_uploaded", tg_id, {"id": measurement_id, "count": len(saved)})
|
||||
return {
|
||||
"ok": True,
|
||||
"id": measurement_id,
|
||||
"saved": saved,
|
||||
"total": len(combined),
|
||||
"design_files": combined,
|
||||
}
|
||||
|
||||
|
||||
def _handle_measurement_decision(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Менеджер фиксирует решение про подбор техники после замера.
|
||||
body: {initData, measurement_id, decision: needed|not_needed|later|done, lead_id?}"""
|
||||
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 or not sheets.has_role(user, "manager"):
|
||||
return {"error": "only_manager"}
|
||||
|
||||
measurement_id = (body.get("measurement_id") or "").strip()
|
||||
decision = (body.get("decision") or "").strip()
|
||||
if decision not in ("needed", "not_needed", "later", "done"):
|
||||
return {"error": "bad_decision"}
|
||||
row = sheets.find_row("Measurements", "id", measurement_id)
|
||||
if not row:
|
||||
return {"error": "measurement_not_found"}
|
||||
if str(row.get("manager_tg_id")) != str(tg_id) and str(row.get("requested_by_tg_id")) != str(tg_id):
|
||||
return {"error": "forbidden"}
|
||||
|
||||
sheets.update_cell_by_key("Measurements", "id", measurement_id, "podbor_decision", decision)
|
||||
sheets.update_cell_by_key("Measurements", "id", measurement_id, "podbor_decision_at", _now_iso())
|
||||
lead_id = (body.get("lead_id") or "").strip()
|
||||
if lead_id:
|
||||
sheets.update_cell_by_key("Measurements", "id", measurement_id, "podbor_lead_id", lead_id)
|
||||
|
||||
sheets.log_event("podbor_decision", tg_id, {"id": measurement_id, "decision": decision})
|
||||
return {"ok": True, "id": measurement_id, "decision": decision}
|
||||
|
||||
|
||||
def _handle_manager_pending(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Возвращает actionable карты для менеджера на главной:
|
||||
завершённые замеры где ещё не зафиксировано решение про подбор."""
|
||||
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 or not sheets.has_role(user, "manager"):
|
||||
return {"error": "only_manager"}
|
||||
|
||||
try:
|
||||
ws = sheets.sheet("Measurements")
|
||||
rows = ws.get_all_values()
|
||||
except Exception:
|
||||
return {"ok": True, "pending": []}
|
||||
if not rows or len(rows) < 2:
|
||||
return {"ok": True, "pending": []}
|
||||
|
||||
headers = rows[0]
|
||||
out = []
|
||||
for r in rows[1:]:
|
||||
row = dict(zip(headers, r + [""] * (len(headers) - len(r))))
|
||||
if str(row.get("manager_tg_id", "")) != str(tg_id) and \
|
||||
str(row.get("requested_by_tg_id", "")) != str(tg_id):
|
||||
continue
|
||||
if row.get("archived_at"):
|
||||
continue
|
||||
if row.get("status") != "completed":
|
||||
continue
|
||||
decision = row.get("podbor_decision") or ""
|
||||
# Показываем pending (нет решения) + later (отложено) — для повторного предложения
|
||||
if decision in ("needed", "not_needed", "done"):
|
||||
continue
|
||||
out.append({
|
||||
"id": row.get("id", ""),
|
||||
"client_name": row.get("client_name", ""),
|
||||
"client_phone": row.get("client_phone", ""),
|
||||
"address": row.get("address", ""),
|
||||
"ts": row.get("ts", ""),
|
||||
"decision": decision, # пусто или "later"
|
||||
})
|
||||
# Сортируем самые свежие сверху
|
||||
out.sort(key=lambda x: x.get("ts", ""), reverse=True)
|
||||
return {"ok": True, "count": len(out), "pending": out}
|
||||
|
||||
|
||||
def _normalize_phone(raw: str) -> tuple[str, bool]:
|
||||
"""Нормализует RU-телефон в формат +7XXXXXXXXXX.
|
||||
Возвращает (нормализованный, валиден ли)."""
|
||||
@ -1929,6 +2182,15 @@ def _handle_measurement_detail(body: dict[str, Any]) -> dict[str, Any]:
|
||||
# Google Calendar
|
||||
"gcal_event_id": row.get("gcal_event_id", ""),
|
||||
"gcal_event_url": row.get("gcal_event_url", ""),
|
||||
# Чертёж и решение про подбор
|
||||
"design_files": [f for f in (row.get("design_files") or "").split(",") if f],
|
||||
"podbor_decision": row.get("podbor_decision", ""),
|
||||
"podbor_decision_at": row.get("podbor_decision_at", ""),
|
||||
"podbor_lead_id": row.get("podbor_lead_id", ""),
|
||||
# Номера
|
||||
"client_no": row.get("client_no", ""),
|
||||
"contract_no": row.get("contract_no", ""),
|
||||
"contract_date": row.get("contract_date", ""),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -171,18 +171,28 @@ async function renderManagerHome(me) {
|
||||
|
||||
renderBottomNav("home", { unreadChats: 0 });
|
||||
|
||||
// Контейнер для карточек «Замер готов — что делать с подбором?»
|
||||
const pendingContainer = el(`<div id="pendingContainer"></div>`);
|
||||
app.insertBefore(pendingContainer, todayContainer);
|
||||
|
||||
// Параллельно грузим реальные данные
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/measurements`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
initData: tg?.initData || "",
|
||||
initDataUnsafe: tg?.initDataUnsafe || null,
|
||||
const [resM, resP] = await Promise.all([
|
||||
fetch(`${BACKEND_URL}/api/measurements`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null }),
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
fetch(`${BACKEND_URL}/api/manager_pending`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null }),
|
||||
}),
|
||||
]);
|
||||
const data = await resM.json();
|
||||
const pendingData = await resP.json();
|
||||
const measurements = (data.measurements || []);
|
||||
const pending = (pendingData.pending || []);
|
||||
|
||||
renderManagerPending(pendingContainer, pending);
|
||||
renderManagerToday(todayContainer, measurements, firstName, greetingEl);
|
||||
renderManagerProjects(projectsContainer, measurements);
|
||||
} catch (e) {
|
||||
@ -190,6 +200,85 @@ async function renderManagerHome(me) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------- Менеджер: карточки «Замер готов — подбор?» ----------------- */
|
||||
function renderManagerPending(container, pending) {
|
||||
container.innerHTML = "";
|
||||
if (!pending.length) return;
|
||||
|
||||
container.appendChild(el(`
|
||||
<div class="section-head"><span class="label">✅ Замеры готовы · ${pending.length}</span></div>
|
||||
`));
|
||||
|
||||
for (const p of pending) {
|
||||
const isLater = p.decision === "later";
|
||||
const card = el(`
|
||||
<section class="pending-card${isLater ? " later" : ""}">
|
||||
<div class="pending-head">
|
||||
<span class="pending-icon">✅</span>
|
||||
<div>
|
||||
<div class="pending-title">${escHtml(p.client_name || "Без имени")}</div>
|
||||
<div class="pending-sub">Замер выполнен · ${escHtml(p.address || "адрес не указан")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pending-question">${isLater ? "Снова: " : ""}Клиенту потребуется помощь с подбором техники?</div>
|
||||
<div class="pending-actions">
|
||||
<button class="btn-primary" data-act="yes" type="button">Да, поможем</button>
|
||||
<button class="btn-secondary" data-act="no" type="button">Нет</button>
|
||||
<button class="btn-secondary" data-act="later" type="button">Позже</button>
|
||||
</div>
|
||||
<div class="pending-result" data-id="${p.id}"></div>
|
||||
</section>
|
||||
`);
|
||||
card.querySelectorAll("button[data-act]").forEach(btn => {
|
||||
btn.addEventListener("click", () => handlePodborDecision(p, btn.dataset.act, card));
|
||||
});
|
||||
container.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePodborDecision(item, act, card) {
|
||||
const decisionMap = { yes: "needed", no: "not_needed", later: "later" };
|
||||
const decision = decisionMap[act];
|
||||
if (!decision) return;
|
||||
const resultEl = card.querySelector(".pending-result");
|
||||
if (resultEl) resultEl.textContent = "Сохраняем...";
|
||||
card.querySelectorAll("button").forEach(b => b.disabled = true);
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/measurement_decision`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
initData: tg?.initData || "",
|
||||
initDataUnsafe: tg?.initDataUnsafe || null,
|
||||
measurement_id: item.id,
|
||||
decision,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
if (resultEl) resultEl.innerHTML = `<span style="color:#C0392B;">Ошибка: ${escHtml(data.error)}</span>`;
|
||||
card.querySelectorAll("button").forEach(b => b.disabled = false);
|
||||
return;
|
||||
}
|
||||
haptic && haptic("success");
|
||||
if (decision === "needed") {
|
||||
// Переходим в подбор техники с pre-fill из клиента
|
||||
sessionStorage.setItem("prefillClient", JSON.stringify({
|
||||
name: item.client_name, phone: item.client_phone,
|
||||
}));
|
||||
location.hash = `#/podbor?client_name=${encodeURIComponent(item.client_name || "")}&client_phone=${encodeURIComponent(item.client_phone || "")}`;
|
||||
} else {
|
||||
// Анимируем удаление карточки
|
||||
card.style.transition = "opacity 0.25s, transform 0.25s";
|
||||
card.style.opacity = "0";
|
||||
card.style.transform = "translateX(20px)";
|
||||
setTimeout(() => card.remove(), 250);
|
||||
}
|
||||
} catch (e) {
|
||||
if (resultEl) resultEl.innerHTML = `<span style="color:#C0392B;">Сеть: ${escHtml(e.message)}</span>`;
|
||||
card.querySelectorAll("button").forEach(b => b.disabled = false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderManagerToday(container, measurements, firstName, greetingEl) {
|
||||
const today = _startOfDay(new Date());
|
||||
const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
@ -834,6 +834,107 @@ const Clients = (function () {
|
||||
}
|
||||
root.appendChild(list);
|
||||
}
|
||||
|
||||
// Чертежи / DWG
|
||||
root.appendChild(renderDesignFilesBlock(m));
|
||||
}
|
||||
|
||||
/* ===================== Чертежи / DWG ===================== */
|
||||
|
||||
function renderDesignFilesBlock(measurement) {
|
||||
const section = el(`
|
||||
<section class="block design-upload">
|
||||
<div class="block-head">📐 Чертёж / DWG</div>
|
||||
<div class="design-files-list" id="designFilesList"></div>
|
||||
<label class="design-upload-label">Прикрепить файлы (DWG, DXF, PDF, изображение)</label>
|
||||
<input type="file" class="design-upload-input" id="designFilesInput"
|
||||
accept=".dwg,.dxf,.pdf,.png,.jpg,.jpeg,image/png,image/jpeg,application/pdf,application/acad,image/vnd.dwg"
|
||||
multiple>
|
||||
<div class="design-upload-status" id="designUploadStatus"></div>
|
||||
</section>
|
||||
`);
|
||||
|
||||
const list = section.querySelector("#designFilesList");
|
||||
const input = section.querySelector("#designFilesInput");
|
||||
const status = section.querySelector("#designUploadStatus");
|
||||
|
||||
function refreshList(files) {
|
||||
list.innerHTML = "";
|
||||
const arr = (files || measurement.design_files || []).filter(Boolean);
|
||||
if (!arr.length) {
|
||||
list.innerHTML = `<div class="muted" style="font-size:12px;padding:4px 0;">Чертежей пока нет</div>`;
|
||||
return;
|
||||
}
|
||||
for (const fn of arr) {
|
||||
const url = `${BACKEND_URL}/api/photo/${measurement.id}/${fn}`;
|
||||
const ext = (fn.split(".").pop() || "").toLowerCase();
|
||||
const icon = (ext === "dwg" || ext === "dxf") ? "📐"
|
||||
: (ext === "pdf") ? "📄"
|
||||
: "🖼️";
|
||||
const item = el(`
|
||||
<a class="design-file-item" href="${url}" target="_blank" rel="noopener" download>
|
||||
<span class="design-file-icon">${icon}</span>
|
||||
<span class="design-file-name">${escHtml(fn)}</span>
|
||||
<span class="design-file-size">${ext.toUpperCase()}</span>
|
||||
</a>
|
||||
`);
|
||||
list.appendChild(item);
|
||||
}
|
||||
}
|
||||
refreshList();
|
||||
|
||||
input.addEventListener("change", async (ev) => {
|
||||
const files = Array.from(ev.target.files || []);
|
||||
ev.target.value = "";
|
||||
if (!files.length) return;
|
||||
|
||||
status.textContent = `Загружаем ${files.length} файл(а/ов)…`;
|
||||
try {
|
||||
// Читаем по одному в base64 data URL
|
||||
const payload = [];
|
||||
for (const f of files) {
|
||||
if (f.size > 30 * 1024 * 1024) {
|
||||
status.textContent = `Файл ${f.name} больше 30 МБ — пропустили`;
|
||||
continue;
|
||||
}
|
||||
const dataUrl = await new Promise((resolve, reject) => {
|
||||
const r = new FileReader();
|
||||
r.onerror = reject;
|
||||
r.onload = () => resolve(r.result);
|
||||
r.readAsDataURL(f);
|
||||
});
|
||||
payload.push({ name: f.name, data_url: dataUrl });
|
||||
if (payload.length >= 10) break;
|
||||
}
|
||||
if (!payload.length) {
|
||||
status.textContent = "Нет подходящих файлов";
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`${BACKEND_URL}/api/measurement_design_upload`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
initData: tg?.initData || "",
|
||||
initDataUnsafe: tg?.initDataUnsafe || null,
|
||||
measurement_id: measurement.id,
|
||||
files: payload,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
status.textContent = "Ошибка: " + data.error;
|
||||
return;
|
||||
}
|
||||
haptic && haptic("success");
|
||||
measurement.design_files = data.design_files || [];
|
||||
refreshList(measurement.design_files);
|
||||
status.textContent = `✓ загружено ${payload.length}`;
|
||||
setTimeout(() => { status.textContent = ""; }, 3000);
|
||||
} catch (e) {
|
||||
status.textContent = "Сеть: " + e.message;
|
||||
}
|
||||
});
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
async function fetchMeasurementDetail(measurementId) {
|
||||
|
||||
@ -2924,6 +2924,134 @@
|
||||
}
|
||||
.report-print-btn:active { background: rgba(107, 74, 43, 0.12); }
|
||||
|
||||
/* ===== Карточки «Замер готов — что с подбором?» на главной менеджера ===== */
|
||||
.pending-card {
|
||||
background: var(--card, #FFFFFF);
|
||||
border: 1px solid var(--line, rgba(15,15,14,0.08));
|
||||
border-left: 3px solid #76BD22;
|
||||
border-radius: 12px;
|
||||
padding: 14px 14px 12px;
|
||||
margin: 10px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
box-shadow: 0 1px 3px rgba(15, 15, 14, 0.04);
|
||||
}
|
||||
.pending-card.later {
|
||||
border-left-color: #B07E00;
|
||||
background: rgba(176, 126, 0, 0.04);
|
||||
}
|
||||
.pending-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
.pending-icon {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.pending-title {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--ink);
|
||||
line-height: 1.25;
|
||||
}
|
||||
.pending-sub {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.pending-question {
|
||||
font-size: 14px;
|
||||
color: var(--ink-2, #2A2622);
|
||||
line-height: 1.35;
|
||||
padding: 2px 0 0 32px;
|
||||
}
|
||||
.pending-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding-left: 32px;
|
||||
}
|
||||
.pending-actions .btn-primary,
|
||||
.pending-actions .btn-secondary {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 9px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.pending-result {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
padding-left: 32px;
|
||||
min-height: 0;
|
||||
}
|
||||
.pending-result:empty { display: none; }
|
||||
|
||||
/* ===== Загрузка DWG/чертежей замера ===== */
|
||||
.design-upload {
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--line-strong, rgba(15,15,14,0.16));
|
||||
border-radius: 10px;
|
||||
background: var(--paper-2, #F0EDE5);
|
||||
}
|
||||
.design-upload-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-2);
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
.design-upload-input {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.design-upload-status {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 6px;
|
||||
min-height: 0;
|
||||
}
|
||||
.design-files-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.design-file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--ink);
|
||||
padding: 6px 10px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.design-file-item .design-file-icon {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.design-file-item .design-file-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 12px;
|
||||
}
|
||||
.design-file-item .design-file-size {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ===== Печать / PDF ===== */
|
||||
@media print {
|
||||
body { background: white !important; color: black !important; }
|
||||
|
||||
@ -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=20260514a">
|
||||
<link rel="stylesheet" href="assets/podbor.css?v=20260514a">
|
||||
<link rel="stylesheet" href="assets/styles.css?v=20260514b">
|
||||
<link rel="stylesheet" href="assets/podbor.css?v=20260514b">
|
||||
</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=20260514a" alt="@wasrusgen1">
|
||||
<img class="brand-logo" src="assets/wasrusgen-logo.svg?v=20260514b" 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,14 +35,14 @@
|
||||
<div class="brand-tagline-gold">CRM</div>
|
||||
</div>
|
||||
<main id="app"></main>
|
||||
<script src="assets/icons.js?v=20260514a"></script>
|
||||
<script src="assets/podbor.config.js?v=20260514a"></script>
|
||||
<script src="assets/podbor.picts.js?v=20260514a"></script>
|
||||
<script src="assets/podbor.js?v=20260514a"></script>
|
||||
<script src="assets/clients.js?v=20260514a"></script>
|
||||
<script src="assets/zamer-picts.js?v=20260514a"></script>
|
||||
<script src="assets/measurements.js?v=20260514a"></script>
|
||||
<script src="assets/request.js?v=20260514a"></script>
|
||||
<script src="assets/app.js?v=20260514a"></script>
|
||||
<script src="assets/icons.js?v=20260514b"></script>
|
||||
<script src="assets/podbor.config.js?v=20260514b"></script>
|
||||
<script src="assets/podbor.picts.js?v=20260514b"></script>
|
||||
<script src="assets/podbor.js?v=20260514b"></script>
|
||||
<script src="assets/clients.js?v=20260514b"></script>
|
||||
<script src="assets/zamer-picts.js?v=20260514b"></script>
|
||||
<script src="assets/measurements.js?v=20260514b"></script>
|
||||
<script src="assets/request.js?v=20260514b"></script>
|
||||
<script src="assets/app.js?v=20260514b"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user