mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 18:04:47 +00:00
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:
parent
121927ab2d
commit
a437b55447
@ -111,6 +111,7 @@ async def _dispatch_post(request: Request):
|
|||||||
"measurement_request": _handle_measurement_request,
|
"measurement_request": _handle_measurement_request,
|
||||||
"measurement_inbox": _handle_measurement_inbox,
|
"measurement_inbox": _handle_measurement_inbox,
|
||||||
"measurement_schedule": _handle_measurement_schedule,
|
"measurement_schedule": _handle_measurement_schedule,
|
||||||
|
"measurement_next_no": _handle_measurement_next_no,
|
||||||
"ping": lambda b: {"pong": True, "time": _now_iso()},
|
"ping": lambda b: {"pong": True, "time": _now_iso()},
|
||||||
"seed_admin": lambda b: _handle_seed_admin(),
|
"seed_admin": lambda b: _handle_seed_admin(),
|
||||||
"test_ai": lambda b: _handle_test_ai(),
|
"test_ai": lambda b: _handle_test_ai(),
|
||||||
@ -201,6 +202,12 @@ async def api_measurement_schedule(request: Request):
|
|||||||
return _handle_measurement_schedule(body)
|
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")
|
@app.post("/api/grant_role")
|
||||||
async def api_grant_role(request: Request):
|
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}
|
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:
|
def _format_date_human(iso: str) -> str:
|
||||||
"""ISO datetime → '15.05.2026 14:00' для уведомлений."""
|
"""ISO datetime → '15.05.2026 14:00' для уведомлений."""
|
||||||
if not iso:
|
if not iso:
|
||||||
|
|||||||
@ -44,10 +44,10 @@ const Measurements = (function () {
|
|||||||
client_phone: "",
|
client_phone: "",
|
||||||
address: "",
|
address: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
// Общая инфа замера (по чек-листу)
|
// Общая инфа замера. zamer_no подгружается из бэка автоматически,
|
||||||
|
// floor_base убран — он на самих фото с замером.
|
||||||
zamer_no: "",
|
zamer_no: "",
|
||||||
zamer_date: todayStr,
|
zamer_date: todayStr,
|
||||||
floor_base: "0,000 = +88 мм над плитой",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -170,19 +170,14 @@ const Measurements = (function () {
|
|||||||
<div class="form-row two-col">
|
<div class="form-row two-col">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field-label">№ замера</span>
|
<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>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field-label">Дата замера</span>
|
<span class="field-label">Дата замера</span>
|
||||||
<input type="date" data-bind="zamer_date" value="${escAttr(state.zamer_date)}">
|
<input type="date" data-bind="zamer_date" value="${escAttr(state.zamer_date)}">
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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;">
|
<div class="section-head" style="margin-top:18px;">
|
||||||
<span class="label">📷 Фото замера</span>
|
<span class="label">📷 Фото замера</span>
|
||||||
@ -224,9 +219,44 @@ const Measurements = (function () {
|
|||||||
location.hash = "#/measure/checklist";
|
location.hash = "#/measure/checklist";
|
||||||
});
|
});
|
||||||
node.querySelector("#submitBtn").addEventListener("click", () => onSubmit(node));
|
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;
|
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() {
|
function renderClientReadOnly() {
|
||||||
return el(`
|
return el(`
|
||||||
<div class="block">
|
<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() {
|
async function renderChecklist() {
|
||||||
root.innerHTML = "";
|
root.innerHTML = "";
|
||||||
root.appendChild(el(`
|
root.appendChild(el(`
|
||||||
<header class="podbor-header">
|
<header class="podbor-header">
|
||||||
<button class="podbor-back" aria-label="Назад">${ICONS.arrow_left || "‹"}</button>
|
<button class="podbor-back" aria-label="Назад">${ICONS.arrow_left || "‹"}</button>
|
||||||
<div class="podbor-title">Чек-лист замера</div>
|
<div class="podbor-title">Чек-лист замера</div>
|
||||||
<div style="width:28px"></div>
|
<button class="podbor-help" id="resetCl" aria-label="Сбросить">↺</button>
|
||||||
</header>
|
</header>
|
||||||
`));
|
`));
|
||||||
root.querySelector(".podbor-back").addEventListener("click", () => {
|
root.querySelector(".podbor-back").addEventListener("click", () => {
|
||||||
// Возврат к мастеру (если был открыт через #/measure?id=X)
|
|
||||||
if (measurementId) location.hash = `#/measure?id=${measurementId}`;
|
if (measurementId) location.hash = `#/measure?id=${measurementId}`;
|
||||||
else location.hash = "#/measure";
|
else location.hash = "#/measure";
|
||||||
});
|
});
|
||||||
@ -399,14 +443,63 @@ const Measurements = (function () {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch("./assets/zamer-checklist.md", { cache: "no-cache" });
|
const res = await fetch("./assets/zamer-checklist.md", { cache: "no-cache" });
|
||||||
const md = await res.text();
|
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) {
|
} catch (e) {
|
||||||
wrap.innerHTML = `<div class="error">Не удалось загрузить чек-лист: ${e.message}</div>`;
|
wrap.innerHTML = `<div class="error">Не удалось загрузить чек-лист: ${e.message}</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Минимальный markdown → HTML: заголовки, списки, таблицы, code */
|
function bindChecklistInteractions(wrap, clState) {
|
||||||
function renderMarkdown(md) {
|
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 lines = md.split("\n");
|
||||||
const out = [];
|
const out = [];
|
||||||
let inList = false;
|
let inList = false;
|
||||||
@ -461,11 +554,17 @@ const Measurements = (function () {
|
|||||||
} else if (line.startsWith("- ") || line.startsWith("* ")) {
|
} else if (line.startsWith("- ") || line.startsWith("* ")) {
|
||||||
if (!inList) { out.push("<ul>"); inList = true; }
|
if (!inList) { out.push("<ul>"); inList = true; }
|
||||||
let content = line.slice(2);
|
let content = line.slice(2);
|
||||||
// [ ] checkbox
|
// [ ] checkbox — делаем интерактивным с уникальным ключом
|
||||||
if (content.startsWith("[ ] ")) {
|
if (content.startsWith("[ ] ") || content.startsWith("[x] ") || content.startsWith("[X] ")) {
|
||||||
out.push(`<li><span class="cl-check">☐</span> ${inline(content.slice(4))}</li>`);
|
const text = content.slice(4);
|
||||||
} else if (content.startsWith("[x] ") || content.startsWith("[X] ")) {
|
// Ключ = первые 60 символов содержимого (для стабильности при edit)
|
||||||
out.push(`<li><span class="cl-check checked">☑</span> ${inline(content.slice(4))}</li>`);
|
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 {
|
} else {
|
||||||
out.push(`<li>${inline(content)}</li>`);
|
out.push(`<li>${inline(content)}</li>`);
|
||||||
}
|
}
|
||||||
@ -526,7 +625,6 @@ const Measurements = (function () {
|
|||||||
// Общая инфа замера
|
// Общая инфа замера
|
||||||
zamer_no: state.zamer_no || "",
|
zamer_no: state.zamer_no || "",
|
||||||
zamer_date: state.zamer_date || "",
|
zamer_date: state.zamer_date || "",
|
||||||
floor_base: state.floor_base || "",
|
|
||||||
notes: state.notes || "",
|
notes: state.notes || "",
|
||||||
// Клиент
|
// Клиент
|
||||||
client_name: isUpdate ? prefilledClient.name : state.client_name,
|
client_name: isUpdate ? prefilledClient.name : state.client_name,
|
||||||
|
|||||||
@ -2067,9 +2067,56 @@
|
|||||||
.checklist-md ul { margin: 6px 0 6px 6px; padding-left: 14px; }
|
.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 { 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::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 { position: absolute; left: 0; top: 0; font-size: 16px; color: var(--walnut, #6B4A2B); line-height: 1.3; }
|
||||||
.checklist-md li .cl-check.checked { color: var(--accent-1, #003E7E); }
|
|
||||||
.checklist-md li:has(.cl-check)::before { display: none; }
|
.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 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 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 strong { color: var(--ink, #1F1A14); }
|
||||||
|
|||||||
@ -12,8 +12,8 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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">
|
<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>
|
<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/styles.css?v=20260513m">
|
||||||
<link rel="stylesheet" href="assets/podbor.css?v=20260513l">
|
<link rel="stylesheet" href="assets/podbor.css?v=20260513m">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Splash — за пределами #app, render-функции его не смывают -->
|
<!-- Splash — за пределами #app, render-функции его не смывают -->
|
||||||
@ -34,13 +34,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<main id="app"></main>
|
<main id="app"></main>
|
||||||
<script src="assets/icons.js?v=20260513l"></script>
|
<script src="assets/icons.js?v=20260513m"></script>
|
||||||
<script src="assets/podbor.config.js?v=20260513l"></script>
|
<script src="assets/podbor.config.js?v=20260513m"></script>
|
||||||
<script src="assets/podbor.picts.js?v=20260513l"></script>
|
<script src="assets/podbor.picts.js?v=20260513m"></script>
|
||||||
<script src="assets/podbor.js?v=20260513l"></script>
|
<script src="assets/podbor.js?v=20260513m"></script>
|
||||||
<script src="assets/clients.js?v=20260513l"></script>
|
<script src="assets/clients.js?v=20260513m"></script>
|
||||||
<script src="assets/measurements.js?v=20260513l"></script>
|
<script src="assets/measurements.js?v=20260513m"></script>
|
||||||
<script src="assets/request.js?v=20260513l"></script>
|
<script src="assets/request.js?v=20260513m"></script>
|
||||||
<script src="assets/app.js?v=20260513l"></script>
|
<script src="assets/app.js?v=20260513m"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user