Phase 4 stage 1: Сборки — модель + создание + список

Backend:
- sheets.is_master(user) — единая роль measurer ∨ assembler
- grant_role() автоматически выдаёт парную роль (measurer ↔ assembler)
- Новая таблица Assemblies со схемой: client, scope, scheduled_at,
  status (created|scheduled|in_progress|completed|cancelled),
  photos_before/in_progress/after, signature_file, gcal_event_id
- POST /api/assembly_create — менеджер заводит сборку,
  при scheduled_at создаётся событие Google Calendar (4 часа)
- POST /api/assembly_list — фильтр по роли: менеджер видит свои,
  мастер — назначенные + неназначенные (created/scheduled)
- POST /api/assembly_detail — карточка с правами доступа
- /api/photo: добавил MIME для pdf/dwg/dxf (для DWG-блока B+E)

Frontend (assembly.js — новый модуль):
- Форма /api/assembly_create с валидацией: имя, адрес, scope
- Pre-fill из карточки клиента (sessionStorage.prefillAssembly,
  адрес + measurement_id из последнего замера)
- Список сборок + детальная карточка со статусом и составом работ
- Маршруты: #/assembly, #/assembly/new, #/assembly/<id>

Frontend (app.js + clients.js):
- Кнопка «🔨 Заказать сборку» в карточке клиента
- Quick-action «Сборки» на главной менеджера
- Блок «🔨 Сборки» в кабинете мастера (caps.measurer ∨ assembler)

CSS: .assembly-card / .assembly-card-* (золотой бордер)
index.html: cache bump v=20260514c
This commit is contained in:
wasrusgen 2026-05-14 09:53:40 +03:00
parent 5e6746e676
commit 52eb0e4a96
7 changed files with 843 additions and 15 deletions

View File

