measurements: структура фото + чек-лист + общая инфа

По чек-листу ЗАМЕРОВ (D:\!!! GOOGLE DISK\ЗАМЕРЫ\...\ЧЕКЛИСТ_ЗАМЕРА.md):
каждая стена снимается отдельно, имя файла отражает тип.

Wizard:
- Каждое фото получает dropdown «Что это»:
  Стена 1, 2, 3, 4 · План комнаты · Общий вид · Деталь
- Авто-предложение типа: w1 → w2 → w3 → w4 → plan → general
- Добавлены поля общей инфы:
  · № замера (опционально)
  · Дата замера (auto-сегодня)
  · Стяжка / нулевой пол (default «0,000 = +88 мм над плитой»)
- В шапке кнопка 📋 — открывает чек-лист отдельной страницей
- Inline-рендер markdown с поддержкой заголовков, списков, таблиц, code

Backend:
- _save_measurement_photo принимает kind+kind_seq → имена файлов
  структурные: w1.jpg, w2.jpg, plan.jpg, general_2.jpg, detail_1.jpg.
  Это упрощает дальнейшую обработку для генерации DWG.
- Расширена схема Measurements: zamer_no, zamer_date, floor_base, photos_meta.
- /api/measurement_detail отдаёт новые поля.

Cache bust v=20260513l.
This commit is contained in:
wasrusgen 2026-05-13 07:19:25 +03:00
parent 5c2a5bb335
commit 121927ab2d
5 changed files with 535 additions and 49 deletions

View File

