/* ============================================================
Замер — структурированная загрузка фото по типам.
Типы фото: стена 1-4, план комнаты, общий вид, деталь.
============================================================ */
const Measurements = (function () {
const STORAGE_KEY = "zov-measurement-draft-v2";
// Типы фото — в соответствии с чек-листом ЗАМЕРОВ
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;
function loadState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
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 подгружается из бэка автоматически,
// floor_base убран — он на самих фото с замером.
zamer_no: "",
zamer_date: todayStr,
};
}
function saveState() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) {}
}
function reset() {
state = defaultState();
saveState();
photos = [];
prefilledClient = null;
}
/* ===================== Mount + Render ===================== */
function mount(container) {
root = container;
document.body.classList.remove("has-bottom-nav");
const oldNav = document.getElementById("bottom-nav");
if (oldNav) oldNav.remove();
photos = [];
measurementId = "";
prefilledClient = null;
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();
return;
}
render();
}
async function loadRequestAndStart() {
root.innerHTML = "";
root.appendChild(renderHeader("Закрыть заявку"));
root.appendChild(el(`
`));
try {
const res = await fetch(`${BACKEND_URL}/api/measurement_detail`, {
method: "POST",
body: JSON.stringify({
initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null,
measurement_id: measurementId,
}),
});
const data = await res.json();
if (data.error) {
root.innerHTML = "";
root.appendChild(renderHeader("Ошибка"));
root.appendChild(el(`${data.error}
`));
return;
}
prefilledClient = {
name: data.client_name || "",
phone: data.client_phone || "",
address: data.address || "",
};
render();
} catch (e) {
root.innerHTML = "";
root.appendChild(renderHeader("Ошибка"));
root.appendChild(el(`Сеть: ${e.message}
`));
}
}
function render() {
if (!root) return;
root.innerHTML = "";
root.appendChild(renderHeader(measurementId ? "Закрыть заявку" : "Новый замер"));
const screen = el(`
`);
root.appendChild(screen);
screen.appendChild(renderForm());
}
function renderHeader(title) {
const h = el(`
`);
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;
const clientBlock = isUpdate ? renderClientReadOnly() : renderClientInputs();
const node = el(`
${isUpdate ? "Фотос замера " : "Новыйзамер "}
${isUpdate
? "Загружайте фото по чек-листу — каждая стена отдельно. Чертёж сделаем по фото."
: "Заполните клиента, дату и загрузите фото по чек-листу. Откройте 📋 чтобы посмотреть как правильно снимать."}
📐 Общая информация
№ замера
Подбираем следующий…
Дата замера
Для каждого фото выберите тип. По чек-листу: каждая стена отдельно + план + общие виды.
+
Добавить фото
камера или галерея · до 30 шт
${isUpdate ? "Закрыть заявку" : "Сохранить замер"}
`);
node.querySelector("#clientBlock").appendChild(clientBlock);
bindInputs(node);
bindPhotoInput(node);
node.querySelector("#openChecklist2").addEventListener("click", () => {
location.hash = "#/measure/checklist";
});
node.querySelector("#submitBtn").addEventListener("click", () => onSubmit(node));
// Голосовой ввод заметок
setupVoiceMic(
node.querySelector("#zamerMic"),
node.querySelector("#zamerNotes"),
node.querySelector("#zamerMicStatus"),
(text) => { state.notes = text; saveState(); },
);
// Подгружаем следующий № замера если поле пустое
if (!state.zamer_no) {
fetchNextZamerNo(node);
} else {
const hint = node.querySelector("#zamerNoHint");
if (hint) hint.textContent = "Можно переписать вручную";
}
return node;
}
/* ===================== Голосовой ввод заметок ===================== */
// continuous=false + авто-рестарт по фразам — исключает дубли на Android/iOS Chrome
function setupVoiceMic(micBtn, textarea, statusEl, onChange) {
if (!micBtn || !textarea) return;
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) {
micBtn.disabled = true;
micBtn.title = "Браузер не поддерживает голосовой ввод";
micBtn.style.opacity = "0.5";
if (statusEl) statusEl.textContent = "недоступно в этом браузере";
return;
}
let active = false;
let baseText = "";
let curRec = null;
function startPhrase() {
let rec;
try {
rec = new SR();
rec.lang = "ru-RU";
rec.continuous = false;
rec.interimResults = true;
} catch (e) {
if (statusEl) statusEl.textContent = "Микрофон недоступен: " + e.message;
active = false; micBtn.classList.remove("rec"); micBtn.textContent = "🎤 Диктовать";
return;
}
curRec = rec;
rec.onresult = (ev) => {
let fin = "", itr = "";
for (let i = 0; i < ev.results.length; i++) {
const t = ev.results[i][0].transcript.trim();
if (!t) continue;
if (ev.results[i].isFinal) fin += (fin ? " " : "") + t;
else itr += (itr ? " " : "") + t;
}
const shown = fin || itr;
textarea.value = baseText + (baseText && shown ? " " : "") + shown;
};
rec.onend = () => {
baseText = textarea.value.trim();
if (active) {
startPhrase();
} else {
micBtn.classList.remove("rec");
micBtn.textContent = "🎤 Диктовать";
if (statusEl && statusEl.textContent === "Слушаю...") statusEl.textContent = "";
if (onChange) onChange(textarea.value || "");
haptic && haptic("impact");
}
};
rec.onerror = (ev) => {
if (ev.error === "no-speech") return;
if (statusEl) statusEl.textContent = "Ошибка: " + (ev.error || "неизвестно");
active = false; micBtn.classList.remove("rec"); micBtn.textContent = "🎤 Диктовать";
};
try { rec.start(); } catch (e) {
if (statusEl) statusEl.textContent = "Не запустить: " + e.message;
active = false; micBtn.classList.remove("rec"); micBtn.textContent = "🎤 Диктовать";
}
}
micBtn.addEventListener("click", () => {
if (active) {
active = false;
curRec?.stop();
return;
}
active = true;
baseText = (textarea.value || "").trim();
micBtn.classList.add("rec");
micBtn.textContent = "⏹ Стоп";
if (statusEl) statusEl.textContent = "Слушаю...";
haptic && haptic("impact");
startPhrase();
});
}
async function fetchNextZamerNo(node) {
try {
const res = await fetch(`${BACKEND_URL}/api/measurement_next_no`, {
method: "POST",
body: JSON.stringify({
initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null,
}),
});
const data = await res.json();
const hint = node.querySelector("#zamerNoHint");
const input = node.querySelector("#zamerNoInput");
if (data.ok && data.next_no && input && !state.zamer_no) {
input.value = String(data.next_no);
state.zamer_no = String(data.next_no);
saveState();
if (hint) hint.textContent = "Подобран автоматически — можно изменить";
} else if (hint) {
hint.textContent = "Введите номер вручную";
}
} catch (e) {
const hint = node.querySelector("#zamerNoHint");
if (hint) hint.textContent = "Введите номер вручную";
}
}
function renderClientReadOnly() {
return el(`
Клиент ${escHtml(prefilledClient.name || "—")}
${prefilledClient.phone ? `
Телефон ${escHtml(prefilledClient.phone)}
` : ""}
${prefilledClient.address ? `
Адрес ${escHtml(prefilledClient.address)}
` : ""}
`);
}
function renderClientInputs() {
return el(`
`);
}
function bindInputs(node) {
node.querySelectorAll("[data-bind]").forEach(inp => {
inp.addEventListener("input", e => {
state[e.target.dataset.bind] = e.target.value;
saveState();
});
});
}
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 => {
const i = +e.currentTarget.dataset.idx;
photos.splice(i, 1);
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);
});
}
input.addEventListener("change", async (e) => {
const files = Array.from(e.target.files || []);
input.value = "";
if (!files.length) return;
for (const f of files) {
if (photos.length >= 30) break;
if (!f.type || !f.type.startsWith("image/")) continue;
try {
const dataUrl = await compressImage(f, 1800, 0.78);
const kind = nextKindSuggestion();
photos.push({ dataUrl, kind });
} catch (err) {
console.warn("Не удалось сжать фото", err);
}
}
refreshList();
haptic && haptic("success");
});
refreshList();
}
function compressImage(file, maxSide = 1800, quality = 0.78) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = reject;
reader.onload = e => {
const img = new Image();
img.onerror = reject;
img.onload = () => {
let { width, height } = img;
if (width > maxSide || height > maxSide) {
if (width >= height) {
height = Math.round(height * maxSide / width);
width = maxSide;
} else {
width = Math.round(width * maxSide / height);
height = maxSide;
}
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
canvas.getContext("2d").drawImage(img, 0, 0, width, height);
try { resolve(canvas.toDataURL("image/jpeg", quality)); }
catch (err) { reject(err); }
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
});
}
/* ===================== Чек-лист — отдельный экран ===================== */
// Состояние галочек хранится в localStorage по measurement_id (или draft)
function checklistKey() {
return `zov-checklist-${measurementId || "draft"}`;
}
function loadChecklistState() {
try { return JSON.parse(localStorage.getItem(checklistKey()) || "{}"); }
catch (e) { return {}; }
}
function saveChecklistState(s) {
try { localStorage.setItem(checklistKey(), JSON.stringify(s)); } catch (e) {}
}
function resetChecklistDraft() {
try { localStorage.removeItem(`zov-checklist-draft`); } catch (e) {}
}
async function renderChecklist() {
root.innerHTML = "";
root.appendChild(el(`
`));
root.querySelector(".podbor-back").addEventListener("click", () => {
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();
const clState = loadChecklistState();
wrap.innerHTML = `
${renderMarkdown(md, clState)}
`;
bindChecklistInteractions(wrap, clState);
updateChecklistProgress(wrap, clState);
root.querySelector("#resetCl").addEventListener("click", () => {
if (!confirm("Сбросить все галочки?")) return;
const empty = {};
saveChecklistState(empty);
renderChecklist();
});
} catch (e) {
wrap.innerHTML = `Не удалось загрузить чек-лист: ${e.message}
`;
}
}
function bindChecklistInteractions(wrap, clState) {
wrap.querySelectorAll(".cl-item").forEach(item => {
item.addEventListener("click", () => {
const key = item.dataset.key;
if (!key) return;
const isChecked = item.classList.contains("checked");
const checkSpan = item.querySelector(".cl-check");
if (isChecked) {
item.classList.remove("checked");
if (checkSpan) checkSpan.textContent = "☐";
delete clState[key];
} else {
item.classList.add("checked");
if (checkSpan) checkSpan.textContent = "☑";
clState[key] = true;
}
saveChecklistState(clState);
updateChecklistProgress(wrap, clState);
haptic && haptic("impact");
});
});
}
function updateChecklistProgress(wrap, clState) {
const total = wrap.querySelectorAll(".cl-item").length;
const done = Object.keys(clState).filter(k => clState[k]).length;
const pct = total ? Math.round((done / total) * 100) : 0;
const bar = wrap.querySelector("#clProgress");
if (bar) {
bar.innerHTML = `
${done} из ${total} · ${pct}%
`;
}
}
/* Минимальный markdown → HTML: заголовки, списки, таблицы, code, чекбоксы */
function renderMarkdown(md, clState = {}) {
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();
// Директива @pict:KEY — вставляем SVG-эскиз из ZAMER_PICTS
const pictMatch = line.match(/^@pict:([a-z_]+)$/i);
if (pictMatch) {
closeList();
closeTable();
const key = pictMatch[1].toLowerCase();
const svg = (window.ZAMER_PICTS || {})[key];
if (svg) {
out.push(`${svg}
`);
}
continue;
}
// Таблица
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("