@ -120,6 +120,9 @@ async def _dispatch_post(request: Request):
"measurement_design_upload": _handle_measurement_design_upload, "measurement_design_upload": _handle_measurement_design_upload,
"measurement_decision": _handle_measurement_decision, "measurement_decision": _handle_measurement_decision,
"manager_pending": _handle_manager_pending, "manager_pending": _handle_manager_pending,
"assembly_create": _handle_assembly_create,
"assembly_list": _handle_assembly_list,
"assembly_detail": _handle_assembly_detail,
"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(),
@ -264,6 +267,24 @@ async def api_manager_pending(request: Request):
return _handle_manager_pending(body) return _handle_manager_pending(body)
@app.post("/api/assembly_create")
async def api_assembly_create(request: Request):
body = await _safe_json(request)
return _handle_assembly_create(body)
@app.post("/api/assembly_list")
async def api_assembly_list(request: Request):
body = await _safe_json(request)
return _handle_assembly_list(body)
@app.post("/api/assembly_detail")
async def api_assembly_detail(request: Request):
body = await _safe_json(request)
return _handle_assembly_detail(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):
"""Админ выдаёт роль другому пользователю. """Админ выдаёт роль другому пользователю.
@ -1727,6 +1748,263 @@ def _handle_manager_pending(body: dict[str, Any]) -> dict[str, Any]:
return {"ok": True, "count": len(out), "pending": out} return {"ok": True, "count": len(out), "pending": out}
# =================================================================
# Сборки (Phase 4) — workflow от подписанного договора до приёмки
# =================================================================
def _assembly_columns() -> list[str]:
return [
"id", "ts",
# Связи
"manager_tg_id", "assigned_to_tg_id",
"client_name", "client_phone", "address",
"measurement_id", "lead_id", "client_tg_id",
# Скоуп и расписание
"scope_of_work", # текстовое описание
"scheduled_at", # ISO
# Статус: created | scheduled | in_progress | completed | cancelled
"status",
"started_at", "completed_at",
# Фото-отчёт: списки имён файлов через запятую (внутри PHOTOS_DIR/<assembly_id>/)
"photos_before", "photos_in_progress", "photos_after",
# Приёмка ППР: подпись клиента (PNG dataURL → файл) + ФИО/дата
"signature_file", "signed_by_name", "signed_at",
# Google Calendar
"gcal_event_id", "gcal_event_url",
# Прочее
"manager_note",
"archived_at",
]
def _ensure_assemblies_sheet() -> None:
"""Догоняет схему Assemblies (добавляет недостающие колонки)."""
try:
ws = sheets.sheet("Assemblies")
existing = ws.row_values(1)
except Exception:
sheets.ensure_sheet("Assemblies", _assembly_columns())
return
want = _assembly_columns()
missing = [c for c in want if c not in existing]
if missing:
new_headers = existing + missing
ws.update("A1", [new_headers])
log.info("Assemblies: дополнили колонки: %s", missing)
def _row_for_assembly(assembly_id: str, ts: str, **fields) -> list[str]:
cols = _assembly_columns()
base = {c: "" for c in cols}
base["id"] = assembly_id
base["ts"] = ts
base["status"] = "created"
base.update(fields)
return [str(base.get(c, "")) for c in cols]
def _handle_assembly_create(body: dict[str, Any]) -> dict[str, Any]:
"""Менеджер заводит сборку.
body: {initData, client_name, client_phone?, address, scope_of_work,
measurement_id?, lead_id?, scheduled_at?, manager_note?}"""
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 isinstance(unsafe, dict) and unsafe.get("user", {}).get("id"):
auth = {"user": unsafe["user"]}
else:
return {"error": "invalid_init_data"}
tg_id = auth["user"]["id"]
user = sheets.find_user(tg_id)
if not user or not sheets.has_role(user, "manager"):
return {"error": "only_manager"}
_ensure_assemblies_sheet()
client_name = (body.get("client_name") or "").strip()
address = (body.get("address") or "").strip()
scope = (body.get("scope_of_work") or "").strip()
if not client_name:
return {"error": "missing_client_name"}
if not address:
return {"error": "missing_address"}
if not scope:
return {"error": "missing_scope"}
phone_raw = (body.get("client_phone") or "").strip()
phone_norm, _ = _normalize_phone(phone_raw) if phone_raw else ("", False)
assembly_id = _short_id()
ts = _now_iso()
scheduled_at = (body.get("scheduled_at") or "").strip()
status = "scheduled" if scheduled_at else "created"
fields = {
"manager_tg_id": tg_id,
"client_name": client_name,
"client_phone": phone_norm or phone_raw,
"address": address,
"scope_of_work": scope,
"measurement_id": (body.get("measurement_id") or "").strip(),
"lead_id": (body.get("lead_id") or "").strip(),
"client_tg_id": (body.get("client_tg_id") or "").strip(),
"scheduled_at": scheduled_at,
"status": status,
"manager_note": (body.get("manager_note") or "").strip(),
}
# Google Calendar — если дата назначена
if scheduled_at:
try:
from . import gcalendar
ev = gcalendar.create_event(
summary=f"🔨 Сборка: {client_name}",
description=f"{scope}\n\nКлиент: {client_name}\nТел: {phone_norm or phone_raw}\nАдрес: {address}",
start_iso=scheduled_at,
duration_min=240, # 4 часа на сборку
location=address,
)
if ev:
fields["gcal_event_id"] = ev.get("id", "")
fields["gcal_event_url"] = ev.get("html_link", "")
except Exception as e:
log.warning("Не удалось создать событие Calendar для сборки: %s", e)
sheets.append_row("Assemblies", _row_for_assembly(assembly_id, ts, **fields))
sheets.log_event("assembly_created", tg_id, {"id": assembly_id, "client": client_name})
return {"ok": True, "id": assembly_id, "status": status}
def _handle_assembly_list(body: dict[str, Any]) -> dict[str, Any]:
"""Список сборок.
Менеджер: видит свои (manager_tg_id == self).
Мастер: видит назначенные ему (assigned_to_tg_id == self) + неназначенные status='created'."""
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 isinstance(unsafe, dict) and unsafe.get("user", {}).get("id"):
auth = {"user": unsafe["user"]}
else:
return {"error": "invalid_init_data"}
tg_id = auth["user"]["id"]
user = sheets.find_user(tg_id)
if not user:
return {"error": "user_not_found"}
is_manager = sheets.has_role(user, "manager")
is_master = sheets.is_master(user)
if not is_manager and not is_master:
return {"error": "forbidden"}
_ensure_assemblies_sheet()
try:
ws = sheets.sheet("Assemblies")
rows = ws.get_all_values()
except Exception:
return {"ok": True, "assemblies": []}
if not rows or len(rows) < 2:
return {"ok": True, "assemblies": []}
headers = rows[0]
out = []
for r in rows[1:]:
row = dict(zip(headers, r + [""] * (len(headers) - len(r))))
if row.get("archived_at"):
continue
# Фильтр по роли
visible = False
if is_manager and str(row.get("manager_tg_id")) == str(tg_id):
visible = True
if is_master:
if str(row.get("assigned_to_tg_id")) == str(tg_id):
visible = True
elif not row.get("assigned_to_tg_id") and row.get("status") in ("created", "scheduled"):
visible = True
if not visible:
continue
out.append({
"id": row.get("id", ""),
"ts": row.get("ts", ""),
"client_name": row.get("client_name", ""),
"client_phone": row.get("client_phone", ""),
"address": row.get("address", ""),
"scope_of_work": row.get("scope_of_work", ""),
"scheduled_at": row.get("scheduled_at", ""),
"status": row.get("status", ""),
"assigned_to_tg_id": row.get("assigned_to_tg_id", ""),
"manager_tg_id": row.get("manager_tg_id", ""),
"gcal_event_url": row.get("gcal_event_url", ""),
"measurement_id": row.get("measurement_id", ""),
"lead_id": row.get("lead_id", ""),
})
out.sort(key=lambda x: x.get("scheduled_at") or x.get("ts", ""), reverse=True)
return {"ok": True, "count": len(out), "assemblies": out}
def _handle_assembly_detail(body: dict[str, Any]) -> dict[str, Any]:
"""Детальная карточка сборки."""
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 isinstance(unsafe, dict) and unsafe.get("user", {}).get("id"):
auth = {"user": unsafe["user"]}
else:
return {"error": "invalid_init_data"}
tg_id = auth["user"]["id"]
user = sheets.find_user(tg_id)
if not user:
return {"error": "user_not_found"}
assembly_id = (body.get("assembly_id") or "").strip()
if not assembly_id:
return {"error": "missing_assembly_id"}
_ensure_assemblies_sheet()
row = sheets.find_row("Assemblies", "id", assembly_id)
if not row:
return {"error": "assembly_not_found"}
# Право: менеджер-владелец, назначенный мастер, неназначенная сборка (status=created)
is_owner = str(row.get("manager_tg_id")) == str(tg_id) or \
str(row.get("assigned_to_tg_id")) == str(tg_id)
is_open_slot = (not row.get("assigned_to_tg_id")) and row.get("status") in ("created", "scheduled")
if not is_owner and not is_open_slot:
return {"error": "forbidden"}
def _list(s: str) -> list[str]:
return [x for x in (s or "").split(",") if x]
return {
"ok": True,
"id": row.get("id", ""),
"ts": row.get("ts", ""),
"manager_tg_id": row.get("manager_tg_id", ""),
"assigned_to_tg_id": row.get("assigned_to_tg_id", ""),
"client_name": row.get("client_name", ""),
"client_phone": row.get("client_phone", ""),
"address": row.get("address", ""),
"measurement_id": row.get("measurement_id", ""),
"lead_id": row.get("lead_id", ""),
"scope_of_work": row.get("scope_of_work", ""),
"scheduled_at": row.get("scheduled_at", ""),
"status": row.get("status", ""),
"started_at": row.get("started_at", ""),
"completed_at": row.get("completed_at", ""),
"photos_before": _list(row.get("photos_before", "")),
"photos_in_progress": _list(row.get("photos_in_progress", "")),
"photos_after": _list(row.get("photos_after", "")),
"signature_file": row.get("signature_file", ""),
"signed_by_name": row.get("signed_by_name", ""),
"signed_at": row.get("signed_at", ""),
"gcal_event_id": row.get("gcal_event_id", ""),
"gcal_event_url": row.get("gcal_event_url", ""),
"manager_note": row.get("manager_note", ""),
}
def _normalize_phone(raw: str) -> tuple[str, bool]: def _normalize_phone(raw: str) -> tuple[str, bool]:
"""Нормализует RU-телефон в формат +7XXXXXXXXXX. """Нормализует RU-телефон в формат +7XXXXXXXXXX.
Возвращает (нормализованный, валиден ли).""" Возвращает (нормализованный, валиден ли)."""

