mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 18:04:47 +00:00
feat: client picker in measurement form, address geocoding, edit-client 4-field addr
- measurements.js: replace manual name/phone inputs with client picker overlay (search by ФИО or phone, sorted alphabetically, picks from /api/clients) - clients.js: new-client and edit-client forms now use 4 split address fields (город, улица, дом, кв./офис) with geocode validation on save - clients.js: add splitAddress() helper to pre-fill edit form from stored address - clients.js: voice engine refactored (continuous=false + auto-restart, no duplication) - clients.js: note block view/edit toggle (textarea closes after save) - podbor.css: styles for picker overlay, picker-row, chosen-card, addr-grid, geo-status - backend main.py: _handle_client_create and _handle_client_update accept gps_lat/gps_lng - index.html: cache bump → v=20260514i Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cbea202de5
commit
44799362c1
@ -2119,6 +2119,8 @@ def _handle_client_create(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
note = (body.get("note") or "").strip()
|
note = (body.get("note") or "").strip()
|
||||||
contract_no = (body.get("contract_no") or "").strip()
|
contract_no = (body.get("contract_no") or "").strip()
|
||||||
contract_date = (body.get("contract_date") or "").strip()
|
contract_date = (body.get("contract_date") or "").strip()
|
||||||
|
gps_lat = body.get("gps_lat")
|
||||||
|
gps_lng = body.get("gps_lng")
|
||||||
|
|
||||||
# Валидация
|
# Валидация
|
||||||
if not full_name:
|
if not full_name:
|
||||||
@ -2152,6 +2154,8 @@ def _handle_client_create(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
client_no=str(client_no),
|
client_no=str(client_no),
|
||||||
contract_no=contract_no,
|
contract_no=contract_no,
|
||||||
contract_date=contract_date,
|
contract_date=contract_date,
|
||||||
|
gps_lat=gps_lat,
|
||||||
|
gps_lng=gps_lng,
|
||||||
))
|
))
|
||||||
|
|
||||||
# Сохраняем заметку в ClientNotes если она передана
|
# Сохраняем заметку в ClientNotes если она передана
|
||||||
@ -2303,6 +2307,8 @@ def _handle_client_update(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
new_address = body.get("address")
|
new_address = body.get("address")
|
||||||
new_contract_no = body.get("contract_no")
|
new_contract_no = body.get("contract_no")
|
||||||
new_contract_date = body.get("contract_date")
|
new_contract_date = body.get("contract_date")
|
||||||
|
new_gps_lat = body.get("gps_lat")
|
||||||
|
new_gps_lng = body.get("gps_lng")
|
||||||
|
|
||||||
if new_name and len(new_name) < 2:
|
if new_name and len(new_name) < 2:
|
||||||
return {"error": "bad_name", "msg": "Имя слишком короткое"}
|
return {"error": "bad_name", "msg": "Имя слишком короткое"}
|
||||||
@ -2337,6 +2343,8 @@ def _handle_client_update(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
address_col = col_idx("address")
|
address_col = col_idx("address")
|
||||||
contract_no_col = col_idx("contract_no")
|
contract_no_col = col_idx("contract_no")
|
||||||
contract_date_col = col_idx("contract_date")
|
contract_date_col = col_idx("contract_date")
|
||||||
|
gps_lat_col = col_idx("gps_lat")
|
||||||
|
gps_lng_col = col_idx("gps_lng")
|
||||||
|
|
||||||
updated = 0
|
updated = 0
|
||||||
for i, r in enumerate(rows[1:], start=2):
|
for i, r in enumerate(rows[1:], start=2):
|
||||||
@ -2357,6 +2365,10 @@ def _handle_client_update(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
ws.update_cell(i, contract_no_col, new_contract_no.strip())
|
ws.update_cell(i, contract_no_col, new_contract_no.strip())
|
||||||
if isinstance(new_contract_date, str) and contract_date_col:
|
if isinstance(new_contract_date, str) and contract_date_col:
|
||||||
ws.update_cell(i, contract_date_col, new_contract_date.strip())
|
ws.update_cell(i, contract_date_col, new_contract_date.strip())
|
||||||
|
if new_gps_lat is not None and gps_lat_col:
|
||||||
|
ws.update_cell(i, gps_lat_col, new_gps_lat)
|
||||||
|
if new_gps_lng is not None and gps_lng_col:
|
||||||
|
ws.update_cell(i, gps_lng_col, new_gps_lng)
|
||||||
updated += 1
|
updated += 1
|
||||||
|
|
||||||
sheets.log_event("client_updated", tg_id, {"client_key": client_key, "updated": updated})
|
sheets.log_event("client_updated", tg_id, {"client_key": client_key, "updated": updated})
|
||||||
|
|||||||
@ -58,12 +58,27 @@ const Clients = (function () {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label class="field">
|
<span class="field-label">Адрес *</span>
|
||||||
<span class="field-label">Адрес</span>
|
<div class="addr-grid">
|
||||||
<input type="text" id="ad" placeholder="СПб, Просвещения 87, кв. 12">
|
<label class="field">
|
||||||
<span class="field-hint" id="addrHint">Укажите город, улицу, дом, кв.</span>
|
<span class="field-sublabel">Город</span>
|
||||||
<span class="field-error" id="errAddr"></span>
|
<input type="text" id="ad_city" placeholder="Санкт-Петербург" value="Санкт-Петербург" autocomplete="address-level2">
|
||||||
</label>
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-sublabel">Улица</span>
|
||||||
|
<input type="text" id="ad_street" placeholder="пр. Просвещения" autocomplete="street-address">
|
||||||
|
</label>
|
||||||
|
<label class="field addr-house">
|
||||||
|
<span class="field-sublabel">Дом</span>
|
||||||
|
<input type="text" id="ad_house" placeholder="87" inputmode="text">
|
||||||
|
</label>
|
||||||
|
<label class="field addr-apt">
|
||||||
|
<span class="field-sublabel">Кв./офис</span>
|
||||||
|
<input type="text" id="ad_apt" placeholder="12" inputmode="numeric">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<span class="field-error" id="errAddr"></span>
|
||||||
|
<div class="geo-status" id="geoStatus"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row two-col">
|
<div class="form-row two-col">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
@ -116,14 +131,21 @@ const Clients = (function () {
|
|||||||
const e = form.querySelector("#" + id);
|
const e = form.querySelector("#" + id);
|
||||||
if (e) e.textContent = "";
|
if (e) e.textContent = "";
|
||||||
});
|
});
|
||||||
const name = (form.querySelector("#fn").value || "").trim();
|
const name = (form.querySelector("#fn").value || "").trim();
|
||||||
const phoneRaw = (form.querySelector("#ph").value || "").trim();
|
const phoneRaw = (form.querySelector("#ph").value || "").trim();
|
||||||
const address = (form.querySelector("#ad").value || "").trim();
|
const adCity = (form.querySelector("#ad_city").value || "").trim();
|
||||||
const note = (form.querySelector("#nt").value || "").trim();
|
const adStreet = (form.querySelector("#ad_street").value|| "").trim();
|
||||||
const contract_no = (form.querySelector("#cn").value || "").trim();
|
const adHouse = (form.querySelector("#ad_house").value || "").trim();
|
||||||
const contract_date = (form.querySelector("#cd").value || "").trim();
|
const adApt = (form.querySelector("#ad_apt").value || "").trim();
|
||||||
|
const note = (form.querySelector("#nt").value || "").trim();
|
||||||
|
const contract_no = (form.querySelector("#cn").value || "").trim();
|
||||||
|
const contract_date = (form.querySelector("#cd").value || "").trim();
|
||||||
|
|
||||||
// Валидация на клиенте
|
// Собираем адрес из полей
|
||||||
|
const address = [adCity, adStreet, adHouse ? "д. " + adHouse : "", adApt ? "кв. " + adApt : ""]
|
||||||
|
.filter(Boolean).join(", ");
|
||||||
|
|
||||||
|
// Валидация
|
||||||
if (!name || name.length < 2) {
|
if (!name || name.length < 2) {
|
||||||
form.querySelector("#errName").textContent = "Имя обязательно (минимум 2 символа)";
|
form.querySelector("#errName").textContent = "Имя обязательно (минимум 2 символа)";
|
||||||
return;
|
return;
|
||||||
@ -134,12 +156,39 @@ const Clients = (function () {
|
|||||||
"Введите корректный российский номер (+7XXXXXXXXXX или 8XXXXXXXXXX)";
|
"Введите корректный российский номер (+7XXXXXXXXXX или 8XXXXXXXXXX)";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (address && address.length < 5) {
|
if (!adCity || !adStreet || !adHouse) {
|
||||||
form.querySelector("#errAddr").textContent = "Адрес слишком короткий — нужны улица + дом";
|
form.querySelector("#errAddr").textContent = "Укажите город, улицу и номер дома";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
btn.textContent = "Проверяем адрес…";
|
||||||
|
|
||||||
|
// Геокодирование — проверяем адрес, продолжаем даже при неудаче
|
||||||
|
let gps_lat = null, gps_lng = null;
|
||||||
|
const geoEl = form.querySelector("#geoStatus");
|
||||||
|
try {
|
||||||
|
const geoRes = await fetch(`${BACKEND_URL}/api/geocode`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
initData: tg?.initData || "",
|
||||||
|
initDataUnsafe: tg?.initDataUnsafe || null,
|
||||||
|
address: `${adCity}, ${adStreet}, д. ${adHouse}`,
|
||||||
|
city: adCity,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const geoData = await geoRes.json();
|
||||||
|
if (geoData.ok && geoData.result) {
|
||||||
|
gps_lat = geoData.result.lat;
|
||||||
|
gps_lng = geoData.result.lng;
|
||||||
|
geoEl.innerHTML = `<span class="geo-ok">✓ ${escHtml(geoData.result.formatted || address)}</span>`;
|
||||||
|
} else {
|
||||||
|
geoEl.innerHTML = `<span class="geo-warn">⚠ Адрес не найден в геокодере — проверьте написание. Сохраняем без координат.</span>`;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
geoEl.innerHTML = `<span class="geo-warn">⚠ Геокодер недоступен. Сохраняем без координат.</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
btn.textContent = "Сохраняем...";
|
btn.textContent = "Сохраняем...";
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BACKEND_URL}/api/client_create`, {
|
const res = await fetch(`${BACKEND_URL}/api/client_create`, {
|
||||||
@ -148,7 +197,7 @@ const Clients = (function () {
|
|||||||
initData: tg?.initData || "",
|
initData: tg?.initData || "",
|
||||||
initDataUnsafe: tg?.initDataUnsafe || null,
|
initDataUnsafe: tg?.initDataUnsafe || null,
|
||||||
full_name: name, phone: norm.value, address, note,
|
full_name: name, phone: norm.value, address, note,
|
||||||
contract_no, contract_date,
|
contract_no, contract_date, gps_lat, gps_lng,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@ -191,6 +240,25 @@ const Clients = (function () {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Разбирает сохранённый адрес «Город, Улица, д. NN, кв. MM» обратно в поля.
|
||||||
|
function splitAddress(combined) {
|
||||||
|
if (!combined) return { city: "Санкт-Петербург", street: "", house: "", apt: "" };
|
||||||
|
let s = combined.trim();
|
||||||
|
let apt = "";
|
||||||
|
const aptMatch = s.match(/,\s*кв\.?\s*([^\s,]+)/i);
|
||||||
|
if (aptMatch) { apt = aptMatch[1]; s = s.replace(aptMatch[0], ""); }
|
||||||
|
let house = "";
|
||||||
|
const houseMatch = s.match(/,\s*д\.?\s*([^\s,]+)/i);
|
||||||
|
if (houseMatch) { house = houseMatch[1]; s = s.replace(houseMatch[0], ""); }
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePhone(raw) {
|
function normalizePhone(raw) {
|
||||||
if (!raw) return { ok: false, value: "" };
|
if (!raw) return { ok: false, value: "" };
|
||||||
const digits = String(raw).replace(/\D/g, "");
|
const digits = String(raw).replace(/\D/g, "");
|
||||||
@ -644,6 +712,7 @@ const Clients = (function () {
|
|||||||
root.innerHTML = "";
|
root.innerHTML = "";
|
||||||
root.appendChild(headerEl("Редактировать клиента", "#/clients"));
|
root.appendChild(headerEl("Редактировать клиента", "#/clients"));
|
||||||
|
|
||||||
|
const addrParts = splitAddress(client.address || "");
|
||||||
const form = el(`
|
const form = el(`
|
||||||
<section class="podbor-step">
|
<section class="podbor-step">
|
||||||
<h2 class="display-title">Редактируем<br><span class="accent">клиента</span></h2>
|
<h2 class="display-title">Редактируем<br><span class="accent">клиента</span></h2>
|
||||||
@ -664,10 +733,27 @@ const Clients = (function () {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label class="field">
|
<span class="field-label">Адрес</span>
|
||||||
<span class="field-label">Адрес</span>
|
<div class="addr-grid">
|
||||||
<input type="text" id="ed_addr" value="${escAttr(client.address || "")}" placeholder="СПб, Просвещения 87, кв. 12">
|
<label class="field">
|
||||||
</label>
|
<span class="field-sublabel">Город</span>
|
||||||
|
<input type="text" id="ed_city" value="${escAttr(addrParts.city)}" placeholder="Санкт-Петербург" autocomplete="address-level2">
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-sublabel">Улица</span>
|
||||||
|
<input type="text" id="ed_street" value="${escAttr(addrParts.street)}" placeholder="пр. Просвещения" autocomplete="street-address">
|
||||||
|
</label>
|
||||||
|
<label class="field addr-house">
|
||||||
|
<span class="field-sublabel">Дом</span>
|
||||||
|
<input type="text" id="ed_house" value="${escAttr(addrParts.house)}" placeholder="87" inputmode="text">
|
||||||
|
</label>
|
||||||
|
<label class="field addr-apt">
|
||||||
|
<span class="field-sublabel">Кв./офис</span>
|
||||||
|
<input type="text" id="ed_apt" value="${escAttr(addrParts.apt)}" placeholder="12" inputmode="numeric">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<span class="field-error" id="ed_errAddr"></span>
|
||||||
|
<div class="geo-status" id="ed_geoStatus"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row two-col">
|
<div class="form-row two-col">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
@ -695,15 +781,19 @@ const Clients = (function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
form.querySelector("#ed_save").addEventListener("click", async () => {
|
form.querySelector("#ed_save").addEventListener("click", async () => {
|
||||||
const fn = form.querySelector("#ed_fn").value.trim();
|
const fn = form.querySelector("#ed_fn").value.trim();
|
||||||
const ph = form.querySelector("#ed_ph").value.trim();
|
const ph = form.querySelector("#ed_ph").value.trim();
|
||||||
const addr = form.querySelector("#ed_addr").value.trim();
|
const edCity = (form.querySelector("#ed_city").value || "").trim();
|
||||||
const cno = form.querySelector("#ed_cno").value.trim();
|
const edStreet = (form.querySelector("#ed_street").value || "").trim();
|
||||||
const cdate = form.querySelector("#ed_cdate").value.trim();
|
const edHouse = (form.querySelector("#ed_house").value || "").trim();
|
||||||
const errName = form.querySelector("#ed_errName");
|
const edApt = (form.querySelector("#ed_apt").value || "").trim();
|
||||||
|
const cno = form.querySelector("#ed_cno").value.trim();
|
||||||
|
const cdate = form.querySelector("#ed_cdate").value.trim();
|
||||||
|
const errName = form.querySelector("#ed_errName");
|
||||||
const errPhone = form.querySelector("#ed_errPhone");
|
const errPhone = form.querySelector("#ed_errPhone");
|
||||||
const result = form.querySelector("#ed_result");
|
const errAddr = form.querySelector("#ed_errAddr");
|
||||||
errName.textContent = ""; errPhone.textContent = ""; result.innerHTML = "";
|
const result = form.querySelector("#ed_result");
|
||||||
|
errName.textContent = ""; errPhone.textContent = ""; errAddr.textContent = ""; result.innerHTML = "";
|
||||||
|
|
||||||
if (!fn || fn.length < 2) {
|
if (!fn || fn.length < 2) {
|
||||||
errName.textContent = "Имя слишком короткое";
|
errName.textContent = "Имя слишком короткое";
|
||||||
@ -714,14 +804,43 @@ const Clients = (function () {
|
|||||||
errPhone.textContent = "Телефон в формате +7XXXXXXXXXX";
|
errPhone.textContent = "Телефон в формате +7XXXXXXXXXX";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (addr && addr.length < 5) {
|
if (!edCity || !edStreet || !edHouse) {
|
||||||
result.innerHTML = `<span style="color:#C0392B;">Адрес слишком короткий</span>`;
|
errAddr.textContent = "Укажите город, улицу и номер дома";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const btn = form.querySelector("#ed_save");
|
const address = [edCity, edStreet, edHouse ? "д. " + edHouse : "", edApt ? "кв. " + edApt : ""]
|
||||||
btn.disabled = true; btn.textContent = "Сохраняем...";
|
.filter(Boolean).join(", ");
|
||||||
|
|
||||||
|
const btn = form.querySelector("#ed_save");
|
||||||
|
btn.disabled = true; btn.textContent = "Проверяем адрес…";
|
||||||
|
|
||||||
|
// Геокодирование — необязательно, продолжаем даже при неудаче
|
||||||
|
let gps_lat = null, gps_lng = null;
|
||||||
|
const geoEl = form.querySelector("#ed_geoStatus");
|
||||||
|
try {
|
||||||
|
const geoRes = await fetch(`${BACKEND_URL}/api/geocode`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
initData: tg?.initData || "",
|
||||||
|
initDataUnsafe: tg?.initDataUnsafe || null,
|
||||||
|
address: `${edCity}, ${edStreet}, д. ${edHouse}`,
|
||||||
|
city: edCity,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const geoData = await geoRes.json();
|
||||||
|
if (geoData.ok && geoData.result) {
|
||||||
|
gps_lat = geoData.result.lat;
|
||||||
|
gps_lng = geoData.result.lng;
|
||||||
|
geoEl.innerHTML = `<span class="geo-ok">✓ ${escHtml(geoData.result.formatted || address)}</span>`;
|
||||||
|
} else {
|
||||||
|
geoEl.innerHTML = `<span class="geo-warn">⚠ Адрес не найден — сохраняем без координат.</span>`;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
geoEl.innerHTML = `<span class="geo-warn">⚠ Геокодер недоступен. Сохраняем без координат.</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.textContent = "Сохраняем...";
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BACKEND_URL}/api/client_update`, {
|
const res = await fetch(`${BACKEND_URL}/api/client_update`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@ -731,9 +850,11 @@ const Clients = (function () {
|
|||||||
client_key: (client.client_name || "").toLowerCase(),
|
client_key: (client.client_name || "").toLowerCase(),
|
||||||
full_name: fn,
|
full_name: fn,
|
||||||
phone: norm.value,
|
phone: norm.value,
|
||||||
address: addr,
|
address,
|
||||||
contract_no: cno,
|
contract_no: cno,
|
||||||
contract_date: cdate,
|
contract_date: cdate,
|
||||||
|
gps_lat,
|
||||||
|
gps_lng,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|||||||
@ -28,6 +28,7 @@ const Measurements = (function () {
|
|||||||
let root = null;
|
let root = null;
|
||||||
let measurementId = ""; // если задан — update-mode (закрытие заявки)
|
let measurementId = ""; // если задан — update-mode (закрытие заявки)
|
||||||
let prefilledClient = null;
|
let prefilledClient = null;
|
||||||
|
let pickedClient = null; // клиент из пикера (только для нового замера)
|
||||||
|
|
||||||
function loadState() {
|
function loadState() {
|
||||||
try {
|
try {
|
||||||
@ -60,6 +61,7 @@ const Measurements = (function () {
|
|||||||
saveState();
|
saveState();
|
||||||
photos = [];
|
photos = [];
|
||||||
prefilledClient = null;
|
prefilledClient = null;
|
||||||
|
pickedClient = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===================== Mount + Render ===================== */
|
/* ===================== Mount + Render ===================== */
|
||||||
@ -73,6 +75,7 @@ const Measurements = (function () {
|
|||||||
photos = [];
|
photos = [];
|
||||||
measurementId = "";
|
measurementId = "";
|
||||||
prefilledClient = null;
|
prefilledClient = null;
|
||||||
|
pickedClient = null;
|
||||||
|
|
||||||
const hashMatch = (location.hash.split("?")[1] || "");
|
const hashMatch = (location.hash.split("?")[1] || "");
|
||||||
const fragQp = new URLSearchParams(hashMatch);
|
const fragQp = new URLSearchParams(hashMatch);
|
||||||
@ -155,7 +158,7 @@ const Measurements = (function () {
|
|||||||
|
|
||||||
function renderForm() {
|
function renderForm() {
|
||||||
const isUpdate = !!measurementId && prefilledClient;
|
const isUpdate = !!measurementId && prefilledClient;
|
||||||
const clientBlock = isUpdate ? renderClientReadOnly() : renderClientInputs();
|
const clientBlock = isUpdate ? renderClientReadOnly() : renderClientPicker();
|
||||||
|
|
||||||
const node = el(`
|
const node = el(`
|
||||||
<section class="podbor-step">
|
<section class="podbor-step">
|
||||||
@ -363,31 +366,164 @@ const Measurements = (function () {
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderClientInputs() {
|
/* ===================== Пикер клиента ===================== */
|
||||||
return el(`
|
|
||||||
<div>
|
function renderClientPicker() {
|
||||||
|
const wrap = el(`
|
||||||
|
<div class="client-picker-wrap">
|
||||||
|
<div class="form-row" id="pcChoiceRow"></div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field-label">ФИО клиента *</span>
|
<span class="field-label">Адрес объекта</span>
|
||||||
<input type="text" data-bind="client_name" value="${escAttr(state.client_name)}" placeholder="Иванов Иван Иванович">
|
<input type="text" data-bind="address" id="pcAddr"
|
||||||
<span class="field-error" id="nameError"></span>
|
value="${escAttr(state.address)}"
|
||||||
</label>
|
placeholder="СПб, пр. Просвещения, д. 87, кв. 12">
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<label class="field">
|
|
||||||
<span class="field-label">Телефон *</span>
|
|
||||||
<input type="tel" data-bind="client_phone" value="${escAttr(state.client_phone)}" placeholder="+7 921 555-12-34">
|
|
||||||
<span class="field-error" id="phoneError"></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<label class="field">
|
|
||||||
<span class="field-label">Адрес</span>
|
|
||||||
<input type="text" data-bind="address" value="${escAttr(state.address)}" placeholder="СПб, Просвещения 87, кв. 12">
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const choiceRow = wrap.querySelector("#pcChoiceRow");
|
||||||
|
const addrInput = wrap.querySelector("#pcAddr");
|
||||||
|
|
||||||
|
addrInput.addEventListener("input", e => {
|
||||||
|
state.address = e.target.value;
|
||||||
|
saveState();
|
||||||
|
});
|
||||||
|
|
||||||
|
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 (!addrInput.value && pickedClient.address) {
|
||||||
|
addrInput.value = pickedClient.address;
|
||||||
|
state.address = pickedClient.address;
|
||||||
|
saveState();
|
||||||
|
}
|
||||||
|
} 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 res = await fetch(`${BACKEND_URL}/api/clients`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
initData: tg?.initData || "",
|
||||||
|
initDataUnsafe: tg?.initDataUnsafe || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
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) {
|
function bindInputs(node) {
|
||||||
@ -703,19 +839,12 @@ const Measurements = (function () {
|
|||||||
|
|
||||||
const isUpdate = !!measurementId && prefilledClient;
|
const isUpdate = !!measurementId && prefilledClient;
|
||||||
if (!isUpdate) {
|
if (!isUpdate) {
|
||||||
const name = (state.client_name || "").trim();
|
if (!pickedClient) {
|
||||||
const phone = (state.client_phone || "").trim();
|
const nameErr = node.querySelector("#nameError");
|
||||||
const nameErr = node.querySelector("#nameError");
|
if (nameErr) {
|
||||||
const phoneErr = node.querySelector("#phoneError");
|
nameErr.textContent = "Выберите клиента из списка";
|
||||||
if (nameErr) nameErr.textContent = "";
|
nameErr.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
if (phoneErr) phoneErr.textContent = "";
|
}
|
||||||
if (!name) {
|
|
||||||
if (nameErr) nameErr.textContent = "Укажите имя клиента";
|
|
||||||
btn.disabled = false; btn.textContent = "Сохранить замер";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (phone.replace(/\D/g, "").length < 10) {
|
|
||||||
if (phoneErr) phoneErr.textContent = "Слишком короткий номер";
|
|
||||||
btn.disabled = false; btn.textContent = "Сохранить замер";
|
btn.disabled = false; btn.textContent = "Сохранить замер";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -735,9 +864,9 @@ const Measurements = (function () {
|
|||||||
zamer_date: state.zamer_date || "",
|
zamer_date: state.zamer_date || "",
|
||||||
notes: state.notes || "",
|
notes: state.notes || "",
|
||||||
// Клиент
|
// Клиент
|
||||||
client_name: isUpdate ? prefilledClient.name : state.client_name,
|
client_name: isUpdate ? prefilledClient.name : pickedClient.client_name,
|
||||||
client_phone: isUpdate ? prefilledClient.phone : state.client_phone,
|
client_phone: isUpdate ? prefilledClient.phone : pickedClient.client_phone,
|
||||||
address: isUpdate ? prefilledClient.address : state.address,
|
address: isUpdate ? prefilledClient.address : state.address,
|
||||||
measurement_id: measurementId || undefined,
|
measurement_id: measurementId || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -3320,6 +3320,190 @@
|
|||||||
border-top: 1px dashed var(--line);
|
border-top: 1px dashed var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Поля адреса (addr-grid) ===== */
|
||||||
|
.addr-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.addr-grid .field { margin: 0; }
|
||||||
|
.addr-house { grid-column: 1; }
|
||||||
|
.addr-apt { grid-column: 2; }
|
||||||
|
.field-sublabel {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--muted, #998877);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
.geo-status {
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.4;
|
||||||
|
min-height: 18px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
.geo-ok { color: #27AE60; }
|
||||||
|
.geo-warn { color: #C0392B; }
|
||||||
|
|
||||||
|
/* ===== Пикер клиента (замер) ===== */
|
||||||
|
.client-picker-wrap { margin-bottom: 2px; }
|
||||||
|
.picker-open-btn {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--paper, #FBF7F0);
|
||||||
|
border: 1.5px dashed rgba(107,74,43,0.28);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--walnut, #6B4A2B);
|
||||||
|
text-align: left;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.picker-open-btn:active { background: rgba(107,74,43,0.06); }
|
||||||
|
.picker-open-btn .picker-open-icon {
|
||||||
|
width: 32px; height: 32px; flex-shrink: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(145deg, #845A2E 0%, #5C3A1C 100%);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: #F5DAAA; font-size: 16px;
|
||||||
|
}
|
||||||
|
.picker-chosen-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--paper, #FBF7F0);
|
||||||
|
border: 1.5px solid rgba(107,74,43,0.22);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.picker-chosen-info { flex: 1; min-width: 0; }
|
||||||
|
.picker-chosen-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink, #1F1A14);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.picker-chosen-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted, #998877);
|
||||||
|
margin-top: 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.picker-change-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 5px 10px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(107,74,43,0.28);
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--walnut, #6B4A2B);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.picker-change-btn:active { background: rgba(107,74,43,0.08); }
|
||||||
|
|
||||||
|
/* Оверлей пикера */
|
||||||
|
.client-picker-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 800;
|
||||||
|
background: rgba(20,15,10,0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.picker-sheet {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 80vh;
|
||||||
|
background: var(--bg, #F5EFE6);
|
||||||
|
border-radius: 18px 18px 0 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 -4px 24px rgba(0,0,0,0.18);
|
||||||
|
}
|
||||||
|
.picker-sheet-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 16px 10px;
|
||||||
|
border-bottom: 1px solid var(--line, rgba(107,74,43,0.12));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.picker-sheet-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink, #1F1A14);
|
||||||
|
}
|
||||||
|
.picker-sheet-close {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: rgba(107,74,43,0.09);
|
||||||
|
border: none; border-radius: 50%;
|
||||||
|
font-size: 16px; cursor: pointer;
|
||||||
|
color: var(--walnut, #6B4A2B);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.picker-search-wrap {
|
||||||
|
padding: 10px 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.picker-search {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 12px;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid rgba(107,74,43,0.18);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ink, #1F1A14);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.picker-search:focus { outline: none; border-color: var(--walnut, #6B4A2B); }
|
||||||
|
.picker-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 0 env(safe-area-inset-bottom, 16px);
|
||||||
|
}
|
||||||
|
.picker-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 11px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid rgba(107,74,43,0.07);
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.picker-row:active { background: rgba(107,74,43,0.06); }
|
||||||
|
.picker-row-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink, #1F1A14);
|
||||||
|
}
|
||||||
|
.picker-row-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted, #998877);
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
.picker-empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px 16px;
|
||||||
|
color: var(--muted, #998877);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Печать / PDF ===== */
|
/* ===== Печать / PDF ===== */
|
||||||
@media print {
|
@media print {
|
||||||
body { background: white !important; color: black !important; }
|
body { background: white !important; color: black !important; }
|
||||||
|
|||||||
@ -12,14 +12,14 @@
|
|||||||
<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;800&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&family=Cormorant+Garamond:ital,wght@1,400;1,500;1,600&family=Caveat:wght@500;700&display=swap">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&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&family=Cormorant+Garamond:ital,wght@1,400;1,500;1,600&family=Caveat:wght@500;700&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=20260514h">
|
<link rel="stylesheet" href="assets/styles.css?v=20260514i">
|
||||||
<link rel="stylesheet" href="assets/podbor.css?v=20260514h">
|
<link rel="stylesheet" href="assets/podbor.css?v=20260514i">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Splash — лого @wasrusgen1 + опилки (16) + вращающийся диск -->
|
<!-- Splash — лого @wasrusgen1 + опилки (16) + вращающийся диск -->
|
||||||
<div class="loader splash" id="splash">
|
<div class="loader splash" id="splash">
|
||||||
<div class="brand-logo-wrap">
|
<div class="brand-logo-wrap">
|
||||||
<img class="brand-logo" src="assets/wasrusgen-logo.svg?v=20260514h" alt="@wasrusgen1">
|
<img class="brand-logo" src="assets/wasrusgen-logo.svg?v=20260514i" alt="@wasrusgen1">
|
||||||
<div class="splash-dust" aria-hidden="true">
|
<div class="splash-dust" aria-hidden="true">
|
||||||
<span class="dust d1"></span> <span class="dust d2"></span>
|
<span class="dust d1"></span> <span class="dust d2"></span>
|
||||||
<span class="dust d3"></span> <span class="dust d4"></span>
|
<span class="dust d3"></span> <span class="dust d4"></span>
|
||||||
@ -35,15 +35,15 @@
|
|||||||
<div class="brand-tagline-gold">CRM</div>
|
<div class="brand-tagline-gold">CRM</div>
|
||||||
</div>
|
</div>
|
||||||
<main id="app"></main>
|
<main id="app"></main>
|
||||||
<script src="assets/icons.js?v=20260514h"></script>
|
<script src="assets/icons.js?v=20260514i"></script>
|
||||||
<script src="assets/podbor.config.js?v=20260514h"></script>
|
<script src="assets/podbor.config.js?v=20260514i"></script>
|
||||||
<script src="assets/podbor.picts.js?v=20260514h"></script>
|
<script src="assets/podbor.picts.js?v=20260514i"></script>
|
||||||
<script src="assets/podbor.js?v=20260514h"></script>
|
<script src="assets/podbor.js?v=20260514i"></script>
|
||||||
<script src="assets/clients.js?v=20260514h"></script>
|
<script src="assets/clients.js?v=20260514i"></script>
|
||||||
<script src="assets/zamer-picts.js?v=20260514h"></script>
|
<script src="assets/zamer-picts.js?v=20260514i"></script>
|
||||||
<script src="assets/measurements.js?v=20260514h"></script>
|
<script src="assets/measurements.js?v=20260514i"></script>
|
||||||
<script src="assets/request.js?v=20260514h"></script>
|
<script src="assets/request.js?v=20260514i"></script>
|
||||||
<script src="assets/assembly.js?v=20260514h"></script>
|
<script src="assets/assembly.js?v=20260514i"></script>
|
||||||
<script src="assets/app.js?v=20260514h"></script>
|
<script src="assets/app.js?v=20260514i"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user