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:
wasrusgen 2026-05-14 09:17:32 +03:00
parent 34b83899b5
commit 5e6746e676
5 changed files with 602 additions and 22 deletions

View File

@ -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", ""),
}

View File

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

View File

@ -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) {

View File

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

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=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>