View File

@ -132,6 +132,15 @@ def has_role(user: dict[str, Any] | None, role: str) -> bool:
return role in parse_roles(user.get("role", "")) return role in parse_roles(user.get("role", ""))
def is_master(user: dict[str, Any] | None) -> bool:
"""«Мастер» — единая роль для замерщика+сборщика.
True если у пользователя есть либо measurer, либо assembler."""
if not user:
return False
roles = parse_roles(user.get("role", ""))
return "measurer" in roles or "assembler" in roles
def primary_role(user: dict[str, Any] | None) -> str: def primary_role(user: dict[str, Any] | None) -> str:
"""Первая (главная) роль для legacy-кода: manager > measurer > assembler > client.""" """Первая (главная) роль для legacy-кода: manager > measurer > assembler > client."""
if not user: if not user:
@ -144,16 +153,25 @@ def primary_role(user: dict[str, Any] | None) -> str:
def grant_role(tg_id: int, role: str) -> bool: def grant_role(tg_id: int, role: str) -> bool:
"""Добавляет роль пользователю (если её ещё нет). Возвращает True если что-то изменилось.""" """Добавляет роль пользователю (если её ещё нет). Возвращает True если что-то изменилось.
Замерщик и сборщик объединены в одну роль «мастер» при выдаче одной автоматически выдаётся вторая."""
if role not in VALID_ROLES: if role not in VALID_ROLES:
return False return False
user = find_user(tg_id) user = find_user(tg_id)
if not user: if not user:
return False return False
current = parse_roles(user.get("role", "")) current = parse_roles(user.get("role", ""))
if role in current: changed = False
if role not in current:
current.append(role)
changed = True
# Парный «мастер»: measurer ↔ assembler — выдаём вместе
paired = {"measurer": "assembler", "assembler": "measurer"}.get(role)
if paired and paired not in current:
current.append(paired)
changed = True
if not changed:
return False return False
current.append(role)
return update_cell_by_key("Users", "tg_id", tg_id, "role", ",".join(current)) return update_cell_by_key("Users", "tg_id", tg_id, "role", ",".join(current))

