mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 18:04:47 +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_note": _handle_client_note,
|
||||||
"client_create": _handle_client_create,
|
"client_create": _handle_client_create,
|
||||||
"client_delete": _handle_client_delete,
|
"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()},
|
"ping": lambda b: {"pong": True, "time": _now_iso()},
|
||||||
"seed_admin": lambda b: _handle_seed_admin(),
|
"seed_admin": lambda b: _handle_seed_admin(),
|
||||||
"test_ai": lambda b: _handle_test_ai(),
|
"test_ai": lambda b: _handle_test_ai(),
|
||||||
@ -243,6 +246,24 @@ async def api_client_delete(request: Request):
|
|||||||
return _handle_client_delete(body)
|
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")
|
@app.post("/api/grant_role")
|
||||||
async def api_grant_role(request: Request):
|
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():
|
if not p.exists() or not p.is_file():
|
||||||
return JSONResponse({"error": "not_found"}, status_code=404)
|
return JSONResponse({"error": "not_found"}, status_code=404)
|
||||||
ext = filename.rsplit(".", 1)[-1].lower()
|
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)
|
return FileResponse(str(p), media_type=media)
|
||||||
|
|
||||||
|
|
||||||
@ -657,6 +684,12 @@ def _measurement_columns() -> list[str]:
|
|||||||
"client_no", "contract_no", "contract_date",
|
"client_no", "contract_no", "contract_date",
|
||||||
# Soft-delete
|
# Soft-delete
|
||||||
"archived_at",
|
"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": "",
|
"gcal_event_id": "", "gcal_event_url": "",
|
||||||
"client_no": "", "contract_no": "", "contract_date": "",
|
"client_no": "", "contract_no": "", "contract_date": "",
|
||||||
"archived_at": "",
|
"archived_at": "",
|
||||||
|
"design_files": "",
|
||||||
|
"podbor_decision": "", "podbor_decision_at": "", "podbor_lead_id": "",
|
||||||
}
|
}
|
||||||
base.update(fields)
|
base.update(fields)
|
||||||
return [str(base.get(c, "")) for c in cols]
|
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>Замер выполнен</b>\n"
|
||||||
f"Клиент: <b>{client_name or '—'}</b>\n"
|
f"Клиент: <b>{client_name or '—'}</b>\n"
|
||||||
f"Замерщик: {user.get('full_name') or tg_id}\n"
|
f"Замерщик: {user.get('full_name') or tg_id}\n"
|
||||||
f"Фото: {len(saved_photos)} шт · площадь {m.get('area_m2') or '—'} м²\n\n"
|
f"Фото: {len(saved_photos)} шт\n\n"
|
||||||
f"Откройте кабинет — можно запускать подбор техники."
|
f"❓ <b>Клиенту потребуется помощь с подбором техники?</b>\n"
|
||||||
|
f"Откройте кабинет — на главной появится карточка с этим вопросом."
|
||||||
)
|
)
|
||||||
elif filled_by == "client_self" and manager_tg_id:
|
elif filled_by == "client_self" and manager_tg_id:
|
||||||
tg.send_message(
|
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}
|
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]:
|
def _normalize_phone(raw: str) -> tuple[str, bool]:
|
||||||
"""Нормализует RU-телефон в формат +7XXXXXXXXXX.
|
"""Нормализует RU-телефон в формат +7XXXXXXXXXX.
|
||||||
Возвращает (нормализованный, валиден ли)."""
|
Возвращает (нормализованный, валиден ли)."""
|
||||||
@ -1929,6 +2182,15 @@ def _handle_measurement_detail(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
# Google Calendar
|
# Google Calendar
|
||||||
"gcal_event_id": row.get("gcal_event_id", ""),
|
"gcal_event_id": row.get("gcal_event_id", ""),
|
||||||
"gcal_event_url": row.get("gcal_event_url", ""),
|
"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 });
|
renderBottomNav("home", { unreadChats: 0 });
|
||||||
|
|
||||||
|
// Контейнер для карточек «Замер готов — что делать с подбором?»
|
||||||
|
const pendingContainer = el(`<div id="pendingContainer"></div>`);
|
||||||
|
app.insertBefore(pendingContainer, todayContainer);
|
||||||
|
|
||||||
// Параллельно грузим реальные данные
|
// Параллельно грузим реальные данные
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BACKEND_URL}/api/measurements`, {
|
const [resM, resP] = await Promise.all([
|
||||||
method: "POST",
|
fetch(`${BACKEND_URL}/api/measurements`, {
|
||||||
body: JSON.stringify({
|
method: "POST",
|
||||||
initData: tg?.initData || "",
|
body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null }),
|
||||||
initDataUnsafe: tg?.initDataUnsafe || null,
|
|
||||||
}),
|
}),
|
||||||
});
|
fetch(`${BACKEND_URL}/api/manager_pending`, {
|
||||||
const data = await res.json();
|
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 measurements = (data.measurements || []);
|
||||||
|
const pending = (pendingData.pending || []);
|
||||||
|
|
||||||
|
renderManagerPending(pendingContainer, pending);
|
||||||
renderManagerToday(todayContainer, measurements, firstName, greetingEl);
|
renderManagerToday(todayContainer, measurements, firstName, greetingEl);
|
||||||
renderManagerProjects(projectsContainer, measurements);
|
renderManagerProjects(projectsContainer, measurements);
|
||||||
} catch (e) {
|
} 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) {
|
function renderManagerToday(container, measurements, firstName, greetingEl) {
|
||||||
const today = _startOfDay(new Date());
|
const today = _startOfDay(new Date());
|
||||||
const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1);
|
const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|||||||
@ -834,6 +834,107 @@ const Clients = (function () {
|
|||||||
}
|
}
|
||||||
root.appendChild(list);
|
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) {
|
async function fetchMeasurementDetail(measurementId) {
|
||||||
|
|||||||
@ -2924,6 +2924,134 @@
|
|||||||
}
|
}
|
||||||
.report-print-btn:active { background: rgba(107, 74, 43, 0.12); }
|
.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 ===== */
|
/* ===== Печать / PDF ===== */
|
||||||
@media print {
|
@media print {
|
||||||
body { background: white !important; color: black !important; }
|
body { background: white !important; color: black !important; }
|
||||||
|
|||||||
@ -12,14 +12,14 @@
|
|||||||
<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;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">
|
<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>
|
<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/styles.css?v=20260514b">
|
||||||
<link rel="stylesheet" href="assets/podbor.css?v=20260514a">
|
<link rel="stylesheet" href="assets/podbor.css?v=20260514b">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Splash — лого @wasrusgen1 + опилки (16) + вращающийся диск -->
|
<!-- Splash — лого @wasrusgen1 + опилки (16) + вращающийся диск -->
|
||||||
<div class="loader splash" id="splash">
|
<div class="loader splash" id="splash">
|
||||||
<div class="brand-logo-wrap">
|
<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">
|
<div class="splash-dust" aria-hidden="true">
|
||||||
<span class="dust d1"></span> <span class="dust d2"></span>
|
<span class="dust d1"></span> <span class="dust d2"></span>
|
||||||
<span class="dust d3"></span> <span class="dust d4"></span>
|
<span class="dust d3"></span> <span class="dust d4"></span>
|
||||||
@ -35,14 +35,14 @@
|
|||||||
<div class="brand-tagline-gold">CRM</div>
|
<div class="brand-tagline-gold">CRM</div>
|
||||||
</div>
|
</div>
|
||||||
<main id="app"></main>
|
<main id="app"></main>
|
||||||
<script src="assets/icons.js?v=20260514a"></script>
|
<script src="assets/icons.js?v=20260514b"></script>
|
||||||
<script src="assets/podbor.config.js?v=20260514a"></script>
|
<script src="assets/podbor.config.js?v=20260514b"></script>
|
||||||
<script src="assets/podbor.picts.js?v=20260514a"></script>
|
<script src="assets/podbor.picts.js?v=20260514b"></script>
|
||||||
<script src="assets/podbor.js?v=20260514a"></script>
|
<script src="assets/podbor.js?v=20260514b"></script>
|
||||||
<script src="assets/clients.js?v=20260514a"></script>
|
<script src="assets/clients.js?v=20260514b"></script>
|
||||||
<script src="assets/zamer-picts.js?v=20260514a"></script>
|
<script src="assets/zamer-picts.js?v=20260514b"></script>
|
||||||
<script src="assets/measurements.js?v=20260514a"></script>
|
<script src="assets/measurements.js?v=20260514b"></script>
|
||||||
<script src="assets/request.js?v=20260514a"></script>
|
<script src="assets/request.js?v=20260514b"></script>
|
||||||
<script src="assets/app.js?v=20260514a"></script>
|
<script src="assets/app.js?v=20260514b"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user