mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 17:04:48 +00:00
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:
parent
5c2a5bb335
commit
121927ab2d
@ -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,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", "")),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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,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 = `<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">
|
||||
<img src="${ph.dataUrl}" alt="фото ${idx + 1}">
|
||||
<button class="photo-rm" data-idx="${idx}" aria-label="Удалить">×</button>
|
||||
<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 => {
|
||||
@ -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 };
|
||||
})();
|
||||
|
||||
@ -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;
|
||||
|
||||
145
miniapp/assets/zamer-checklist.md
Normal file
145
miniapp/assets/zamer-checklist.md
Normal 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 (общий физический угол).
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user