View File

@ -145,6 +145,7 @@ async function renderManagerHome(me) {
{ icon: "plus", title: "Новый клиент", subtitle: "Завести карточку", href: "#/clients/new" }, { icon: "plus", title: "Новый клиент", subtitle: "Завести карточку", href: "#/clients/new" },
{ icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" }, { icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" },
{ icon: "ruler", title: "Заказать замер", subtitle: "Назначить замерщика", href: "#/request" }, { icon: "ruler", title: "Заказать замер", subtitle: "Назначить замерщика", href: "#/request" },
{ icon: "wrench", title: "Сборки", subtitle: "Заявки на сборку", href: "#/assembly" },
]; ];
app.appendChild(el(`<div class="section-head"><span class="label">Быстрые действия</span></div>`)); app.appendChild(el(`<div class="section-head"><span class="label">Быстрые действия</span></div>`));
const grid = el(`<div class="quick-grid"></div>`); const grid = el(`<div class="quick-grid"></div>`);
@ -748,6 +749,67 @@ async function renderStaff(me) {
}); });
app.appendChild(quick); app.appendChild(quick);
} }
// Сборки — отдельный блок, доступен мастеру (measurer assembler)
if (caps.measurer || caps.assembler) {
const assemblySection = el(`
<section class="block" style="margin-top:18px;">
<div class="block-head">🔨 Сборки</div>
<div id="assemblyList"><div class="loader-inline"><div class="spinner"></div></div></div>
</section>
`);
app.appendChild(assemblySection);
renderStaffAssemblies(assemblySection.querySelector("#assemblyList"));
}
}
async function renderStaffAssemblies(container) {
try {
const res = await fetch(`${BACKEND_URL}/api/assembly_list`, {
method: "POST",
body: JSON.stringify({
initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null,
}),
});
const data = await res.json();
if (data.error) {
container.innerHTML = `<div class="error">Ошибка: ${escHtml(data.error)}</div>`;
return;
}
const items = (data.assemblies || []).filter(a => a.status !== "completed" && a.status !== "cancelled");
if (!items.length) {
container.innerHTML = `<div class="empty" style="padding:12px;text-align:center;color:var(--muted);font-size:13px;">Сборок нет</div>`;
return;
}
container.innerHTML = "";
for (const a of items) {
const dateStr = a.scheduled_at ? formatDateHuman(a.scheduled_at) : "— дата не назначена";
const statusLabel = {
created: "📝 создана",
scheduled: "📅 назначена",
in_progress: "🔧 в работе",
}[a.status] || a.status;
const card = el(`
<article class="assembly-card" data-id="${a.id}">
<div class="assembly-card-head">
<span class="assembly-card-status">${statusLabel}</span>
<span class="assembly-card-date">${escHtml(dateStr)}</span>
</div>
<div class="assembly-card-name">${escHtml(a.client_name || "Без имени")}</div>
<div class="assembly-card-address">${escHtml(a.address || "адрес не указан")}</div>
${a.scope_of_work ? `<div class="assembly-card-scope">${escHtml(a.scope_of_work.slice(0, 100))}${a.scope_of_work.length > 100 ? "…" : ""}</div>` : ""}
</article>
`);
card.addEventListener("click", () => {
haptic && haptic("impact");
location.hash = `#/assembly/${a.id}`;
});
container.appendChild(card);
}
} catch (e) {
container.innerHTML = `<div class="error">Сеть: ${escHtml(e.message)}</div>`;
}
} }
/* ----------------- Группировка инбокса замерщика по дням ----------------- */ /* ----------------- Группировка инбокса замерщика по дням ----------------- */
@ -1464,6 +1526,11 @@ async function init() {
hideSplash(); hideSplash();
return; return;
} }
if (location.hash.startsWith("#/assembly")) {
Assembly.mount(app);
hideSplash();
return;
}
if (me.role === "staff") { if (me.role === "staff") {
renderStaff(me); renderStaff(me);
} else if (me.role === "manager") { } else if (me.role === "manager") {
@ -1490,6 +1557,8 @@ function routeByHash() {
MeasurementRequest.mount(app); MeasurementRequest.mount(app);
} else if (location.hash.startsWith("#/inbox/")) { } else if (location.hash.startsWith("#/inbox/")) {
renderInboxDetail(location.hash.replace("#/inbox/", "")); renderInboxDetail(location.hash.replace("#/inbox/", ""));
} else if (location.hash.startsWith("#/assembly")) {
Assembly.mount(app);
} else { } else {
// Главный экран по роли // Главный экран по роли
const me = window.__zovMe; const me = window.__zovMe;

395
miniapp/assets/assembly.js Normal file
View File

@ -0,0 +1,395 @@
/* ============================================================
Сборка (Phase 4) менеджер создаёт заявку на сборку,
мастер исполняет, клиент подписывает приёмку.
Этап 1: создание + список + детальная.
============================================================ */
const Assembly = (function () {
let root = null;
let state = {
client_name: "",
client_phone: "",
address: "",
scope_of_work: "",
measurement_id: "",
lead_id: "",
scheduled_at: "",
manager_note: "",
};
function mount(container) {
root = container;
document.body.classList.remove("has-bottom-nav");
const oldNav = document.getElementById("bottom-nav");
if (oldNav) oldNav.remove();
const hash = location.hash || "";
// #/assembly/new — форма создания
// #/assembly/<id> — детальная карточка
if (hash === "#/assembly/new" || hash.startsWith("#/assembly/new?")) {
resetState();
prefillFromSession();
renderForm();
} else if (hash.startsWith("#/assembly/")) {
const id = hash.replace("#/assembly/", "").split("?")[0];
renderDetail(id);
} else {
// Список (для мастера)
renderList();
}
}
function resetState() {
state = {
client_name: "",
client_phone: "",
address: "",
scope_of_work: "",
measurement_id: "",
lead_id: "",
scheduled_at: "",
manager_note: "",
};
}
function prefillFromSession() {
try {
const raw = sessionStorage.getItem("prefillAssembly");
if (raw) {
const pre = JSON.parse(raw);
if (pre.name) state.client_name = pre.name;
if (pre.phone) state.client_phone = pre.phone;
if (pre.address) state.address = pre.address;
if (pre.measurement_id) state.measurement_id = pre.measurement_id;
if (pre.lead_id) state.lead_id = pre.lead_id;
sessionStorage.removeItem("prefillAssembly");
}
} catch (e) {}
}
function headerEl(title, backHash) {
const h = el(`
<header class="podbor-header">
<button class="podbor-back" aria-label="Назад">${ICONS.arrow_left || ""}</button>
<div class="podbor-title">${escHtml(title)}</div>
<div style="width:28px"></div>
</header>
`);
h.querySelector(".podbor-back").addEventListener("click", () => {
if (backHash) location.hash = backHash;
else history.back();
});
return h;
}
/* ===================== Форма создания ===================== */
function renderForm() {
if (!root) return;
root.innerHTML = "";
root.appendChild(headerEl("Заказать сборку", ""));
const form = el(`
<section class="podbor-step">
<h2 class="display-title">Новая<br><span class="accent">сборка</span></h2>
<p class="lede">Опишите состав работ мастер получит карточку с адресом и датой.</p>
<div class="form-row">
<label class="field">
<span class="field-label">ФИО клиента *</span>
<input type="text" data-bind="client_name" value="${escAttr(state.client_name)}" placeholder="Иванов Иван Иванович">
<span class="field-error" id="errName"></span>
</label>
</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">
</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">
<span class="field-error" id="errAddress"></span>
</label>
</div>
<div class="form-row">
<label class="field">
<span class="field-label">Состав работ *</span>
<textarea data-bind="scope_of_work" rows="4" placeholder="Кухня по проекту, корпус по чертежу, столешница из камня (отдельный замер), 8 фасадов, варочная Bosch, духовка Bosch, вытяжка Faber, посудомойка Bosch встроенная.">${escHtml(state.scope_of_work)}</textarea>
<span class="field-error" id="errScope"></span>
</label>
</div>
<div class="form-row">
<label class="field">
<span class="field-label">Дата и время (можно позже)</span>
<input type="datetime-local" data-bind="scheduled_at" value="${state.scheduled_at}">
<span class="field-hint">Если оставите пустым назначите позже на главной</span>
</label>
</div>
<div class="form-row">
<label class="field">
<span class="field-label">Заметка мастеру</span>
<textarea data-bind="manager_note" rows="2" placeholder="код домофона, особенности заезда, ключевой контакт на месте">${escHtml(state.manager_note)}</textarea>
</label>
</div>
<div class="podbor-cta-row" style="margin-top:18px;">
<button class="btn-primary" id="submitBtn">Заказать сборку</button>
</div>
<div id="submitResult" class="submit-result"></div>
</section>
`);
bindInputs(form);
form.querySelector("#submitBtn").addEventListener("click", () => onSubmit(form));
root.appendChild(form);
}
function bindInputs(node) {
node.querySelectorAll("[data-bind]").forEach(input => {
input.addEventListener("input", () => {
state[input.dataset.bind] = input.value;
});
});
}
async function onSubmit(form) {
const btn = form.querySelector("#submitBtn");
const result = form.querySelector("#submitResult");
result.innerHTML = "";
form.querySelectorAll(".field-error").forEach(e => e.textContent = "");
let ok = true;
if (!state.client_name.trim()) {
form.querySelector("#errName").textContent = "Укажите имя клиента";
ok = false;
}
if (!state.address.trim()) {
form.querySelector("#errAddress").textContent = "Укажите адрес сборки";
ok = false;
}
if (!state.scope_of_work.trim()) {
form.querySelector("#errScope").textContent = "Опишите состав работ";
ok = false;
}
if (!ok) return;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-inline"></span> сохраняем...';
const body = {
initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null,
client_name: state.client_name.trim(),
client_phone: state.client_phone.trim(),
address: state.address.trim(),
scope_of_work: state.scope_of_work.trim(),
measurement_id: state.measurement_id,
lead_id: state.lead_id,
scheduled_at: state.scheduled_at ? new Date(state.scheduled_at).toISOString() : "",
manager_note: state.manager_note.trim(),
};
try {
const res = await fetch(`${BACKEND_URL}/api/assembly_create`, {
method: "POST",
body: JSON.stringify(body),
});
const data = await res.json();
if (data.error) {
result.innerHTML = `<div class="error">Ошибка: ${escHtml(data.error)}</div>`;
btn.disabled = false;
btn.textContent = "Заказать сборку";
return;
}
haptic && haptic("success");
result.innerHTML = `
<div class="success">
<div class="success-icon">${ICONS.check || "✓"}</div>
<div>
<div class="success-title">Сборка заведена</div>
<div class="success-sub">ID #${(data.id || "").slice(0, 6)} · ${data.status === "scheduled" ? "дата назначена" : "без даты"}</div>
</div>
</div>
<div class="podbor-cta-row" style="margin-top:16px;">
<button class="btn-secondary" id="toHome">На главную</button>
<button class="btn-primary" id="toDetail">Открыть карточку</button>
</div>
`;
btn.style.display = "none";
result.querySelector("#toHome")?.addEventListener("click", () => {
location.hash = "";
location.reload();
});
result.querySelector("#toDetail")?.addEventListener("click", () => {
location.hash = `#/assembly/${data.id}`;
});
} catch (e) {
result.innerHTML = `<div class="error">Сеть: ${escHtml(e.message)}</div>`;
btn.disabled = false;
btn.textContent = "Заказать сборку";
}
}
/* ===================== Список сборок ===================== */
async function renderList() {
if (!root) return;
root.innerHTML = "";
root.appendChild(headerEl("Сборки", ""));
const loading = el(`<div class="loader-inline"><div class="spinner"></div></div>`);
root.appendChild(loading);
try {
const res = await fetch(`${BACKEND_URL}/api/assembly_list`, {
method: "POST",
body: JSON.stringify({
initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null,
}),
});
const data = await res.json();
loading.remove();
if (data.error) {
root.appendChild(el(`<div class="error">${escHtml(data.error)}</div>`));
return;
}
const items = data.assemblies || [];
if (!items.length) {
root.appendChild(el(`<div class="empty" style="padding:32px;text-align:center;color:var(--muted);">Сборок пока нет</div>`));
return;
}
const list = el(`<div class="assembly-list"></div>`);
for (const a of items) {
const dateStr = a.scheduled_at ? formatDateHuman(a.scheduled_at) : "—";
const statusLabel = {
created: "📝 создана",
scheduled: "📅 назначена",
in_progress: "🔧 в работе",
completed: "✅ завершена",
cancelled: "❌ отменена",
}[a.status] || a.status;
const card = el(`
<article class="assembly-card" data-id="${a.id}">
<div class="assembly-card-head">
<span class="assembly-card-status">${statusLabel}</span>
<span class="assembly-card-date">${escHtml(dateStr)}</span>
</div>
<div class="assembly-card-name">${escHtml(a.client_name || "Без имени")}</div>
<div class="assembly-card-address">${escHtml(a.address || "адрес не указан")}</div>
${a.scope_of_work ? `<div class="assembly-card-scope">${escHtml(a.scope_of_work.slice(0, 120))}${a.scope_of_work.length > 120 ? "…" : ""}</div>` : ""}
</article>
`);
card.addEventListener("click", () => {
haptic && haptic("impact");
location.hash = `#/assembly/${a.id}`;
});
list.appendChild(card);
}
root.appendChild(list);
} catch (e) {
loading.remove();
root.appendChild(el(`<div class="error">Сеть: ${escHtml(e.message)}</div>`));
}
}
/* ===================== Детальная карточка ===================== */
async function renderDetail(id) {
if (!root) return;
root.innerHTML = "";
root.appendChild(headerEl("Сборка", ""));
const loading = el(`<div class="loader-inline"><div class="spinner"></div></div>`);
root.appendChild(loading);
let a;
try {
const res = await fetch(`${BACKEND_URL}/api/assembly_detail`, {
method: "POST",
body: JSON.stringify({
initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null,
assembly_id: id,
}),
});
a = await res.json();
} catch (e) {
loading.remove();
root.appendChild(el(`<div class="error">Сеть: ${escHtml(e.message)}</div>`));
return;
}
loading.remove();
if (a.error) {
root.appendChild(el(`<div class="error">${escHtml(a.error)}</div>`));
return;
}
const dateStr = a.scheduled_at ? formatDateHuman(a.scheduled_at) : "Не назначена";
const statusLabel = {
created: "📝 создана",
scheduled: "📅 назначена",
in_progress: "🔧 в работе",
completed: "✅ завершена",
cancelled: "❌ отменена",
}[a.status] || a.status;
root.appendChild(el(`
<div class="measurement-detail-head">
<div class="kicker">Сборка #${(a.id || "").slice(0, 8)} · ${statusLabel}</div>
<h2 class="display-title">${escHtml(a.client_name || "Без имени")}</h2>
<div class="measurement-detail-meta">
${a.client_phone ? `<span>📞 ${escHtml(a.client_phone)}</span>` : ""}
<span>📍 ${escHtml(a.address || "адрес не указан")}</span>
<span>📅 ${escHtml(dateStr)}</span>
</div>
</div>
`));
if (a.gcal_event_url) {
root.appendChild(el(`
<div style="padding:4px 16px 8px;">
<a href="${a.gcal_event_url}" target="_blank" rel="noopener" style="color:var(--accent-1, #003E7E);font-size:13px;">📅 Открыть в Google Calendar</a>
</div>
`));
}
root.appendChild(el(`
<section class="block">
<div class="block-head">🛠 Состав работ</div>
<div style="padding:12px 4px;color:var(--ink);font-size:14.5px;line-height:1.5;white-space:pre-wrap;">${escHtml(a.scope_of_work || "—")}</div>
</section>
`));
if (a.manager_note) {
root.appendChild(el(`
<section class="block">
<div class="block-head">📝 Заметка от менеджера</div>
<div style="padding:12px 4px;color:var(--ink);font-size:14px;line-height:1.4;white-space:pre-wrap;">${escHtml(a.manager_note)}</div>
</section>
`));
}
// Этапы 2-3 (фото / подпись) — добавим в следующем коммите
root.appendChild(el(`
<div style="padding:18px 16px;text-align:center;color:var(--muted);font-size:13px;">
Фото-отчёт и приёмка появятся в следующем обновлении.
</div>
`));
}
/* ===================== Helpers ===================== */
function escHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function escAttr(s) { return escHtml(s); }
return { mount };
})();

View File

@ -413,6 +413,7 @@ const Clients = (function () {
<div class="client-quick-actions"> <div class="client-quick-actions">
<button class="qa-btn" data-act="podbor">🤖<span>Подбор техники</span></button> <button class="qa-btn" data-act="podbor">🤖<span>Подбор техники</span></button>
<button class="qa-btn" data-act="measure">📐<span>Заказать замер</span></button> <button class="qa-btn" data-act="measure">📐<span>Заказать замер</span></button>
<button class="qa-btn" data-act="assembly">🔨<span>Заказать сборку</span></button>
<button class="qa-btn" data-act="copy">📋<span>Копировать ФИО+тел</span></button> <button class="qa-btn" data-act="copy">📋<span>Копировать ФИО+тел</span></button>
</div> </div>
`); `);
@ -428,6 +429,15 @@ const Clients = (function () {
name: client.client_name, phone: client.client_phone, name: client.client_name, phone: client.client_phone,
})); }));
location.hash = "#/request"; location.hash = "#/request";
} else if (act === "assembly") {
// Pre-fill assembly with client info + address из последнего замера
sessionStorage.setItem("prefillAssembly", JSON.stringify({
name: client.client_name,
phone: client.client_phone,
address: (myMeasurements[0] && myMeasurements[0].address) || "",
measurement_id: (myMeasurements[0] && myMeasurements[0].id) || "",
}));
location.hash = "#/assembly/new";
} else if (act === "copy") { } else if (act === "copy") {
const txt = `${client.client_name || ""} ${client.client_phone || ""}`.trim(); const txt = `${client.client_name || ""} ${client.client_phone || ""}`.trim();
(navigator.clipboard?.writeText(txt) || Promise.resolve()) (navigator.clipboard?.writeText(txt) || Promise.resolve())

View File

@ -3052,6 +3052,63 @@
flex-shrink: 0; flex-shrink: 0;
} }
/* ===== Сборки (Phase 4) ===== */
.assembly-list {
display: flex;
flex-direction: column;
gap: 10px;
padding: 8px 0;
}
.assembly-card {
background: var(--card, #FFFFFF);
border: 1px solid var(--line, rgba(15,15,14,0.08));
border-left: 3px solid #B07E00;
border-radius: 12px;
padding: 12px 14px;
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
}
.assembly-card:active {
transform: scale(0.98);
}
.assembly-card-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.assembly-card-status {
font-size: 11px;
font-weight: 600;
color: var(--muted, #998877);
text-transform: lowercase;
}
.assembly-card-date {
font-size: 12px;
color: var(--muted);
font-family: var(--font-mono, monospace);
}
.assembly-card-name {
font-weight: 600;
font-size: 15px;
color: var(--ink);
margin-bottom: 2px;
}
.assembly-card-address {
font-size: 13px;
color: var(--muted);
margin-bottom: 4px;
}
.assembly-card-scope {
font-size: 12.5px;
color: var(--ink-2, #2A2622);
line-height: 1.35;
margin-top: 4px;
padding-top: 6px;
border-top: 1px dashed var(--line);
}
/* ===== Печать / PDF ===== */ /* ===== Печать / PDF ===== */
@media print { @media print {
body { background: white !important; color: black !important; } body { background: white !important; color: black !important; }

View File

@ -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=20260514b"> <link rel="stylesheet" href="assets/styles.css?v=20260514c">
<link rel="stylesheet" href="assets/podbor.css?v=20260514b"> <link rel="stylesheet" href="assets/podbor.css?v=20260514c">
</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=20260514b" alt="@wasrusgen1"> <img class="brand-logo" src="assets/wasrusgen-logo.svg?v=20260514c" 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,14 +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=20260514b"></script> <script src="assets/icons.js?v=20260514c"></script>
<script src="assets/podbor.config.js?v=20260514b"></script> <script src="assets/podbor.config.js?v=20260514c"></script>
<script src="assets/podbor.picts.js?v=20260514b"></script> <script src="assets/podbor.picts.js?v=20260514c"></script>
<script src="assets/podbor.js?v=20260514b"></script> <script src="assets/podbor.js?v=20260514c"></script>
<script src="assets/clients.js?v=20260514b"></script> <script src="assets/clients.js?v=20260514c"></script>
<script src="assets/zamer-picts.js?v=20260514b"></script> <script src="assets/zamer-picts.js?v=20260514c"></script>
<script src="assets/measurements.js?v=20260514b"></script> <script src="assets/measurements.js?v=20260514c"></script>
<script src="assets/request.js?v=20260514b"></script> <script src="assets/request.js?v=20260514c"></script>
<script src="assets/app.js?v=20260514b"></script> <script src="assets/assembly.js?v=20260514c"></script>
<script src="assets/app.js?v=20260514c"></script>
</body> </body>
</html> </html>