From 121927ab2dcfa5ddcef8fb8b73ff2047ec9f5bdc Mon Sep 17 00:00:00 2001
From: wasrusgen
Date: Wed, 13 May 2026 07:19:25 +0300
Subject: [PATCH] =?UTF-8?q?measurements:=20=D1=81=D1=82=D1=80=D1=83=D0=BA?=
=?UTF-8?q?=D1=82=D1=83=D1=80=D0=B0=20=D1=84=D0=BE=D1=82=D0=BE=20+=20?=
=?UTF-8?q?=D1=87=D0=B5=D0=BA-=D0=BB=D0=B8=D1=81=D1=82=20+=20=D0=BE=D0=B1?=
=?UTF-8?q?=D1=89=D0=B0=D1=8F=20=D0=B8=D0=BD=D1=84=D0=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
По чек-листу ЗАМЕРОВ (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.
---
backend-py/app/main.py | 78 +++++++--
miniapp/assets/measurements.js | 253 ++++++++++++++++++++++++++----
miniapp/assets/podbor.css | 88 +++++++++++
miniapp/assets/zamer-checklist.md | 145 +++++++++++++++++
miniapp/index.html | 20 +--
5 files changed, 535 insertions(+), 49 deletions(-)
create mode 100644 miniapp/assets/zamer-checklist.md
diff --git a/backend-py/app/main.py b/backend-py/app/main.py
index 506b1f0..90d629d 100644
--- a/backend-py/app/main.py
+++ b/backend-py/app/main.py
@@ -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//.`.
+
+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,7 +563,24 @@ 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)
- name = f"{idx}.{ext}"
+ 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
except Exception:
@@ -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", "")),
}
diff --git a/miniapp/assets/measurements.js b/miniapp/assets/measurements.js
index da46ef1..f0258e5 100644
--- a/miniapp/assets/measurements.js
+++ b/miniapp/assets/measurements.js
@@ -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= → 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 () {
`);
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 () {
${isUpdate ? "Фотос замера " : "Новыйзамер "}
${isUpdate
- ? "Загрузите фото от руки нарисованных замеров (а также общие фото помещения). Чертёж сделаем по ним отдельно."
- : "Заполните данные клиента и загрузите фото замера. Можно фотать рукописные эскизы — главное чтобы были видны размеры."}
-
+ ? "Загружайте фото по чек-листу — каждая стена отдельно. Чертёж сделаем по фото."
+ : "Заполните клиента, дату и загрузите фото по чек-листу. Откройте 📋 чтобы посмотреть как правильно снимать."}
- 📷 Фото замера
+ 📐 Общая информация
+
+
+ № замера
+
+
+
+ Дата замера
+
+
+
+
+
+ Стяжка / нулевой пол
+
+
+
+
+
+
+ Для каждого фото выберите тип. По чек-листу: каждая стена отдельно + план + общие виды.
+
+
Добавить фото
- камера или галерея · до 20 шт
+ камера или галерея · до 30 шт
-
+
Заметки (опционально)
-
+
@@ -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(`
@@ -210,7 +262,6 @@ const Measurements = (function () {
`);
- return block;
}
function bindInputs(node) {
@@ -222,17 +273,39 @@ 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 = `Ещё нет фото
`;
+ return;
+ }
photos.forEach((ph, idx) => {
const tile = el(`
-
-
-
×
+
+
+
+
×
+
+
+ ${PHOTO_KINDS.map(k =>
+ `${k.label} `
+ ).join("")}
+
`);
tile.querySelector(".photo-rm").addEventListener("click", e => {
@@ -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(`
+
+ `));
+ root.querySelector(".podbor-back").addEventListener("click", () => {
+ // Возврат к мастеру (если был открыт через #/measure?id=X)
+ if (measurementId) location.hash = `#/measure?id=${measurementId}`;
+ else location.hash = "#/measure";
+ });
+
+ const wrap = el(`
`);
+ root.appendChild(wrap);
+ wrap.appendChild(el(`
`));
+
+ try {
+ const res = await fetch("./assets/zamer-checklist.md", { cache: "no-cache" });
+ const md = await res.text();
+ wrap.innerHTML = `
${renderMarkdown(md)}
`;
+ } catch (e) {
+ wrap.innerHTML = `
Не удалось загрузить чек-лист: ${e.message}
`;
+ }
+ }
+
+ /* Минимальный 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(""); inList = false; } }
+ function closeTable() {
+ if (!inTable) return;
+ if (tableRows.length) {
+ const html = ["
"];
+ tableRows.forEach((cells, i) => {
+ const tag = i === 0 ? "th" : "td";
+ if (i === 1 && cells.every(c => /^[-:\s|]+$/.test(c))) return; // skip separator
+ html.push(`${cells.map(c => `<${tag}>${inline(c)}${tag}>`).join("")} `);
+ });
+ html.push("
");
+ out.push(html.join(""));
+ }
+ tableRows = [];
+ inTable = false;
+ }
+
+ function inline(s) {
+ return escHtml(s)
+ .replace(/`([^`]+)`/g, "
$1")
+ .replace(/\*\*([^*]+)\*\*/g, "
$1 ")
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '
$1 ');
+ }
+
+ 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(`
${inline(line.slice(2))} `);
+ } else if (line.startsWith("## ")) {
+ closeList();
+ out.push(`
${inline(line.slice(3))} `);
+ } else if (line.startsWith("### ")) {
+ closeList();
+ out.push(`
${inline(line.slice(4))} `);
+ } else if (line.startsWith("- ") || line.startsWith("* ")) {
+ if (!inList) { out.push("
"); inList = true; }
+ let content = line.slice(2);
+ // [ ] checkbox
+ if (content.startsWith("[ ] ")) {
+ out.push(`☐ ${inline(content.slice(4))} `);
+ } else if (content.startsWith("[x] ") || content.startsWith("[X] ")) {
+ out.push(`☑ ${inline(content.slice(4))} `);
+ } else {
+ out.push(`${inline(content)} `);
+ }
+ } else if (line === "---") {
+ closeList();
+ out.push(` `);
+ } else if (line === "") {
+ closeList();
+ out.push("");
+ } else {
+ closeList();
+ out.push(`${inline(line)}
`);
+ }
+ }
+ closeList();
+ closeTable();
+ return out.join("\n");
+ }
+
/* ===================== Submit ===================== */
async function onSubmit(node) {
@@ -307,7 +494,6 @@ const Measurements = (function () {
btn.innerHTML = ' сохраняем...';
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 };
})();
diff --git a/miniapp/assets/podbor.css b/miniapp/assets/podbor.css
index e609a0d..c30cba0 100644
--- a/miniapp/assets/podbor.css
+++ b/miniapp/assets/podbor.css
@@ -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;
diff --git a/miniapp/assets/zamer-checklist.md b/miniapp/assets/zamer-checklist.md
new file mode 100644
index 0000000..6d5b404
--- /dev/null
+++ b/miniapp/assets/zamer-checklist.md
@@ -0,0 +1,145 @@
+# Чек-лист замера квартиры
+
+Применяется ко всем фото-замерам. Цель — чтобы по фото без уточнений собирался корректный CAD/DXF и менеджер всё понял с первого взгляда.
+
+---
+
+## 1. Перед началом
+
+- [ ] Завести папку `ЗАМЕРЫ/<номер> <адрес> - <кв>/` (как 157, 158…).
+- [ ] Снимать каждую стену **отдельно**, кадр должен охватывать стену целиком, от пола до потолка.
+- [ ] Имя файла фото = номер стены: `_w1.jpg`, `_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 (общий физический угол).
diff --git a/miniapp/index.html b/miniapp/index.html
index d470c44..9eaab7c 100644
--- a/miniapp/index.html
+++ b/miniapp/index.html
@@ -12,8 +12,8 @@
-
-
+
+
@@ -34,13 +34,13 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+