mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 16:24:50 +00:00
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:
parent
5e6746e676
commit
52eb0e4a96
@ -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.
|
||||||
Возвращает (нормализованный, валиден ли)."""
|
Возвращает (нормализованный, валиден ли)."""
|
||||||
|
|||||||
@ -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))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
395
miniapp/assets/assembly.js
Normal 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, "&").replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
function escAttr(s) { return escHtml(s); }
|
||||||
|
|
||||||
|
return { mount };
|
||||||
|
})();
|
||||||
@ -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())
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user