@ -527,9 +527,24 @@ def _handle_me(body: dict[str, Any]) -> dict[str, Any]:
_DATA_URL_RE = re.compile(r"^data:image/(jpeg|jpg|png|webp);base64,(.+)$", re.DOTALL)
# Маппинг тип фото → префикс имени файла (по чек-листу замера)
_PHOTO_KIND_PREFIX = {
"wall1": "w1",
"wall2": "w2",
"wall3": "w3",
"wall4": "w4",
"plan": "plan",
"general": "general",
"detail": "detail",
}
def _save_measurement_photo(measurement_id: str, idx: int, data_url: str) -> str | None:
"""Сохраняет фото (data-URL) в `PHOTOS_DIR/<measurement_id>/<idx>.<ext>`.
def _save_measurement_photo(
measurement_id: str, idx: int, data_url: str,
kind: str | None = None, kind_seq: int = 0,
) -> str | None:
"""Сохраняет фото с осмысленным именем: `w1.jpg` / `plan.jpg` / `general_3.jpg`.
Если несколько фото одного типа добавляем суффикс _2, _3.
Возвращает имя файла или None при ошибке."""
if not isinstance(data_url, str):
return None
@ -548,6 +563,23 @@ def _save_measurement_photo(measurement_id: str, idx: int, data_url: str) -> str
target_dir = PHOTOS_DIR / measurement_id
try:
target_dir.mkdir(parents=True, exist_ok=True)
prefix = _PHOTO_KIND_PREFIX.get(kind or "", "")
if prefix:
# wall1/2/3/4 — один на стену; если дубль — добавляем _2, _3...
if prefix.startswith("w") and len(prefix) == 2:
name_base = prefix
candidate = f"{name_base}.{ext}"
n = 2
while (target_dir / candidate).exists():
candidate = f"{name_base}_{n}.{ext}"
n += 1
name = candidate
else:
# plan / general / detail — могут быть множественные
name = f"{prefix}_{kind_seq}.{ext}" if kind_seq > 0 else f"{prefix}.{ext}"
if (target_dir / name).exists():
name = f"{prefix}_{kind_seq + 1}.{ext}"
else:
name = f"{idx}.{ext}"
(target_dir / name).write_bytes(raw)
return name
@ -562,9 +594,11 @@ def _measurement_columns() -> list[str]:
"id", "ts", "client_tg_id", "manager_tg_id", "filled_by",
"layout", "area_m2", "ceiling_mm", "walls", "openings", "infra", "niches",
"photos", "notes", "status",
# Новые поля (Commit B)
# Поля Commit B (workflow)
"assigned_to_tg_id", "requested_by_tg_id", "scheduled_at",
"address", "client_name", "client_phone",
# Поля Commit C (структура замера по чек-листу)
"zamer_no", "zamer_date", "floor_base", "photos_meta",
]
@ -595,6 +629,7 @@ def _row_for_measurement(measurement_id: str, ts: str, **fields) -> list[str]:
"photos": "", "notes": "", "status": "submitted",
"assigned_to_tg_id": "", "requested_by_tg_id": "", "scheduled_at": "",
"address": "", "client_name": "", "client_phone": "",
"zamer_no": "", "zamer_date": "", "floor_base": "", "photos_meta": "",
}
base.update(fields)
return [str(base.get(c, "")) for c in cols]
@ -666,17 +701,24 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]:
if extras:
notes_full = " · ".join(extras) + ("\n" + notes_full if notes_full else "")
# Сохраняем фотографии (data-URL → файлы), в Sheets кладём только имена
# Сохраняем фотографии (data-URL → файлы), в Sheets кладём только имена.
# Имена структурные: w1.jpg, plan.jpg, general_2.jpg — по типу из photos_meta.
raw_photos = m.get("photos") or []
photos_meta = m.get("photos_meta") or []
saved_photos: list[str] = []
kind_counter: dict[str, int] = {} # сколько раз уже встречался каждый kind
if isinstance(raw_photos, list):
for i, p in enumerate(raw_photos[:20]): # хард-кап 20 фото на замер
for i, p in enumerate(raw_photos[:30]): # хард-кап 30 фото на замер
kind = ""
if i < len(photos_meta) and isinstance(photos_meta[i], dict):
kind = photos_meta[i].get("kind", "")
kind_counter[kind] = kind_counter.get(kind, 0) + 1
seq = kind_counter[kind] - 1 # 0 для первого, 1 для второго и т.д.
if isinstance(p, str) and p.startswith("data:"):
fn = _save_measurement_photo(measurement_id, i, p)
fn = _save_measurement_photo(measurement_id, i, p, kind=kind, kind_seq=seq)
if fn:
saved_photos.append(fn)
elif isinstance(p, str) and p and not p.startswith("data:"):
# уже готовое имя/URL — пропускаем как есть
saved_photos.append(p)
status_new = "completed"
@ -685,6 +727,11 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]:
infra_json = json.dumps(m.get("infra") or {}, ensure_ascii=False)
niches_json = json.dumps(m.get("niches") or {}, ensure_ascii=False)
photos_str = ",".join(saved_photos)
photos_meta_json = json.dumps(m.get("photos_meta") or [], ensure_ascii=False)
zamer_no = (m.get("zamer_no") or "").strip()
zamer_date = (m.get("zamer_date") or "").strip()
floor_base = (m.get("floor_base") or "").strip()
if update_mode:
# Обновляем существующую заявку — статус → completed, плюс заполняем поля
@ -698,8 +745,12 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]:
"infra": infra_json,
"niches": niches_json,
"photos": photos_str,
"photos_meta": photos_meta_json,
"notes": notes_full,
"status": status_new,
"zamer_no": zamer_no,
"zamer_date": zamer_date,
"floor_base": floor_base,
}
for col, val in updates.items():
sheets.update_cell_by_key("Measurements", "id", measurement_id, col, val)
@ -717,6 +768,7 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]:
infra=infra_json,
niches=niches_json,
photos=photos_str,
photos_meta=photos_meta_json,
notes=notes_full,
status=status_new,
assigned_to_tg_id=assigned_to,
@ -725,6 +777,9 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]:
address=address,
client_name=client_name,
client_phone=client_phone,
zamer_no=zamer_no,
zamer_date=zamer_date,
floor_base=floor_base,
))
if client_tg_id:
@ -1262,13 +1317,18 @@ def _handle_measurement_detail(body: dict[str, Any]) -> dict[str, Any]:
"photos": photo_files,
"notes": row.get("notes", ""),
"status": row.get("status", ""),
# Новые поля (Commit B)
# Поля Commit B (workflow)
"assigned_to_tg_id": row.get("assigned_to_tg_id", ""),
"requested_by_tg_id": row.get("requested_by_tg_id", ""),
"scheduled_at": row.get("scheduled_at", ""),
"address": row.get("address", ""),
"client_name": row.get("client_name", ""),
"client_phone": row.get("client_phone", ""),
# Поля Commit C (структура замера)
"zamer_no": row.get("zamer_no", ""),
"zamer_date": row.get("zamer_date", ""),
"floor_base": row.get("floor_base", ""),
"photos_meta": _safe_json(row.get("photos_meta", "")),
}

View File

@ -1,33 +1,53 @@
/* ============================================================
Замер упрощённая версия: только фото + заметки.
DWG-чертёж потом делается отдельным процессом из фото.
Замер структурированная загрузка фото по типам.
Типы фото: стена 1-4, план комнаты, общий вид, деталь.
============================================================ */
const Measurements = (function () {
const STORAGE_KEY = "zov-measurement-draft";
const STORAGE_KEY = "zov-measurement-draft-v2";
// Фото держим только в памяти — data-URL'ы тяжёлые
let photos = []; // Array<{ name, dataUrl }>
// Типы фото — в соответствии с чек-листом ЗАМЕРОВ
const PHOTO_KINDS = [
{ key: "wall1", label: "Стена 1" },
{ key: "wall2", label: "Стена 2" },
{ key: "wall3", label: "Стена 3" },
{ key: "wall4", label: "Стена 4" },
{ key: "plan", label: "План комнаты" },
{ key: "general", label: "Общий вид" },
{ key: "detail", label: "Деталь" },
];
function kindLabel(k) {
return (PHOTO_KINDS.find(p => p.key === k) || {}).label || k;
}
// Фото держим только в памяти
let photos = []; // Array<{ dataUrl, kind }>
let state = loadState();
let root = null;
let measurementId = ""; // если задан — update-mode (закрытие существующей заявки)
let prefilledClient = null; // данные клиента из заявки в update-mode
let measurementId = ""; // если задан — update-mode (закрытие заявки)
let prefilledClient = null;
function loadState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
if (raw) return { ...defaultState(), ...JSON.parse(raw) };
} catch (e) {}
return defaultState();
}
function defaultState() {
const todayStr = new Date().toISOString().slice(0, 10);
return {
client_name: "",
client_phone: "",
address: "",
notes: "",
// Общая инфа замера (по чек-листу)
zamer_no: "",
zamer_date: todayStr,
floor_base: "0,000 = +88 мм над плитой",
};
}
@ -54,10 +74,15 @@ const Measurements = (function () {
measurementId = "";
prefilledClient = null;
// ?id=<measurement_id> → update-mode (замерщик закрывает заявку)
const hashMatch = (location.hash.split("?")[1] || "");
const fragQp = new URLSearchParams(hashMatch);
const mid = fragQp.get("id") || new URLSearchParams(location.search).get("measurement_id") || "";
// Спецроут #/measure/checklist — показать чек-лист
if (location.hash.startsWith("#/measure/checklist")) {
renderChecklist();
return;
}
if (mid) {
measurementId = mid;
loadRequestAndStart();
@ -91,8 +116,6 @@ const Measurements = (function () {
phone: data.client_phone || "",
address: data.address || "",
};
// Не показываем форму клиента — она read-only
state.notes = state.notes || "";
render();
} catch (e) {
root.innerHTML = "";
@ -115,17 +138,20 @@ const Measurements = (function () {
<header class="podbor-header">
<button class="podbor-back" aria-label="Назад">${ICONS.arrow_left || ""}</button>
<div class="podbor-title">${escHtml(title)}</div>
<div style="width:28px"></div>
<button class="podbor-help" id="openChecklist" aria-label="Чек-лист">📋</button>
</header>
`);
h.querySelector(".podbor-back").addEventListener("click", () => {
location.hash = "";
location.reload();
});
h.querySelector("#openChecklist").addEventListener("click", () => {
location.hash = "#/measure/checklist";
});
return h;
}
/* ===================== Главный экран — всё на одной странице ===================== */
/* ===================== Главный экран ===================== */
function renderForm() {
const isUpdate = !!measurementId && prefilledClient;
@ -135,27 +161,50 @@ const Measurements = (function () {
<section class="podbor-step">
<h2 class="display-title">${isUpdate ? "Фото<br><span class=\"accent\">с замера</span>" : "Новый<br><span class=\"accent\">замер</span>"}</h2>
<p class="lede">${isUpdate
? "Загрузите фото от руки нарисованных замеров (а также общие фото помещения). Чертёж сделаем по ним отдельно."
: "Заполните данные клиента и загрузите фото замера. Можно фотать рукописные эскизы — главное чтобы были видны размеры."}
</p>
? "Загружайте фото по чек-листу — каждая стена отдельно. Чертёж сделаем по фото."
: "Заполните клиента, дату и загрузите фото по чек-листу. Откройте 📋 чтобы посмотреть как правильно снимать."}</p>
<div id="clientBlock"></div>
<div class="section-head" style="margin-top:18px;"><span class="label">📷 Фото замера</span></div>
<div class="section-head" style="margin-top:18px;"><span class="label">📐 Общая информация</span></div>
<div class="form-row two-col">
<label class="field">
<span class="field-label"> замера</span>
<input type="text" data-bind="zamer_no" value="${escAttr(state.zamer_no)}" placeholder="например 157">
</label>
<label class="field">
<span class="field-label">Дата замера</span>
<input type="date" data-bind="zamer_date" value="${escAttr(state.zamer_date)}">
</label>
</div>
<div class="form-row">
<label class="field">
<span class="field-label">Стяжка / нулевой пол</span>
<input type="text" data-bind="floor_base" value="${escAttr(state.floor_base)}" placeholder="0,000 = +88 мм над плитой">
</label>
</div>
<div class="section-head" style="margin-top:18px;">
<span class="label">📷 Фото замера</span>
<a class="more" id="openChecklist2" style="cursor:pointer;">Чек-лист</a>
</div>
<p class="muted" style="font-size:12px;margin:-4px 0 8px;">
Для каждого фото выберите тип. По чек-листу: каждая стена отдельно + план + общие виды.
</p>
<div class="photo-uploader">
<label class="photo-add-btn" for="photoInput">
<span class="photo-add-ico"></span>
<span class="photo-add-label">Добавить фото</span>
<span class="photo-add-hint">камера или галерея · до 20 шт</span>
<span class="photo-add-hint">камера или галерея · до 30 шт</span>
</label>
<input id="photoInput" type="file" accept="image/*" capture="environment" multiple hidden>
</div>
<div class="photo-list" id="photoList"></div>
<div class="photo-list-tagged" id="photoList"></div>
<div class="form-row" style="margin-top:18px;">
<label class="field">
<span class="field-label">Заметки (опционально)</span>
<textarea data-bind="notes" rows="3" placeholder="что важно отметить — газ/электро, особые условия, размеры которые сложно прочесть на фото">${escHtml(state.notes || "")}</textarea>
<textarea data-bind="notes" rows="3" placeholder="особенности доступа, газ/электро, что важно учесть">${escHtml(state.notes || "")}</textarea>
</label>
</div>
@ -171,6 +220,9 @@ const Measurements = (function () {
bindInputs(node);
bindPhotoInput(node);
node.querySelector("#openChecklist2").addEventListener("click", () => {
location.hash = "#/measure/checklist";
});
node.querySelector("#submitBtn").addEventListener("click", () => onSubmit(node));
return node;
}
@ -186,7 +238,7 @@ const Measurements = (function () {
}
function renderClientInputs() {
const block = el(`
return el(`
<div>
<div class="form-row">
<label class="field">
@ -210,7 +262,6 @@ const Measurements = (function () {
</div>
</div>
`);
return block;
}
function bindInputs(node) {
@ -222,18 +273,40 @@ const Measurements = (function () {
});
}
function nextKindSuggestion() {
// Авто-предложение: сначала Стена 1, потом 2,3,4, затем План, затем Общий
const usedWalls = new Set(photos.filter(p => p.kind?.startsWith("wall")).map(p => p.kind));
for (let i = 1; i <= 4; i++) {
if (!usedWalls.has(`wall${i}`)) return `wall${i}`;
}
const hasPlan = photos.some(p => p.kind === "plan");
if (!hasPlan) return "plan";
return "general";
}
function bindPhotoInput(node) {
const list = node.querySelector("#photoList");
const input = node.querySelector("#photoInput");
function refreshList() {
list.innerHTML = "";
if (!photos.length) {
list.innerHTML = `<div class="empty" style="padding:12px;text-align:center;color:var(--muted);font-size:12px;">Ещё нет фото</div>`;
return;
}
photos.forEach((ph, idx) => {
const tile = el(`
<div class="photo-tile">
<div class="photo-tagged">
<div class="photo-tagged-thumb">
<img src="${ph.dataUrl}" alt="фото ${idx + 1}">
<button class="photo-rm" data-idx="${idx}" aria-label="Удалить">×</button>
</div>
<select class="photo-kind" data-idx="${idx}">
${PHOTO_KINDS.map(k =>
`<option value="${k.key}" ${k.key === ph.kind ? "selected" : ""}>${k.label}</option>`
).join("")}
</select>
</div>
`);
tile.querySelector(".photo-rm").addEventListener("click", e => {
const i = +e.currentTarget.dataset.idx;
@ -241,6 +314,10 @@ const Measurements = (function () {
haptic && haptic("impact");
refreshList();
});
tile.querySelector(".photo-kind").addEventListener("change", e => {
const i = +e.target.dataset.idx;
photos[i].kind = e.target.value;
});
list.appendChild(tile);
});
}
@ -250,11 +327,12 @@ const Measurements = (function () {
input.value = "";
if (!files.length) return;
for (const f of files) {
if (photos.length >= 20) break;
if (photos.length >= 30) break;
if (!f.type || !f.type.startsWith("image/")) continue;
try {
const dataUrl = await compressImage(f, 1800, 0.78);
photos.push({ name: f.name || `photo_${photos.length + 1}`, dataUrl });
const kind = nextKindSuggestion();
photos.push({ dataUrl, kind });
} catch (err) {
console.warn("Не удалось сжать фото", err);
}
@ -266,7 +344,6 @@ const Measurements = (function () {
refreshList();
}
/* Сжатие через canvas */
function compressImage(file, maxSide = 1800, quality = 0.78) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@ -298,6 +375,116 @@ const Measurements = (function () {
});
}
/* ===================== Чек-лист — отдельный экран ===================== */
async function renderChecklist() {
root.innerHTML = "";
root.appendChild(el(`
<header class="podbor-header">
<button class="podbor-back" aria-label="Назад">${ICONS.arrow_left || ""}</button>
<div class="podbor-title">Чек-лист замера</div>
<div style="width:28px"></div>
</header>
`));
root.querySelector(".podbor-back").addEventListener("click", () => {
// Возврат к мастеру (если был открыт через #/measure?id=X)
if (measurementId) location.hash = `#/measure?id=${measurementId}`;
else location.hash = "#/measure";
});
const wrap = el(`<section class="podbor-step checklist-page"></section>`);
root.appendChild(wrap);
wrap.appendChild(el(`<div class="loader-inline"><div class="spinner"></div></div>`));
try {
const res = await fetch("./assets/zamer-checklist.md", { cache: "no-cache" });
const md = await res.text();
wrap.innerHTML = `<div class="checklist-md">${renderMarkdown(md)}</div>`;
} catch (e) {
wrap.innerHTML = `<div class="error">Не удалось загрузить чек-лист: ${e.message}</div>`;
}
}
/* Минимальный markdown → HTML: заголовки, списки, таблицы, code */
function renderMarkdown(md) {
const lines = md.split("\n");
const out = [];
let inList = false;
let inTable = false;
let tableRows = [];
function closeList() { if (inList) { out.push("</ul>"); inList = false; } }
function closeTable() {
if (!inTable) return;
if (tableRows.length) {
const html = ["<table class='cl-table'>"];
tableRows.forEach((cells, i) => {
const tag = i === 0 ? "th" : "td";
if (i === 1 && cells.every(c => /^[-:\s|]+$/.test(c))) return; // skip separator
html.push(`<tr>${cells.map(c => `<${tag}>${inline(c)}</${tag}>`).join("")}</tr>`);
});
html.push("</table>");
out.push(html.join(""));
}
tableRows = [];
inTable = false;
}
function inline(s) {
return escHtml(s)
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
}
for (const raw of lines) {
const line = raw.trimEnd();
// Таблица
if (line.includes("|") && line.match(/^\s*\|/)) {
if (!inTable) { closeList(); inTable = true; }
const cells = line.split("|").slice(1, -1).map(s => s.trim());
tableRows.push(cells);
continue;
} else if (inTable) {
closeTable();
}
// Заголовки
if (line.startsWith("# ")) {
closeList();
out.push(`<h1>${inline(line.slice(2))}</h1>`);
} else if (line.startsWith("## ")) {
closeList();
out.push(`<h2>${inline(line.slice(3))}</h2>`);
} else if (line.startsWith("### ")) {
closeList();
out.push(`<h3>${inline(line.slice(4))}</h3>`);
} else if (line.startsWith("- ") || line.startsWith("* ")) {
if (!inList) { out.push("<ul>"); inList = true; }
let content = line.slice(2);
// [ ] checkbox
if (content.startsWith("[ ] ")) {
out.push(`<li><span class="cl-check">☐</span> ${inline(content.slice(4))}</li>`);
} else if (content.startsWith("[x] ") || content.startsWith("[X] ")) {
out.push(`<li><span class="cl-check checked">☑</span> ${inline(content.slice(4))}</li>`);
} else {
out.push(`<li>${inline(content)}</li>`);
}
} else if (line === "---") {
closeList();
out.push(`<hr>`);
} else if (line === "") {
closeList();
out.push("");
} else {
closeList();
out.push(`<p>${inline(line)}</p>`);
}
}
closeList();
closeTable();
return out.join("\n");
}
/* ===================== Submit ===================== */
async function onSubmit(node) {
@ -307,7 +494,6 @@ const Measurements = (function () {
btn.innerHTML = '<span class="spinner-inline"></span> сохраняем...';
result.innerHTML = "";
// Валидация: для новой записи нужны клиент + телефон + хотя бы 1 фото
const isUpdate = !!measurementId && prefilledClient;
if (!isUpdate) {
const name = (state.client_name || "").trim();
@ -334,8 +520,15 @@ const Measurements = (function () {
}
const measurement = {
// Структурированные фото + их типы
photos: photos.map(p => p.dataUrl),
photos_meta: photos.map(p => ({ kind: p.kind })),
// Общая инфа замера
zamer_no: state.zamer_no || "",
zamer_date: state.zamer_date || "",
floor_base: state.floor_base || "",
notes: state.notes || "",
// Клиент
client_name: isUpdate ? prefilledClient.name : state.client_name,
client_phone: isUpdate ? prefilledClient.phone : state.client_phone,
address: isUpdate ? prefilledClient.address : state.address,
@ -392,5 +585,5 @@ const Measurements = (function () {
}
function escAttr(s) { return escHtml(s); }
return { mount, reset };
return { mount, reset, kindLabel, PHOTO_KINDS };
})();

View File

@ -1989,6 +1989,94 @@
overflow-x: auto;
}
/* ===== Замер: фото с тегами ===== */
.podbor-header .podbor-help {
background: transparent;
border: none;
font-size: 18px;
cursor: pointer;
padding: 4px 8px;
width: 28px;
text-align: right;
}
.photo-list-tagged {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(108px, 1fr));
gap: 10px;
margin: 10px 0 16px;
}
.photo-tagged {
display: flex;
flex-direction: column;
gap: 6px;
}
.photo-tagged-thumb {
position: relative;
aspect-ratio: 1 / 1;
border-radius: 10px;
overflow: hidden;
background: var(--warm, rgba(107, 74, 43, 0.08));
border: 1px solid rgba(107, 74, 43, 0.15);
}
.photo-tagged-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.photo-tagged-thumb .photo-rm {
position: absolute;
top: 4px;
right: 4px;
width: 22px;
height: 22px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.55);
color: white;
font-size: 16px;
line-height: 1;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.photo-kind {
width: 100%;
padding: 5px 6px;
font-size: 12px;
border-radius: 6px;
border: 1px solid rgba(107, 74, 43, 0.25);
background: var(--paper, #FBF7F0);
color: var(--ink, #1F1A14);
font-family: var(--font-ui, "Inter", sans-serif);
}
/* ===== Чек-лист — страница markdown ===== */
.checklist-page .checklist-md {
font-family: var(--font-ui, "Inter", sans-serif);
font-size: 14px;
line-height: 1.55;
color: var(--ink, #1F1A14);
}
.checklist-md h1 { font-family: var(--font-display, "Newsreader", serif); font-size: 22px; margin: 20px 0 10px; font-style: italic; }
.checklist-md h2 { font-family: var(--font-display, "Newsreader", serif); font-size: 18px; margin: 18px 0 8px; }
.checklist-md h3 { font-size: 14.5px; font-weight: 600; margin: 14px 0 6px; }
.checklist-md p { margin: 6px 0; }
.checklist-md ul { margin: 6px 0 6px 6px; padding-left: 14px; }
.checklist-md li { margin: 3px 0; list-style: none; position: relative; padding-left: 22px; }
.checklist-md li::before { content: ""; position: absolute; left: 6px; top: 9px; width: 6px; height: 6px; background: var(--walnut, #6B4A2B); opacity: 0.45; border-radius: 50%; }
.checklist-md li .cl-check { position: absolute; left: 0; top: 0; font-size: 14px; color: var(--walnut, #6B4A2B); }
.checklist-md li .cl-check.checked { color: var(--accent-1, #003E7E); }
.checklist-md li:has(.cl-check)::before { display: none; }
.checklist-md hr { margin: 18px 0; border: none; border-top: 1px dashed rgba(107, 74, 43, 0.25); }
.checklist-md code { background: rgba(107, 74, 43, 0.08); padding: 1px 5px; border-radius: 3px; font-family: var(--font-mono, "JetBrains Mono", monospace); font-size: 12.5px; }
.checklist-md strong { color: var(--ink, #1F1A14); }
.checklist-md .cl-table { width: 100%; border-collapse: collapse; margin: 8px 0; font-size: 12.5px; }
.checklist-md .cl-table th, .checklist-md .cl-table td { border: 1px solid rgba(107, 74, 43, 0.18); padding: 4px 8px; text-align: left; }
.checklist-md .cl-table th { background: rgba(107, 74, 43, 0.08); font-weight: 600; }
/* ===== Кабинет сотрудника (замерщик/сборщик) ===== */
.staff-head {
display: flex;

View File

@ -0,0 +1,145 @@
# Чек-лист замера квартиры
Применяется ко всем фото-замерам. Цель — чтобы по фото без уточнений собирался корректный CAD/DXF и менеджер всё понял с первого взгляда.
---
## 1. Перед началом
- [ ] Завести папку `ЗАМЕРЫ/<номер> <адрес> - <кв>/` (как 157, 158…).
- [ ] Снимать каждую стену **отдельно**, кадр должен охватывать стену целиком, от пола до потолка.
- [ ] Имя файла фото = номер стены: `<N>_w1.jpg`, `<N>_w2.jpg`, … (либо переименовать после съёмки).
---
## 2. На каждой стене — обязательный минимум
### 2.1. Габариты
- [ ] Ширина по верху (от ЛУ до ПУ).
- [ ] Ширина по низу.
- [ ] При необходимости — ширина по середине (если стена «играет»).
- [ ] Высота слева (от пола до потолка).
- [ ] Высота справа.
### 2.2. Углы
- [ ] Угол в ЛВ (левый-верх): значение в градусах, например `89,9°`.
- [ ] Угол в ПВ (правый-верх).
- [ ] Угол в ЛН (левый-низ).
- [ ] Угол в ПН (правый-низ).
- [ ] Если угол не мерил — пиши `≈90°`, чтобы потом не угадывали.
### 2.3. База отсчёта
- [ ] Выбрать **один угол** для всех горизонтальных размеров на этой стене (обычно — общий физический угол со смежной стеной).
- [ ] Подписать в кадре: `БАЗА: ЛУ` или `БАЗА: ПУ`.
- [ ] Все горизонтали на этой стене — **только от этой базы**. Никаких цепочек между точками.
### 2.4. Вертикальная база
- [ ] Низ — `пол` (нулевой пол, обычно +88 мм над плитой).
- [ ] Верх`потолок`.
- [ ] Для каждой точки — указать, от пола или от потолка считаешь Y.
---
## 3. Точки замера — формат пина
Каждая точка = крест/кружок на стене + шильд со стрелкой. В шильде только **код**:
| Код | Что это |
|-------------|----------------------------------------|
| `R1, R2, …` | розетка одинарная |
| `R1×2`, `R1×3` | блок из 2/3 розеток в одной точке |
| `Rs1` | силовая розетка |
| `Sw1` | выключатель |
| `Ld1` | светильник (LED, бра, потолочный) |
| `V1` | вентиляция / вентбокс (+ габарит, например `200×200`) |
| `J1` | распаечная коробка |
| `Wc1` | вода холодная |
| `Wh1` | вода горячая |
| `D1` | слив / канализация (горизонт. выход) |
| `D1'` | фановый/вертикальный выход у того же узла |
| `Tv1` | ТВ |
| `Net1` | интернет |
Не писать «роз x1» — только код.
---
## 4. Размеры на каждой точке — два числа
- [ ] **Горизонталь** до угла-базы: число + (по желанию) буква базы, например `1087→ПУ`.
- [ ] **Вертикаль** до пола или потолка: `617↑пол` или `1919↓потолок`.
Не давать цепочек между точками. Если одна горизонталь общая для нескольких точек (R6/Wc1/Wh1 на 617) — пиши число один раз, а напротив каждой точки — её X.
---
## 5. Объёмные элементы (выпуски, ниши, колонны)
Развёртка плоская, глубина не показывается. Пиши в шильд так:
- [ ] Труба торчит из стены: `D1' выпуск 70` — значит выходит на 70 мм от плоскости стены. Стрелка от точки — косая.
- [ ] Ниша: `ниша 100×400 гл.50`.
- [ ] Колонна / выступ: `колонна +120` (выступает на 120 мм).
---
## 6. Проёмы (двери / окна / балкон)
- [ ] Обвести проём прямоугольником с диагональю.
- [ ] Подписать код: В1`, `ОК1`, К1`.
- [ ] Размеры:
- ширина проёма
- высота проёма (от пола)
- отступ от угла-базы стены до края проёма
- для окна — высота низа подоконника от пола
---
## 7. Стена «без элементов»
- [ ] Только габариты + углы.
- [ ] Подписать `пусто` или просто не ставить точки.
---
## 8. Общая информация (один раз на квартиру)
- [ ] Номер замера.
- [ ] Адрес, корпус, квартира.
- [ ] Дата замера.
- [ ] Толщина стяжки / нулевой пол (например, `0,000 = +88 мм над плитой`, стяжка 98 мм).
- [ ] План комнаты со стрелками-направлениями: какая стена 1/2/3/4 (особенно когда несколько помещений).
- [ ] Если стены смежные — отметить общий угол: «ПУ стены 2 = ЛУ стены 3».
---
## 9. Перед отправкой
- [ ] Проверить, что для каждой точки указаны и горизонталь, и вертикаль.
- [ ] Проверить, что все горизонтали на одной стене считаны от одного угла-базы.
- [ ] Отметить базы прямо на фото (`БАЗА: ПУ`).
- [ ] Углы в градусах подписаны (или пометка `≈90°`).
- [ ] Сложить все фото в папку замера.
---
## 10. Что я делаю с фото
1. Кладу всё в `<папка замера>/CAD/`.
2. Создаю DXF (R2018, мм) с тремя/четырьмя развёртками стен.
3. Каждая точка = крест + код в шильде + два размера до баз.
4. PNG и PDF превью для отправки менеджеру.
5. Скрипт `build_cad.py` остаётся в той же папке — можно править координаты и пересобирать.
---
## Пример (замер 157, кв. 411)
- Стена 1: пусто, только габариты (1149×2745).
- Стена 2: база — ПРАВЫЙ угол. R7, R5, R6, Wc1, Wh1, D1, D1', R8.
- Стена 3: база — ЛЕВЫЙ угол. V1, R4, Ld1, R1×2, Rs5, R3.
- ПУ стены 2 = ЛУ стены 3 (общий физический угол).

View File

@ -12,8 +12,8 @@
<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&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&display=swap">
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="stylesheet" href="assets/styles.css?v=20260513k">
<link rel="stylesheet" href="assets/podbor.css?v=20260513k">
<link rel="stylesheet" href="assets/styles.css?v=20260513l">
<link rel="stylesheet" href="assets/podbor.css?v=20260513l">
</head>
<body>
<!-- Splash — за пределами #app, render-функции его не смывают -->
@ -34,13 +34,13 @@
</div>
</div>
<main id="app"></main>
<script src="assets/icons.js?v=20260513k"></script>
<script src="assets/podbor.config.js?v=20260513k"></script>
<script src="assets/podbor.picts.js?v=20260513k"></script>
<script src="assets/podbor.js?v=20260513k"></script>
<script src="assets/clients.js?v=20260513k"></script>
<script src="assets/measurements.js?v=20260513k"></script>
<script src="assets/request.js?v=20260513k"></script>
<script src="assets/app.js?v=20260513k"></script>
<script src="assets/icons.js?v=20260513l"></script>
<script src="assets/podbor.config.js?v=20260513l"></script>
<script src="assets/podbor.picts.js?v=20260513l"></script>
<script src="assets/podbor.js?v=20260513l"></script>
<script src="assets/clients.js?v=20260513l"></script>
<script src="assets/measurements.js?v=20260513l"></script>
<script src="assets/request.js?v=20260513l"></script>
<script src="assets/app.js?v=20260513l"></script>
</body>
</html>