measurements: auto-suggest № замера, активные галочки чек-листа, убрана стяжка

1. № замера подбирается автоматически:
   - POST /api/measurement_next_no возвращает max(zamer_no) + 1
   - Wizard при открытии вызывает endpoint и заполняет input
   - Менеджер может переписать вручную (поле редактируемое)
   - Подпись «Подобран автоматически — можно изменить»

2. Поле «Стяжка / нулевой пол» удалено из формы:
   - По логике пользователя — стяжка пишется на самих фото с замером
   - Backend колонка floor_base остаётся для backward compat (старые записи)

3. Чек-лист стал интерактивным:
   - Каждый [ ] item теперь .cl-item с cursor:pointer
   - Тап переключает галочку (☐ ↔ ☑) + страйкаут текста
   - Состояние сохраняется в localStorage по measurement_id (или draft)
   - Sticky прогресс-бар сверху: «N из M · X%» + градиентная полоса
   - Кнопка ↺ в шапке — сбросить все галочки
   - Hapt-фидбэк на каждый тап

Cache bust v=20260513m.
This commit is contained in:
wasrusgen 2026-05-13 07:29:18 +03:00
parent 121927ab2d
commit a437b55447
4 changed files with 220 additions and 32 deletions

View File

@ -111,6 +111,7 @@ async def _dispatch_post(request: Request):
"measurement_request": _handle_measurement_request,
"measurement_inbox": _handle_measurement_inbox,
"measurement_schedule": _handle_measurement_schedule,
"measurement_next_no": _handle_measurement_next_no,
"ping": lambda b: {"pong": True, "time": _now_iso()},
"seed_admin": lambda b: _handle_seed_admin(),
"test_ai": lambda b: _handle_test_ai(),
@ -201,6 +202,12 @@ async def api_measurement_schedule(request: Request):
return _handle_measurement_schedule(body)
@app.post("/api/measurement_next_no")
async def api_measurement_next_no(request: Request):
body = await _safe_json(request)
return _handle_measurement_next_no(body)
@app.post("/api/grant_role")
async def api_grant_role(request: Request):
"""Админ выдаёт роль другому пользователю.
@ -1254,6 +1261,42 @@ def _handle_measurement_schedule(body: dict[str, Any]) -> dict[str, Any]:
return {"ok": True, "id": measurement_id, "status": "scheduled", "scheduled_at": scheduled_at}
def _handle_measurement_next_no(body: dict[str, Any]) -> dict[str, Any]:
"""Возвращает следующий свободный номер замера (max существующих + 1).
Если в Sheets ничего нет стартуем с 1. Менеджер может скорректировать вручную
(например первый раз поставить 158, если до этого замеры были вне системы)."""
cfg = get_config()
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
if not auth or not auth.get("user"):
unsafe = body.get("initDataUnsafe") or {}
if not (isinstance(unsafe, dict) and unsafe.get("user", {}).get("id")):
return {"error": "invalid_init_data"}
_ensure_measurements_sheet()
try:
ws = sheets.sheet("Measurements")
rows = ws.get_all_values()
except Exception:
return {"ok": True, "next_no": 1}
if not rows or len(rows) < 2:
return {"ok": True, "next_no": 1}
headers = rows[0]
if "zamer_no" not in headers:
return {"ok": True, "next_no": 1}
idx = headers.index("zamer_no")
max_n = 0
for r in rows[1:]:
if idx >= len(r):
continue
try:
n = int(str(r[idx]).strip())
if n > max_n:
max_n = n
except (ValueError, TypeError):
pass
return {"ok": True, "next_no": max_n + 1}
def _format_date_human(iso: str) -> str:
"""ISO datetime → '15.05.2026 14:00' для уведомлений."""
if not iso:

View File

@ -44,10 +44,10 @@ const Measurements = (function () {
client_phone: "",
address: "",
notes: "",
// Общая инфа замера (по чек-листу)
// Общая инфа замера. zamer_no подгружается из бэка автоматически,
// floor_base убран — он на самих фото с замером.
zamer_no: "",
zamer_date: todayStr,
floor_base: "0,000 = +88 мм над плитой",
};
}
@ -170,19 +170,14 @@ const Measurements = (function () {
<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">
<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="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>
@ -224,9 +219,44 @@ const Measurements = (function () {
location.hash = "#/measure/checklist";
});
node.querySelector("#submitBtn").addEventListener("click", () => onSubmit(node));
// Подгружаем следующий № замера если поле пустое
if (!state.zamer_no) {
fetchNextZamerNo(node);
} else {
const hint = node.querySelector("#zamerNoHint");
if (hint) hint.textContent = "Можно переписать вручную";
}
return node;
}
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(`
<div class="block">
@ -377,17 +407,31 @@ const Measurements = (function () {
/* ===================== Чек-лист — отдельный экран ===================== */
// Состояние галочек хранится в 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>
<div style="width:28px"></div>
<button class="podbor-help" id="resetCl" aria-label="Сбросить"></button>
</header>
`));
root.querySelector(".podbor-back").addEventListener("click", () => {
// Возврат к мастеру (если был открыт через #/measure?id=X)
if (measurementId) location.hash = `#/measure?id=${measurementId}`;
else location.hash = "#/measure";
});
@ -399,14 +443,63 @@ const Measurements = (function () {
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>`;
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>`;
}
}
/* Минимальный markdown → HTML: заголовки, списки, таблицы, code */
function renderMarkdown(md) {
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;
@ -461,11 +554,17 @@ const Measurements = (function () {
} 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>`);
// [ ] 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>`);
}
@ -526,7 +625,6 @@ const Measurements = (function () {
// Общая инфа замера
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,

View File

@ -2067,9 +2067,56 @@
.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 .cl-check { position: absolute; left: 0; top: 0; font-size: 16px; color: var(--walnut, #6B4A2B); line-height: 1.3; }
.checklist-md li:has(.cl-check)::before { display: none; }
/* Активный чекбокс — кликабельный с подсветкой */
.checklist-md .cl-item {
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
padding: 4px 6px 4px 22px;
border-radius: 6px;
transition: background 0.12s;
}
.checklist-md .cl-item:active { background: rgba(107, 74, 43, 0.10); }
.checklist-md .cl-item.checked { color: var(--muted, #998877); text-decoration: line-through; }
.checklist-md .cl-item.checked .cl-check {
color: var(--accent-1, #003E7E);
text-decoration: none;
display: inline-block;
}
/* Прогресс-бар чек-листа */
.checklist-progress {
position: sticky;
top: 0;
background: var(--paper, #FBF7F0);
padding: 10px 0 12px;
margin: -6px -6px 8px;
z-index: 5;
border-bottom: 1px solid rgba(107, 74, 43, 0.12);
}
.cl-pbar {
height: 3px;
background: rgba(107, 74, 43, 0.12);
border-radius: 2px;
overflow: hidden;
}
.cl-pbar-fill {
height: 100%;
background: linear-gradient(90deg, var(--walnut, #6B4A2B), var(--accent-1, #003E7E));
transition: width 0.25s ease;
}
.cl-pcount {
font-family: var(--font-mono, "JetBrains Mono", monospace);
font-size: 10.5px;
color: var(--muted, #998877);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-top: 6px;
text-align: right;
}
.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); }

View File

@ -12,8 +12,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap">
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="stylesheet" href="assets/styles.css?v=20260513l">
<link rel="stylesheet" href="assets/podbor.css?v=20260513l">
<link rel="stylesheet" href="assets/styles.css?v=20260513m">
<link rel="stylesheet" href="assets/podbor.css?v=20260513m">
</head>
<body>
<!-- Splash — за пределами #app, render-функции его не смывают -->
@ -34,13 +34,13 @@
</div>
</div>
<main id="app"></main>
<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>
<script src="assets/icons.js?v=20260513m"></script>
<script src="assets/podbor.config.js?v=20260513m"></script>
<script src="assets/podbor.picts.js?v=20260513m"></script>
<script src="assets/podbor.js?v=20260513m"></script>
<script src="assets/clients.js?v=20260513m"></script>
<script src="assets/measurements.js?v=20260513m"></script>
<script src="assets/request.js?v=20260513m"></script>
<script src="assets/app.js?v=20260513m"></script>
</body>
</html>