mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 18:24:49 +00:00
Кнопки больше не зависают бесконечно при медленном или недоступном бэкенде. AbortController + дружелюбное сообщение «Сервер не отвечает — попробуйте ещё раз». Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
989 lines
38 KiB
JavaScript
989 lines
38 KiB
JavaScript
/* ============================================================
|
||
Замер — структурированная загрузка фото по типам.
|
||
Типы фото: стена 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;
|
||
let pickedClient = 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;
|
||
pickedClient = 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;
|
||
pickedClient = 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 _fetchWithTimeout(url, body, timeoutMs = 15000) {
|
||
const ctrl = new AbortController();
|
||
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
||
try {
|
||
const res = await fetch(url, { method: "POST", signal: ctrl.signal, body: JSON.stringify(body) });
|
||
return await res.json();
|
||
} catch (e) {
|
||
if (e.name === "AbortError") throw new Error("Сервер не отвечает — попробуйте ещё раз");
|
||
throw e;
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
async function loadRequestAndStart() {
|
||
root.innerHTML = "";
|
||
root.appendChild(renderHeader("Закрыть заявку"));
|
||
root.appendChild(el(`<div class="loader-inline"><div class="spinner"></div></div>`));
|
||
try {
|
||
const data = await _fetchWithTimeout(`${BACKEND_URL}/api/measurement_detail`, {
|
||
initData: tg?.initData || "",
|
||
initDataUnsafe: tg?.initDataUnsafe || null,
|
||
measurement_id: measurementId,
|
||
});
|
||
if (data.error) {
|
||
root.innerHTML = "";
|
||
root.appendChild(renderHeader("Ошибка"));
|
||
root.appendChild(el(`<div class="error">${data.error}</div>`));
|
||
return;
|
||
}
|
||
prefilledClient = {
|
||
name: data.client_name || "",
|
||
phone: data.client_phone || "",
|
||
address: data.address || "",
|
||
};
|
||
render();
|
||
} catch (e) {
|
||
root.innerHTML = "";
|
||
root.appendChild(renderHeader("Ошибка"));
|
||
root.appendChild(el(`<div class="error">Сеть: ${e.message}</div>`));
|
||
}
|
||
}
|
||
|
||
function render() {
|
||
if (!root) return;
|
||
root.innerHTML = "";
|
||
root.appendChild(renderHeader(measurementId ? "Закрыть заявку" : "Новый замер"));
|
||
const screen = el(`<div class="podbor-screen"></div>`);
|
||
root.appendChild(screen);
|
||
screen.appendChild(renderForm());
|
||
}
|
||
|
||
function renderHeader(title) {
|
||
const h = el(`
|
||
<header class="podbor-header">
|
||
<button class="podbor-back" aria-label="Назад">${ICONS.arrow_left || "‹"}</button>
|
||
<div class="podbor-title">${escHtml(title)}</div>
|
||
<button class="podbor-help" id="openChecklist" aria-label="Чек-лист">📋</button>
|
||
</header>
|
||
`);
|
||
h.querySelector(".podbor-back").addEventListener("click", () => {
|
||
location.hash = "";
|
||
if (typeof routeByHash === "function") routeByHash();
|
||
});
|
||
h.querySelector("#openChecklist").addEventListener("click", () => {
|
||
location.hash = "#/measure/checklist";
|
||
});
|
||
return h;
|
||
}
|
||
|
||
/* ===================== Главный экран ===================== */
|
||
|
||
function renderForm() {
|
||
const isUpdate = !!measurementId && prefilledClient;
|
||
const clientBlock = isUpdate ? renderClientReadOnly() : renderClientPicker();
|
||
|
||
const node = el(`
|
||
<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>
|
||
|
||
<div id="clientBlock"></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" id="zamerNoInput" value="${escAttr(state.zamer_no)}" placeholder="…">
|
||
<span class="field-hint" id="zamerNoHint">Подбираем следующий…</span>
|
||
</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="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">камера или галерея · до 30 шт</span>
|
||
</label>
|
||
<input id="photoInput" type="file" accept="image/*" capture="environment" multiple hidden>
|
||
</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" id="zamerNotes" rows="3" placeholder="особенности доступа, газ/электро, что важно учесть">${escHtml(state.notes || "")}</textarea>
|
||
<div class="note-actions" style="margin-top:6px;">
|
||
<button class="btn-mic" id="zamerMic" type="button">🎤 Диктовать</button>
|
||
<span class="note-status" id="zamerMicStatus"></span>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="podbor-cta-row" style="margin-top:20px;">
|
||
<button class="btn-primary" id="submitBtn">${isUpdate ? "Закрыть заявку" : "Сохранить замер"}</button>
|
||
</div>
|
||
|
||
<div id="submitResult" class="submit-result"></div>
|
||
</section>
|
||
`);
|
||
|
||
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 data = await _fetchWithTimeout(`${BACKEND_URL}/api/measurement_next_no`, {
|
||
initData: tg?.initData || "",
|
||
initDataUnsafe: tg?.initDataUnsafe || null,
|
||
});
|
||
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(`
|
||
<div class="block">
|
||
<div class="kv"><span>Клиент</span> <strong>${escHtml(prefilledClient.name || "—")}</strong></div>
|
||
${prefilledClient.phone ? `<div class="kv"><span>Телефон</span> <strong>${escHtml(prefilledClient.phone)}</strong></div>` : ""}
|
||
${prefilledClient.address ? `<div class="kv"><span>Адрес</span> <strong>${escHtml(prefilledClient.address)}</strong></div>` : ""}
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
/* ===================== Пикер клиента ===================== */
|
||
|
||
function renderClientPicker() {
|
||
// Разбивает строку адреса на поля (локальная копия splitAddress из clients.js)
|
||
function _splitAddr(s) {
|
||
if (!s) return { city: "Санкт-Петербург", street: "", house: "", apt: "", entrance: "", floor: "" };
|
||
s = s.trim();
|
||
const grab = (re) => { const m = s.match(re); if (m) { s = s.replace(m[0], ""); return m[1]; } return ""; };
|
||
const floor = grab(/,\s*этаж\s+([^\s,]+)/i);
|
||
const entrance = grab(/,\s*подъезд\s+([^\s,]+)/i);
|
||
const apt = grab(/,\s*кв\.?\s*([^\s,]+)/i);
|
||
const house = grab(/,\s*д\.?\s*([^\s,]+)/i);
|
||
s = s.replace(/,$/, "").trim();
|
||
const parts = s.split(",").map(p => p.trim()).filter(Boolean);
|
||
let city = "", street = "";
|
||
if (parts.length >= 2) { city = parts[0]; street = parts.slice(1).join(", "); }
|
||
else if (parts.length === 1) { city = parts[0]; }
|
||
if (!city) city = "Санкт-Петербург";
|
||
return { city, street, house, apt, entrance, floor };
|
||
}
|
||
|
||
const initParts = _splitAddr(state.address || "");
|
||
|
||
const wrap = el(`
|
||
<div class="client-picker-wrap">
|
||
<div class="form-row" id="pcChoiceRow"></div>
|
||
<div class="form-row">
|
||
<span class="field-label">Адрес объекта</span>
|
||
<div class="addr-grid">
|
||
<label class="field">
|
||
<span class="field-sublabel">Город</span>
|
||
<input type="text" id="pcCity" value="${escAttr(initParts.city)}" placeholder="Санкт-Петербург" autocomplete="address-level2">
|
||
</label>
|
||
<label class="field">
|
||
<span class="field-sublabel">Улица</span>
|
||
<input type="text" id="pcStreet" value="${escAttr(initParts.street)}" placeholder="пр. Просвещения" autocomplete="street-address">
|
||
</label>
|
||
<label class="field addr-house">
|
||
<span class="field-sublabel">Дом</span>
|
||
<input type="text" id="pcHouse" value="${escAttr(initParts.house)}" placeholder="87" inputmode="text">
|
||
</label>
|
||
<label class="field addr-apt">
|
||
<span class="field-sublabel">Кв./офис</span>
|
||
<input type="text" id="pcApt" value="${escAttr(initParts.apt)}" placeholder="12" inputmode="numeric">
|
||
</label>
|
||
<label class="field addr-entrance">
|
||
<span class="field-sublabel">Подъезд</span>
|
||
<input type="text" id="pcEntrance" value="${escAttr(initParts.entrance)}" placeholder="1" inputmode="numeric">
|
||
</label>
|
||
<label class="field addr-floor">
|
||
<span class="field-sublabel">Этаж</span>
|
||
<input type="text" id="pcFloor" value="${escAttr(initParts.floor)}" placeholder="3" inputmode="numeric">
|
||
</label>
|
||
</div>
|
||
<span class="field-error" id="pcAddrErr"></span>
|
||
</div>
|
||
</div>
|
||
`);
|
||
|
||
const choiceRow = wrap.querySelector("#pcChoiceRow");
|
||
|
||
function readAndSaveAddr() {
|
||
const city = (wrap.querySelector("#pcCity").value || "").trim();
|
||
const street = (wrap.querySelector("#pcStreet").value || "").trim();
|
||
const house = (wrap.querySelector("#pcHouse").value || "").trim();
|
||
const apt = (wrap.querySelector("#pcApt").value || "").trim();
|
||
const entrance = (wrap.querySelector("#pcEntrance").value || "").trim();
|
||
const floor = (wrap.querySelector("#pcFloor").value || "").trim();
|
||
state.address = [
|
||
city, street,
|
||
house ? "д. " + house : "",
|
||
apt ? "кв. " + apt : "",
|
||
entrance ? "подъезд " + entrance : "",
|
||
floor ? "этаж " + floor : "",
|
||
].filter(Boolean).join(", ");
|
||
saveState();
|
||
}
|
||
|
||
function fillAddrFields(address) {
|
||
const p = _splitAddr(address || "");
|
||
wrap.querySelector("#pcCity").value = p.city;
|
||
wrap.querySelector("#pcStreet").value = p.street;
|
||
wrap.querySelector("#pcHouse").value = p.house;
|
||
wrap.querySelector("#pcApt").value = p.apt;
|
||
wrap.querySelector("#pcEntrance").value = p.entrance;
|
||
wrap.querySelector("#pcFloor").value = p.floor;
|
||
readAndSaveAddr();
|
||
}
|
||
|
||
["#pcCity","#pcStreet","#pcHouse","#pcApt","#pcEntrance","#pcFloor"].forEach(sel => {
|
||
wrap.querySelector(sel).addEventListener("input", readAndSaveAddr);
|
||
});
|
||
|
||
function refresh() {
|
||
choiceRow.innerHTML = "";
|
||
if (pickedClient) {
|
||
const card = el(`
|
||
<div class="picker-chosen-card">
|
||
<div class="picker-chosen-info">
|
||
<div class="picker-chosen-name">${escHtml(pickedClient.client_name)}</div>
|
||
<div class="picker-chosen-sub">
|
||
${escHtml(pickedClient.client_phone || "")}${pickedClient.contract_no ? " · дог. " + escHtml(pickedClient.contract_no) : ""}
|
||
</div>
|
||
</div>
|
||
<button class="picker-change-btn" type="button">Изменить</button>
|
||
</div>
|
||
`);
|
||
card.querySelector(".picker-change-btn").addEventListener("click", openOverlay);
|
||
choiceRow.appendChild(card);
|
||
if (!state.address && pickedClient.address) {
|
||
fillAddrFields(pickedClient.address);
|
||
}
|
||
} else {
|
||
const empty = el(`
|
||
<div class="picker-empty-state">
|
||
<button class="picker-open-btn" type="button">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
||
stroke-linecap="round" stroke-linejoin="round" width="18" height="18" aria-hidden="true">
|
||
<circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/>
|
||
</svg>
|
||
Выбрать клиента из CRM
|
||
</button>
|
||
<span class="field-error" id="nameError"></span>
|
||
</div>
|
||
`);
|
||
empty.querySelector(".picker-open-btn").addEventListener("click", openOverlay);
|
||
choiceRow.appendChild(empty);
|
||
}
|
||
}
|
||
|
||
async function openOverlay() {
|
||
const overlay = el(`
|
||
<div class="client-picker-overlay">
|
||
<div class="picker-sheet">
|
||
<div class="picker-sheet-head">
|
||
<span class="picker-sheet-title">Выбор клиента</span>
|
||
<button class="picker-close-btn" type="button">✕</button>
|
||
</div>
|
||
<div class="picker-search-wrap">
|
||
<input class="picker-search" type="search"
|
||
placeholder="Поиск по ФИО или телефону…" autocomplete="off">
|
||
</div>
|
||
<div class="picker-list" id="pcList">
|
||
<div class="picker-loading">Загружаем список…</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`);
|
||
document.body.appendChild(overlay);
|
||
requestAnimationFrame(() => overlay.classList.add("open"));
|
||
|
||
const listEl = overlay.querySelector("#pcList");
|
||
const searchEl = overlay.querySelector(".picker-search");
|
||
|
||
function closeOverlay() {
|
||
overlay.classList.remove("open");
|
||
setTimeout(() => overlay.remove(), 220);
|
||
}
|
||
overlay.querySelector(".picker-close-btn").addEventListener("click", closeOverlay);
|
||
overlay.addEventListener("click", e => { if (e.target === overlay) closeOverlay(); });
|
||
|
||
let clients = [];
|
||
try {
|
||
const data = await _fetchWithTimeout(`${BACKEND_URL}/api/clients`, {
|
||
initData: tg?.initData || "",
|
||
initDataUnsafe: tg?.initDataUnsafe || null,
|
||
});
|
||
clients = (data.clients || []).sort((a, b) =>
|
||
(a.client_name || "").localeCompare(b.client_name || "", "ru")
|
||
);
|
||
} catch (e) {
|
||
listEl.innerHTML = `<div class="picker-error">Ошибка загрузки: ${escHtml(e.message)}</div>`;
|
||
return;
|
||
}
|
||
|
||
function renderList(q) {
|
||
q = (q || "").toLowerCase().trim();
|
||
const filtered = q
|
||
? clients.filter(c =>
|
||
(c.client_name || "").toLowerCase().includes(q) ||
|
||
(c.client_phone || "").replace(/\D/g, "").includes(q.replace(/\D/g, ""))
|
||
)
|
||
: clients;
|
||
listEl.innerHTML = "";
|
||
if (!filtered.length) {
|
||
listEl.innerHTML = `<div class="picker-empty-msg">${q ? "Ничего не найдено" : "Список клиентов пуст"}</div>`;
|
||
return;
|
||
}
|
||
filtered.forEach(c => {
|
||
const row = el(`
|
||
<div class="picker-row">
|
||
<div class="picker-row-name">${escHtml(c.client_name || "—")}</div>
|
||
<div class="picker-row-sub">
|
||
${c.client_phone ? `<span>${escHtml(c.client_phone)}</span>` : ""}
|
||
${c.contract_no ? `<span class="picker-contract">дог. ${escHtml(c.contract_no)}</span>` : ""}
|
||
</div>
|
||
</div>
|
||
`);
|
||
row.addEventListener("click", () => {
|
||
pickedClient = {
|
||
client_name: c.client_name || "",
|
||
client_phone: c.client_phone || "",
|
||
address: c.address || "",
|
||
contract_no: c.contract_no || "",
|
||
client_no: c.client_no || "",
|
||
};
|
||
closeOverlay();
|
||
refresh();
|
||
});
|
||
listEl.appendChild(row);
|
||
});
|
||
}
|
||
|
||
renderList("");
|
||
searchEl.focus();
|
||
searchEl.addEventListener("input", () => renderList(searchEl.value));
|
||
}
|
||
|
||
refresh();
|
||
return wrap;
|
||
}
|
||
|
||
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 = `<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-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;
|
||
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(`
|
||
<header class="podbor-header">
|
||
<button class="podbor-back" aria-label="Назад">${ICONS.arrow_left || "‹"}</button>
|
||
<div class="podbor-title">Чек-лист замера</div>
|
||
<button class="podbor-help" id="resetCl" aria-label="Сбросить">↺</button>
|
||
</header>
|
||
`));
|
||
root.querySelector(".podbor-back").addEventListener("click", () => {
|
||
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();
|
||
const clState = loadChecklistState();
|
||
wrap.innerHTML = `
|
||
<div class="checklist-progress" id="clProgress"></div>
|
||
<div class="checklist-md">${renderMarkdown(md, clState)}</div>
|
||
`;
|
||
bindChecklistInteractions(wrap, clState);
|
||
updateChecklistProgress(wrap, clState);
|
||
|
||
root.querySelector("#resetCl").addEventListener("click", () => {
|
||
if (!confirm("Сбросить все галочки?")) return;
|
||
const empty = {};
|
||
saveChecklistState(empty);
|
||
renderChecklist();
|
||
});
|
||
} catch (e) {
|
||
wrap.innerHTML = `<div class="error">Не удалось загрузить чек-лист: ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
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 = `
|
||
<div class="cl-pbar"><div class="cl-pbar-fill" style="width:${pct}%"></div></div>
|
||
<div class="cl-pcount">${done} из ${total} · ${pct}%</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
/* Минимальный 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("</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();
|
||
// Директива @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(`<div class="cl-pict">${svg}</div>`);
|
||
}
|
||
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(`<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("[ ] ") || content.startsWith("[x] ") || content.startsWith("[X] ")) {
|
||
const text = content.slice(4);
|
||
// Ключ = первые 60 символов содержимого (для стабильности при edit)
|
||
const key = "cl_" + text.replace(/[^\wа-яА-ЯёЁ]+/g, "_").slice(0, 60).toLowerCase();
|
||
const checked = !!clState[key];
|
||
out.push(
|
||
`<li class="cl-item${checked ? " checked" : ""}" data-key="${key}">` +
|
||
`<span class="cl-check">${checked ? "☑" : "☐"}</span> ${inline(text)}` +
|
||
`</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) {
|
||
const btn = node.querySelector("#submitBtn");
|
||
const result = node.querySelector("#submitResult");
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="spinner-inline"></span> сохраняем...';
|
||
result.innerHTML = "";
|
||
|
||
const isUpdate = !!measurementId && prefilledClient;
|
||
if (!isUpdate) {
|
||
if (!pickedClient) {
|
||
const nameErr = node.querySelector("#nameError");
|
||
if (nameErr) {
|
||
nameErr.textContent = "Выберите клиента из списка";
|
||
nameErr.scrollIntoView({ behavior: "smooth", block: "center" });
|
||
}
|
||
btn.disabled = false; btn.textContent = "Сохранить замер";
|
||
return;
|
||
}
|
||
}
|
||
if (!photos.length) {
|
||
result.innerHTML = `<div class="error">Добавьте хотя бы одно фото замера.</div>`;
|
||
btn.disabled = false; btn.textContent = isUpdate ? "Закрыть заявку" : "Сохранить замер";
|
||
return;
|
||
}
|
||
|
||
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 || "",
|
||
notes: state.notes || "",
|
||
// Клиент
|
||
client_name: isUpdate ? prefilledClient.name : pickedClient.client_name,
|
||
client_phone: isUpdate ? prefilledClient.phone : pickedClient.client_phone,
|
||
address: isUpdate ? prefilledClient.address : state.address,
|
||
measurement_id: measurementId || undefined,
|
||
};
|
||
|
||
try {
|
||
const data = await _fetchWithTimeout(`${BACKEND_URL}/api/measurement`, {
|
||
initData: tg?.initData || "",
|
||
initDataUnsafe: tg?.initDataUnsafe || null,
|
||
measurement,
|
||
});
|
||
if (data.error) {
|
||
result.innerHTML = `<div class="error">Ошибка: ${data.error}</div>`;
|
||
btn.disabled = false; btn.textContent = isUpdate ? "Закрыть заявку" : "Сохранить замер";
|
||
return;
|
||
}
|
||
haptic && haptic("success");
|
||
result.innerHTML = `
|
||
<div class="success">
|
||
<div class="success-icon">${ICONS.check}</div>
|
||
<div>
|
||
<div class="success-title">${isUpdate ? "Заявка закрыта" : "Замер сохранён"}</div>
|
||
<div class="success-sub">${photos.length} фото · ID #${(data.id || "").slice(0, 6)}</div>
|
||
</div>
|
||
</div>
|
||
<div class="podbor-cta-row" style="margin-top:16px;">
|
||
<button class="btn-secondary" id="newOne">Ещё замер</button>
|
||
<button class="btn-primary" id="toHome">На главную</button>
|
||
</div>
|
||
`;
|
||
reset();
|
||
node.querySelector("#newOne")?.addEventListener("click", () => mount(root));
|
||
node.querySelector("#toHome")?.addEventListener("click", () => {
|
||
location.hash = "";
|
||
if (typeof routeByHash === "function") routeByHash();
|
||
});
|
||
} catch (e) {
|
||
result.innerHTML = `<div class="error">Сеть: ${e.message}</div>`;
|
||
btn.disabled = false; btn.textContent = isUpdate ? "Закрыть заявку" : "Сохранить замер";
|
||
}
|
||
}
|
||
|
||
/* ===================== Helpers ===================== */
|
||
|
||
function escHtml(s) {
|
||
return String(s == null ? "" : s)
|
||
.replace(/&/g, "&").replace(/</g, "<")
|
||
.replace(/>/g, ">").replace(/"/g, """);
|
||
}
|
||
function escAttr(s) { return escHtml(s); }
|
||
|
||
return { mount, reset, kindLabel, PHOTO_KINDS };
|
||
})();
|