feat: expeditor cabinet, electronic signature (OTP+canvas), invoice room picker

New modules:
- expeditor_dashboard.js: route list (date-grouped) + act detail + signature screen
- invoice.js: 3-col chip room picker, 2500₽ base + 1000₽ extra logic
- act4.js, measurer_dashboard.js, finance_summary.js, client_timeline.js, feedback.js, staff_roster.js

Backend:
- /api/expeditor_inbox: filtered assembly list for expeditor role
- /api/act4_request_otp: 6-digit OTP via Telegram, 10-min expiry
- /api/act4_verify_otp: validates code, marks act as signed
- /api/act4_save_signature: saves base64 canvas signature
- Act4s sheet: added signature_b64, otp_code, otp_expires_at columns

Tests:
- tests/expeditor_scenarios.md: 11 manual test scenarios

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wasrusgen 2026-05-21 14:11:20 +03:00
parent 44379576f2
commit 02f8dba469
22 changed files with 7851 additions and 35 deletions

View File

@ -262,3 +262,86 @@ def call_ai(user_prompt: str, system_prompt: str | None = None,
pass
return {"json": json_obj, "text": response_text, "tokens": tokens, "model": actual_model}
_FILES_URL = "https://gigachat.devices.sberbank.ru/api/v1/files"
_VISION_MODEL = "GigaChat-Pro"
def parse_receipt_amount(image_b64: str) -> dict[str, Any]:
"""Парсит фото чека через GigaChat Vision.
Возвращает {"amount": float|None, "raw": str, "error": bool}."""
import base64, io, re as _re
try:
token = _get_token()
except Exception as e:
return {"amount": None, "raw": "", "error": True, "msg": str(e)}
# Декодируем data URL
m = re.match(r"^data:image/(jpeg|jpg|png|webp);base64,(.+)$", image_b64.strip(), re.DOTALL)
if not m:
return {"amount": None, "raw": "", "error": True, "msg": "bad_image_format"}
ext = "jpg" if m.group(1) in ("jpeg", "jpg") else m.group(1)
mime = f"image/{m.group(1)}"
raw_bytes = base64.b64decode(m.group(2), validate=False)
# 1. Загружаем файл в GigaChat Files API
file_id: str | None = None
try:
with httpx.Client(timeout=30.0) as client:
resp = client.post(
_FILES_URL,
headers={"Authorization": f"Bearer {token}"},
files={"file": (f"receipt.{ext}", io.BytesIO(raw_bytes), mime)},
data={"purpose": "general"},
)
if resp.status_code < 400:
file_id = resp.json().get("id")
except Exception as e:
return {"amount": None, "raw": "", "error": True, "msg": f"file_upload: {e}"}
if not file_id:
return {"amount": None, "raw": "", "error": True, "msg": "no_file_id"}
# 2. Спрашиваем итоговую сумму
payload = {
"model": _VISION_MODEL,
"temperature": 0.1,
"max_tokens": 256,
"messages": [{
"role": "user",
"content": [
{"type": "text",
"text": "На этом фото кассовый чек. Найди итоговую сумму (ИТОГ, ИТОГО, СУММА, TOTAL). "
"Ответь ТОЛЬКО числом в рублях без пробелов и без знака ₽ и без копеек, например: 1250. "
"Если сумму найти не удалось — напиши 0."},
{"type": "image_url", "image_url": {"url": f"gigachat://files/{file_id}"}},
],
}],
}
try:
with httpx.Client(timeout=45.0) as client:
resp = client.post(
_CHAT_URL,
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
content=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
)
except Exception as e:
return {"amount": None, "raw": "", "error": True, "msg": f"vision_call: {e}"}
if resp.status_code >= 400:
return {"amount": None, "raw": resp.text[:200], "error": True, "msg": f"vision_http_{resp.status_code}"}
raw_text = ((resp.json().get("choices") or [{}])[0].get("message") or {}).get("content", "").strip()
# Извлекаем число из ответа
nums = re.findall(r"\d[\d\s]*(?:[.,]\d{1,2})?", raw_text)
amount: float | None = None
for n in nums:
try:
v = float(n.replace(" ", "").replace(",", "."))
if v > 0:
amount = v
break
except ValueError:
pass
return {"amount": amount, "raw": raw_text, "error": False}

File diff suppressed because it is too large Load Diff

View File

@ -160,7 +160,7 @@ def find_user(tg_id: int) -> dict[str, Any] | None:
# ---- Multi-role helpers ----
VALID_ROLES = {"manager", "client", "measurer", "assembler"}
VALID_ROLES = {"manager", "client", "measurer", "assembler", "expeditor"}
def parse_roles(role_str: str) -> list[str]:
@ -180,11 +180,11 @@ def has_role(user: dict[str, Any] | None, role: str) -> bool:
def is_master(user: dict[str, Any] | None) -> bool:
"""«Мастер» — единая роль для замерщика+сборщика.
True если у пользователя есть либо measurer, либо assembler."""
True если у пользователя есть либо measurer, либо assembler, либо expeditor."""
if not user:
return False
roles = parse_roles(user.get("role", ""))
return "measurer" in roles or "assembler" in roles
return "measurer" in roles or "assembler" in roles or "expeditor" in roles
def primary_role(user: dict[str, Any] | None) -> str:
@ -192,7 +192,7 @@ def primary_role(user: dict[str, Any] | None) -> str:
if not user:
return ""
roles = parse_roles(user.get("role", ""))
for r in ("manager", "measurer", "assembler", "client"):
for r in ("manager", "measurer", "assembler", "expeditor", "client"):
if r in roles:
return r
return roles[0] if roles else ""
@ -268,11 +268,16 @@ def list_users_with_role(role: str) -> list[dict[str, Any]]:
if role in parse_roles(row.get("role", "")):
full_name = (f"{row.get('first_name', '')} {row.get('last_name', '')}".strip()
or row.get("tg_username", ""))
equipment_raw = row.get("equipment", "")
equipment_list = [x.strip() for x in equipment_raw.split(",") if x.strip()] if equipment_raw else []
EQUIPMENT_REQUIRED = {"tablet", "laser_tape", "angle_meter", "tape", "laser_level"}
equipment_ok = EQUIPMENT_REQUIRED.issubset(set(equipment_list)) if role == "measurer" else True
out.append({
"tg_id": row.get("tg_id"),
"full_name": full_name,
"tg_username": row.get("tg_username", ""),
"roles": parse_roles(row.get("role", "")),
"equipment_ok": equipment_ok,
})
return out

View File

@ -10,3 +10,5 @@ beautifulsoup4>=4.12.0
lxml>=5.2.0
playwright>=1.45.0
openpyxl>=3.1.0
qrcode>=8.0
Pillow>=10.0

345
icon-picker.html Normal file
View File

@ -0,0 +1,345 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Выбор иконок — Tabler Icons (MIT)</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #EFE9DF;
padding: 32px 24px;
color: #3a2a1a;
}
h1 { color: #6B4A2B; font-size: 22px; margin-bottom: 6px; }
.subtitle { color: #9E7A5A; font-size: 13px; margin-bottom: 36px; }
.section { margin-bottom: 36px; }
.section-title {
font-size: 13px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.1em; color: #6B4A2B; margin-bottom: 16px;
display: flex; align-items: center; gap: 10px;
}
.section-title::after {
content: ''; flex: 1; height: 1px; background: #C4A882;
}
.icons-row {
display: flex; flex-wrap: wrap; gap: 12px;
}
.icon-card {
background: #FBF7F0;
border: 2px solid transparent;
border-radius: 14px;
padding: 18px 14px 12px;
display: flex; flex-direction: column;
align-items: center; gap: 10px;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s, transform 0.1s;
width: 110px;
box-shadow: 0 2px 6px rgba(107,74,43,0.08);
user-select: none;
}
.icon-card:hover {
border-color: #C4A882;
box-shadow: 0 4px 14px rgba(107,74,43,0.15);
transform: translateY(-2px);
}
.icon-card.selected {
border-color: #6B4A2B;
background: #F0E8DC;
box-shadow: 0 4px 16px rgba(107,74,43,0.2);
}
.icon-card svg {
display: block;
}
.icon-name {
font-size: 11px; color: #9E7A5A; text-align: center;
font-weight: 500; line-height: 1.3;
}
.icon-card.selected .icon-name { color: #6B4A2B; font-weight: 700; }
.result-bar {
position: fixed; bottom: 0; left: 0; right: 0;
background: #6B4A2B; color: #FBF7F0;
padding: 14px 24px;
display: flex; align-items: center; justify-content: space-between;
font-size: 14px;
transform: translateY(100%);
transition: transform 0.25s;
}
.result-bar.visible { transform: translateY(0); }
.result-bar strong { font-weight: 700; }
.result-bar code {
background: rgba(255,255,255,0.15); border-radius: 4px;
padding: 2px 8px; font-family: monospace; font-size: 12px;
}
.result-bar .hint { color: #C4A882; font-size: 12px; }
</style>
</head>
<body>
<h1>Выбор иконок для роли</h1>
<p class="subtitle">Источник: <strong>Tabler Icons</strong> — MIT лицензия, бесплатно в коммерческих проектах. Нажмите на иконку, чтобы выбрать.</p>
<!-- ====== МЕНЕДЖЕР ====== -->
<div class="section">
<div class="section-title">Менеджер — «Я менеджер / Веду клиентов и заказы»</div>
<div class="icons-row">
<div class="icon-card" data-role="manager" data-name="user" onclick="pick(this)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="72" height="72" fill="none" stroke="#6B4A2B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0"/>
<path d="M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2"/>
</svg>
<div class="icon-name">Человек</div>
</div>
<div class="icon-card" data-role="manager" data-name="user-circle" onclick="pick(this)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="72" height="72" fill="none" stroke="#6B4A2B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"/>
<path d="M9 10a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"/>
<path d="M6.168 18.849a4 4 0 0 1 3.832 -2.849h4a4 4 0 0 1 3.834 2.855"/>
</svg>
<div class="icon-name">Профиль в круге</div>
</div>
<div class="icon-card" data-role="manager" data-name="user-check" onclick="pick(this)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="72" height="72" fill="none" stroke="#6B4A2B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0"/>
<path d="M6 21v-2a4 4 0 0 1 4 -4h4"/>
<path d="M15 19l2 2l4 -4"/>
</svg>
<div class="icon-name">Подтверждённый</div>
</div>
<div class="icon-card" data-role="manager" data-name="user-star" onclick="pick(this)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="72" height="72" fill="none" stroke="#6B4A2B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0"/>
<path d="M6 21v-2a4 4 0 0 1 4 -4h.5"/>
<path d="M17.8 20.817l-2.172 1.138a.392 .392 0 0 1 -.568 -.41l.415 -2.411l-1.757 -1.707a.389 .389 0 0 1 .217 -.665l2.428 -.352l1.086 -2.193a.392 .392 0 0 1 .702 0l1.086 2.193l2.428 .352a.39 .39 0 0 1 .217 .665l-1.757 1.707l.414 2.41a.39 .39 0 0 1 -.567 .411z"/>
</svg>
<div class="icon-name">VIP / Звезда</div>
</div>
<div class="icon-card" data-role="manager" data-name="tie" onclick="pick(this)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="72" height="72" fill="none" stroke="#6B4A2B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22l4 -4l-2.5 -11l.993 -2.649a1 1 0 0 0 -.936 -1.351h-3.114a1 1 0 0 0 -.936 1.351l.993 2.649l-2.5 11l4 4"/>
<path d="M10.5 7h3l5 5.5"/>
</svg>
<div class="icon-name">Галстук</div>
</div>
<div class="icon-card" data-role="manager" data-name="briefcase" onclick="pick(this)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="72" height="72" fill="none" stroke="#6B4A2B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v9a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2l0 -9"/>
<path d="M8 7v-2a2 2 0 0 1 2 -2h4a2 2 0 0 1 2 2v2"/>
<path d="M12 12l0 .01"/>
<path d="M3 13a20 20 0 0 0 18 0"/>
</svg>
<div class="icon-name">Портфель</div>
</div>
<div class="icon-card" data-role="manager" data-name="device-laptop" onclick="pick(this)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="72" height="72" fill="none" stroke="#6B4A2B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 19l18 0"/>
<path d="M5 7a1 1 0 0 1 1 -1h12a1 1 0 0 1 1 1v8a1 1 0 0 1 -1 1h-12a1 1 0 0 1 -1 -1l0 -8"/>
</svg>
<div class="icon-name">Ноутбук</div>
</div>
</div>
</div>
<!-- ====== КЛИЕНТ ====== -->
<div class="section">
<div class="section-title">Клиент — «Я клиент / Заказал кухню ЗОВ»</div>
<div class="icons-row">
<div class="icon-card" data-role="client" data-name="home" onclick="pick(this)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="72" height="72" fill="none" stroke="#6B4A2B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12l-2 0l9 -9l9 9l-2 0"/>
<path d="M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-7"/>
<path d="M9 21v-6a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v6"/>
</svg>
<div class="icon-name">Дом классик</div>
</div>
<div class="icon-card" data-role="client" data-name="home-2" onclick="pick(this)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="72" height="72" fill="none" stroke="#6B4A2B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12l-2 0l9 -9l9 9l-2 0"/>
<path d="M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-7"/>
<path d="M10 12h4v4h-4l0 -4"/>
</svg>
<div class="icon-name">Дом с окном</div>
</div>
<div class="icon-card" data-role="client" data-name="smart-home" onclick="pick(this)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="72" height="72" fill="none" stroke="#6B4A2B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 8.71l-5.333 -4.148a2.666 2.666 0 0 0 -3.274 0l-5.334 4.148a2.665 2.665 0 0 0 -1.029 2.105v7.2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-7.2c0 -.823 -.38 -1.6 -1.03 -2.105"/>
<path d="M16 15c-2.21 1.333 -5.792 1.333 -8 0"/>
</svg>
<div class="icon-name">Смарт-дом</div>
</div>
<div class="icon-card" data-role="client" data-name="home-heart" onclick="pick(this)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="72" height="72" fill="none" stroke="#6B4A2B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12l-9 -9l-9 9h2v7a2 2 0 0 0 2 2h6"/>
<path d="M9 21v-6a2 2 0 0 1 2 -2h2c.39 0 .754 .112 1.061 .304"/>
<path d="M19 21.5l2.518 -2.58a1.74 1.74 0 0 0 0 -2.413a1.627 1.627 0 0 0 -2.346 0l-.168 .172l-.168 -.172a1.627 1.627 0 0 0 -2.346 0a1.74 1.74 0 0 0 0 2.412l2.51 2.59z"/>
</svg>
<div class="icon-name">Дом с сердцем</div>
</div>
<div class="icon-card" data-role="client" data-name="home-check" onclick="pick(this)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="72" height="72" fill="none" stroke="#6B4A2B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21v-6a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2"/>
<path d="M19 13.488v-1.488h2l-9 -9l-9 9h2v7a2 2 0 0 0 2 2h4.525"/>
<path d="M15 19l2 2l4 -4"/>
</svg>
<div class="icon-name">Дом с галкой</div>
</div>
<div class="icon-card" data-role="client" data-name="building" onclick="pick(this)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="72" height="72" fill="none" stroke="#6B4A2B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 21l18 0"/>
<path d="M9 8l1 0"/><path d="M9 12l1 0"/><path d="M9 16l1 0"/>
<path d="M14 8l1 0"/><path d="M14 12l1 0"/><path d="M14 16l1 0"/>
<path d="M5 21v-16a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v16"/>
</svg>
<div class="icon-name">Здание</div>
</div>
<div class="icon-card" data-role="client" data-name="door" onclick="pick(this)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="72" height="72" fill="none" stroke="#6B4A2B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 12v.01"/>
<path d="M3 21h18"/>
<path d="M6 21v-16a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v16"/>
</svg>
<div class="icon-name">Дверь</div>
</div>
</div>
</div>
<!-- ====== СОТРУДНИК ====== -->
<div class="section" style="padding-bottom: 80px;">
<div class="section-title">Сотрудник — «Я сотрудник / Замерщик или сборщик ЗОВ»</div>
<div class="icons-row">
<div class="icon-card" data-role="staff" data-name="helmet" onclick="pick(this)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="72" height="72" fill="none" stroke="#6B4A2B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 4a9 9 0 0 1 5.656 16h-11.312a9 9 0 0 1 5.656 -16"/>
<path d="M20 9h-8.8a1 1 0 0 0 -.968 1.246c.507 2 1.596 3.418 3.268 4.254c2 1 4.333 1.5 7 1.5"/>
</svg>
<div class="icon-name">Каска</div>
</div>
<div class="icon-card" data-role="staff" data-name="tool" onclick="pick(this)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="72" height="72" fill="none" stroke="#6B4A2B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M7 10h3v-3l-3.5 -3.5a6 6 0 0 1 8 8l6 6a2 2 0 0 1 -3 3l-6 -6a6 6 0 0 1 -8 -8l3.5 3.5"/>
</svg>
<div class="icon-name">Гаечный ключ</div>
</div>
<div class="icon-card" data-role="staff" data-name="tools" onclick="pick(this)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="72" height="72" fill="none" stroke="#6B4A2B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 21h4l13 -13a1.5 1.5 0 0 0 -4 -4l-13 13v4"/>
<path d="M14.5 5.5l4 4"/>
<path d="M12 8l-5 -5l-4 4l5 5"/>
<path d="M7 8l-1.5 1.5"/>
<path d="M16 12l5 5l-4 4l-5 -5"/>
<path d="M16 17l-1.5 1.5"/>
</svg>
<div class="icon-name">Набор инструментов</div>
</div>
<div class="icon-card" data-role="staff" data-name="hammer" onclick="pick(this)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="72" height="72" fill="none" stroke="#6B4A2B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M11.414 10l-7.383 7.418a2.091 2.091 0 0 0 0 2.967a2.11 2.11 0 0 0 2.976 0l7.407 -7.385"/>
<path d="M18.121 15.293l2.586 -2.586a1 1 0 0 0 0 -1.414l-7.586 -7.586a1 1 0 0 0 -1.414 0l-2.586 2.586a1 1 0 0 0 0 1.414l7.586 7.586a1 1 0 0 0 1.414 0z"/>
</svg>
<div class="icon-name">Молоток</div>
</div>
<div class="icon-card" data-role="staff" data-name="ruler-2" onclick="pick(this)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="72" height="72" fill="none" stroke="#6B4A2B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 3l4 4l-14 14l-4 -4l14 -14"/>
<path d="M16 7l-1.5 -1.5"/>
<path d="M13 10l-1.5 -1.5"/>
<path d="M10 13l-1.5 -1.5"/>
<path d="M7 16l-1.5 -1.5"/>
</svg>
<div class="icon-name">Линейка / замер</div>
</div>
<div class="icon-card" data-role="staff" data-name="user-cog" onclick="pick(this)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="72" height="72" fill="none" stroke="#6B4A2B" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0"/>
<path d="M6 21v-2a4 4 0 0 1 4 -4h2.5"/>
<path d="M19.001 19a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/>
<path d="M19.001 15.5v1.5"/>
<path d="M19.001 21v1.5"/>
<path d="M22.032 17.25l-1.299 .75"/>
<path d="M17.27 20l-1.3 .75"/>
<path d="M15.97 17.25l1.3 .75"/>
<path d="M20.733 20l1.3 .75"/>
</svg>
<div class="icon-name">Сотрудник+настройки</div>
</div>
</div>
</div>
<!-- Статус-бар выбора -->
<div class="result-bar" id="resultBar">
<div>
<span id="selectionText">Выберите иконки для всех трёх ролей</span><br>
<span class="hint" id="selectionHint">Нажмите на карточку</span>
</div>
<div id="selectionIcons" style="display:flex; gap:24px; align-items:center;"></div>
</div>
<script>
const selected = { manager: null, client: null, staff: null };
const roleNames = { manager: 'Менеджер', client: 'Клиент', staff: 'Сотрудник' };
function pick(card) {
const role = card.dataset.role;
const name = card.dataset.name;
// Снять выбор с предыдущей карточки той же роли
document.querySelectorAll(`.icon-card[data-role="${role}"]`).forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
selected[role] = name;
updateBar();
}
function updateBar() {
const bar = document.getElementById('resultBar');
const text = document.getElementById('selectionText');
const hint = document.getElementById('selectionHint');
const iconsDiv = document.getElementById('selectionIcons');
const filled = Object.entries(selected).filter(([, v]) => v !== null);
bar.classList.add('visible');
if (filled.length === 3) {
text.textContent = '✅ Все три роли выбраны!';
hint.innerHTML = Object.entries(selected).map(([role, name]) =>
`${roleNames[role]}: <code>${name}</code>`
).join(' · ');
} else {
text.textContent = `Выбрано ${filled.length} из 3`;
hint.textContent = filled.map(([role, name]) => `${roleNames[role]}: ${name}`).join(' · ') || 'Нажмите на карточку';
}
iconsDiv.innerHTML = filled.map(([role, name]) => {
const card = document.querySelector(`.icon-card[data-role="${role}"][data-name="${name}"]`);
return card ? `<div style="text-align:center"><div style="background:rgba(255,255,255,0.15);border-radius:8px;padding:6px">${card.querySelector('svg').outerHTML.replace(/width="\d+"/, 'width="36"').replace(/height="\d+"/, 'height="36"').replace(/stroke="#6B4A2B"/g, 'stroke="#FBF7F0"')}</div><div style="font-size:10px;color:#C4A882;margin-top:4px">${roleNames[role]}</div></div>` : '';
}).join('');
}
</script>
</body>
</html>

712
icon-preview.html Normal file
View File

@ -0,0 +1,712 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Icon Preview — 10 Styles × 3 Roles</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #EFE9DF;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
padding: 32px 24px;
min-height: 100vh;
}
h1 {
text-align: center;
color: #6B4A2B;
font-size: 24px;
font-weight: 700;
margin-bottom: 32px;
letter-spacing: 0.5px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
max-width: 1100px;
margin: 0 auto;
}
.card {
background: #FBF7F0;
border-radius: 14px;
padding: 20px;
box-shadow: 0 2px 8px rgba(107,74,43,0.10);
}
.card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 18px;
}
.badge {
width: 30px;
height: 30px;
border-radius: 50%;
background: #6B4A2B;
color: #FBF7F0;
font-size: 13px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.style-name {
font-size: 15px;
font-weight: 600;
color: #6B4A2B;
}
.icons-row {
display: flex;
gap: 16px;
justify-content: center;
align-items: flex-end;
}
.icon-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.icon-label {
font-size: 11px;
color: #8A6A4A;
font-weight: 500;
text-align: center;
letter-spacing: 0.3px;
}
</style>
</head>
<body>
<h1>Превью иконок — 10 стилей × 3 роли</h1>
<div class="grid">
<!-- ===== STYLE 1: Контур ===== -->
<div class="card">
<div class="card-header">
<div class="badge">1</div>
<div class="style-name">Контур</div>
</div>
<div class="icons-row">
<!-- Manager: outline -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<!-- head ellipse -->
<ellipse cx="22" cy="13" rx="6" ry="7" fill="none" stroke="#6B4A2B" stroke-width="1.5"/>
<!-- shoulders arc -->
<path d="M8 38 Q8 28 16 26 Q19 25 22 25 Q25 25 28 26 Q36 28 36 38" fill="none" stroke="#6B4A2B" stroke-width="1.5"/>
<!-- collar V -->
<path d="M18 26 L22 31 L26 26" fill="none" stroke="#6B4A2B" stroke-width="1.5"/>
<!-- tie outline -->
<path d="M21 31 L20.5 36 L22 38 L23.5 36 L23 31 Z" fill="none" stroke="#6B4A2B" stroke-width="1.2"/>
<!-- lapel lines -->
<line x1="18" y1="26" x2="15" y2="30" stroke="#6B4A2B" stroke-width="1.2"/>
<line x1="26" y1="26" x2="29" y2="30" stroke="#6B4A2B" stroke-width="1.2"/>
</svg>
<div class="icon-label">Менеджер</div>
</div>
<!-- Client: house outline -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<!-- roof triangle -->
<polyline points="6,22 22,6 38,22" fill="none" stroke="#6B4A2B" stroke-width="1.5"/>
<!-- walls rect -->
<rect x="9" y="22" width="26" height="16" fill="none" stroke="#6B4A2B" stroke-width="1.5"/>
<!-- window left -->
<rect x="12" y="26" width="7" height="6" fill="none" stroke="#6B4A2B" stroke-width="1.2"/>
<!-- window cross -->
<line x1="15.5" y1="26" x2="15.5" y2="32" stroke="#6B4A2B" stroke-width="1"/>
<line x1="12" y1="29" x2="19" y2="29" stroke="#6B4A2B" stroke-width="1"/>
<!-- window right -->
<rect x="25" y="26" width="7" height="6" fill="none" stroke="#6B4A2B" stroke-width="1.2"/>
<line x1="28.5" y1="26" x2="28.5" y2="32" stroke="#6B4A2B" stroke-width="1"/>
<line x1="25" y1="29" x2="32" y2="29" stroke="#6B4A2B" stroke-width="1"/>
<!-- door arch -->
<path d="M19 38 L19 31 Q22 28 25 31 L25 38" fill="none" stroke="#6B4A2B" stroke-width="1.2"/>
<!-- knob -->
<circle cx="24" cy="34.5" r="0.8" fill="#6B4A2B"/>
</svg>
<div class="icon-label">Клиент</div>
</div>
<!-- Staff: construction worker outline -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<!-- helmet dome arc -->
<path d="M12 22 Q12 10 22 10 Q32 10 32 22" fill="none" stroke="#6B4A2B" stroke-width="1.5"/>
<!-- brim line -->
<line x1="9" y1="22" x2="35" y2="22" stroke="#6B4A2B" stroke-width="2"/>
<!-- head circle -->
<circle cx="22" cy="28" r="5.5" fill="none" stroke="#6B4A2B" stroke-width="1.5"/>
<!-- torso/jacket -->
<path d="M10 44 Q10 36 17 34 L22 33.5 L27 34 Q34 36 34 44" fill="none" stroke="#6B4A2B" stroke-width="1.5"/>
<!-- wrench L-shape -->
<line x1="30" y1="30" x2="38" y2="22" stroke="#6B4A2B" stroke-width="1.5"/>
<line x1="35" y1="22" x2="38" y2="25" stroke="#6B4A2B" stroke-width="1.5"/>
<circle cx="38" cy="22" r="2.5" fill="none" stroke="#6B4A2B" stroke-width="1.2"/>
</svg>
<div class="icon-label">Сотрудник</div>
</div>
</div>
</div>
<!-- ===== STYLE 2: Силуэт ===== -->
<div class="card">
<div class="card-header">
<div class="badge">2</div>
<div class="style-name">Силуэт</div>
</div>
<div class="icons-row">
<!-- Manager silhouette -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<!-- head -->
<circle cx="22" cy="13" r="7" fill="#6B4A2B"/>
<!-- torso/suit path -->
<path d="M8 44 Q8 28 16 26 L18 25.5 L22 24 L26 25.5 L28 26 Q36 28 36 44 Z" fill="#6B4A2B"/>
<!-- collar V white -->
<path d="M18 26 L22 32 L26 26 L24 26 L22 30 L20 26 Z" fill="#FBF7F0"/>
<!-- tie white -->
<path d="M21 32 L20.5 37 L22 39 L23.5 37 L23 32 Z" fill="#FBF7F0"/>
</svg>
<div class="icon-label">Менеджер</div>
</div>
<!-- Client silhouette -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<!-- roof triangle filled -->
<polygon points="6,22 22,5 38,22" fill="#6B4A2B"/>
<!-- walls filled -->
<rect x="9" y="22" width="26" height="17" fill="#6B4A2B"/>
<!-- left window white -->
<rect x="12" y="26" width="7" height="6" fill="#FBF7F0"/>
<!-- right window white -->
<rect x="25" y="26" width="7" height="6" fill="#FBF7F0"/>
<!-- door white -->
<path d="M19 39 L19 31.5 Q22 28.5 25 31.5 L25 39 Z" fill="#FBF7F0"/>
</svg>
<div class="icon-label">Клиент</div>
</div>
<!-- Staff silhouette -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<!-- helmet + brim filled path -->
<path d="M9 22 L9 21 Q9 9 22 9 Q35 9 35 21 L35 22 Z" fill="#6B4A2B"/>
<!-- head circle -->
<circle cx="22" cy="28" r="5.5" fill="#6B4A2B"/>
<!-- torso -->
<path d="M10 44 Q10 36 17 34 L22 33.5 L27 34 Q34 36 34 44 Z" fill="#6B4A2B"/>
<!-- eyes white dots -->
<circle cx="20" cy="27.5" r="1" fill="#FBF7F0"/>
<circle cx="24" cy="27.5" r="1" fill="#FBF7F0"/>
<!-- wrench white -->
<path d="M29 31 L37 23 L35 21 L33 23 L31 21 L33 19 Q34 17 36 18 Q38 19 38 21 Q38 24 36 25 L32 29 Z" fill="#FBF7F0"/>
</svg>
<div class="icon-label">Сотрудник</div>
</div>
</div>
</div>
<!-- ===== STYLE 3: Мягкий ===== -->
<div class="card">
<div class="card-header">
<div class="badge">3</div>
<div class="style-name">Мягкий</div>
</div>
<div class="icons-row">
<!-- Manager soft -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<circle cx="22" cy="13" r="6.5" fill="#FBF7F0" stroke="#6B4A2B" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 38 Q9 28 17 26.5 Q19.5 26 22 26 Q24.5 26 27 26.5 Q35 28 35 38" fill="#FBF7F0" stroke="#6B4A2B" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<!-- collar lines -->
<line x1="19" y1="26.5" x2="22" y2="31" stroke="#6B4A2B" stroke-width="2" stroke-linecap="round"/>
<line x1="25" y1="26.5" x2="22" y2="31" stroke="#6B4A2B" stroke-width="2" stroke-linecap="round"/>
<!-- tie filled polygon -->
<polygon points="21,31 20.5,37 22,39 23.5,37 23,31" fill="#6B4A2B" stroke="#6B4A2B" stroke-width="0.5" stroke-linejoin="round"/>
</svg>
<div class="icon-label">Менеджер</div>
</div>
<!-- Client soft -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<path d="M6,22 L22,6 L38,22" fill="#FBF7F0" stroke="#6B4A2B" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="9" y="21" width="26" height="18" rx="2" fill="#FBF7F0" stroke="#6B4A2B" stroke-width="2.5" stroke-linejoin="round"/>
<!-- windows with glass blue -->
<rect x="12" y="25" width="7" height="6" rx="1.5" fill="#D6EFF5" stroke="#6B4A2B" stroke-width="1.5"/>
<rect x="25" y="25" width="7" height="6" rx="1.5" fill="#D6EFF5" stroke="#6B4A2B" stroke-width="1.5"/>
<!-- door arch warm sand -->
<path d="M19.5 39 L19.5 32 Q22 29 24.5 32 L24.5 39" fill="#EDE0C8" stroke="#6B4A2B" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="icon-label">Клиент</div>
</div>
<!-- Staff soft -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<!-- dome arc -->
<path d="M12 22 Q12 11 22 11 Q32 11 32 22" fill="#FBF7F0" stroke="#6B4A2B" stroke-width="2.5" stroke-linecap="round"/>
<!-- brim line thick -->
<line x1="9" y1="22" x2="35" y2="22" stroke="#6B4A2B" stroke-width="3" stroke-linecap="round"/>
<circle cx="22" cy="28.5" r="5.5" fill="#FBF7F0" stroke="#6B4A2B" stroke-width="2.5"/>
<path d="M11 44 Q11 36 17 34 L22 33.5 L27 34 Q33 36 33 44" fill="#FBF7F0" stroke="#6B4A2B" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<!-- wrench line + circle -->
<line x1="31" y1="31" x2="39" y2="23" stroke="#6B4A2B" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="39" cy="22" r="2.5" fill="#FBF7F0" stroke="#6B4A2B" stroke-width="2"/>
<line x1="36.5" y1="22" x2="36.5" y2="25" stroke="#6B4A2B" stroke-width="2" stroke-linecap="round"/>
</svg>
<div class="icon-label">Сотрудник</div>
</div>
</div>
</div>
<!-- ===== STYLE 4: Значок ===== -->
<div class="card">
<div class="card-header">
<div class="badge">4</div>
<div class="style-name">Значок</div>
</div>
<div class="icons-row">
<!-- Manager badge -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<circle cx="22" cy="22" r="21" fill="#6B4A2B"/>
<!-- white head -->
<circle cx="22" cy="15" r="5.5" fill="#FBF7F0"/>
<!-- white torso -->
<path d="M9 40 Q9 30 17 28 L22 27.5 L27 28 Q35 30 35 40 Z" fill="#FBF7F0"/>
<!-- dark collar/suit -->
<path d="M17 28 L22 34 L27 28 L25 28 L22 32 L19 28 Z" fill="#6B4A2B"/>
<!-- white tie -->
<polygon points="21,34 20.5,39 22,40.5 23.5,39 23,34" fill="#FBF7F0"/>
</svg>
<div class="icon-label">Менеджер</div>
</div>
<!-- Client badge -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<circle cx="22" cy="22" r="21" fill="#6B4A2B"/>
<!-- white roof -->
<polygon points="8,22 22,8 36,22" fill="#FBF7F0"/>
<!-- white walls -->
<rect x="11" y="22" width="22" height="14" fill="#FBF7F0"/>
<!-- dark windows -->
<rect x="13" y="25" width="6" height="5" fill="#6B4A2B"/>
<rect x="25" y="25" width="6" height="5" fill="#6B4A2B"/>
<!-- dark door -->
<path d="M20 36 L20 30.5 Q22 28 24 30.5 L24 36 Z" fill="#6B4A2B"/>
</svg>
<div class="icon-label">Клиент</div>
</div>
<!-- Staff badge -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<circle cx="22" cy="22" r="21" fill="#6B4A2B"/>
<!-- white helmet -->
<path d="M11 23 L11 22 Q11 12 22 12 Q33 12 33 22 L33 23 Z" fill="#FBF7F0"/>
<!-- brim stripe dark -->
<rect x="10" y="22" width="24" height="2" fill="#5A3D24"/>
<!-- white head -->
<circle cx="22" cy="29" r="5" fill="#FBF7F0"/>
<!-- white torso -->
<path d="M12 42 Q12 35 18 33.5 L22 33 L26 33.5 Q32 35 32 42 Z" fill="#FBF7F0"/>
<!-- dark eyes -->
<circle cx="20.5" cy="28.5" r="1" fill="#6B4A2B"/>
<circle cx="23.5" cy="28.5" r="1" fill="#6B4A2B"/>
<!-- wrench white -->
<line x1="31" y1="32" x2="38" y2="25" stroke="#FBF7F0" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="38.5" cy="24" r="2" fill="#FBF7F0"/>
</svg>
<div class="icon-label">Сотрудник</div>
</div>
</div>
</div>
<!-- ===== STYLE 5: Геометрия ===== -->
<div class="card">
<div class="card-header">
<div class="badge">5</div>
<div class="style-name">Геометрия</div>
</div>
<div class="icons-row">
<!-- Manager geometric -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<!-- circle head -->
<circle cx="22" cy="11" r="6" fill="#6B4A2B"/>
<!-- trapezoid body -->
<polygon points="13,40 14,24 30,24 31,40" fill="#6B4A2B"/>
<!-- triangle tie -->
<polygon points="22,24 20,32 24,32" fill="#FBF7F0"/>
<!-- triangle tie bottom -->
<polygon points="20,32 24,32 22,38" fill="#FBF7F0"/>
</svg>
<div class="icon-label">Менеджер</div>
</div>
<!-- Client geometric -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<!-- triangle roof -->
<polygon points="7,22 22,5 37,22" fill="#6B4A2B"/>
<!-- rect walls -->
<rect x="10" y="22" width="24" height="17" fill="#C49A6C"/>
<!-- white square windows -->
<rect x="13" y="25" width="6" height="6" fill="#FBF7F0"/>
<rect x="25" y="25" width="6" height="6" fill="#FBF7F0"/>
<!-- white door rect -->
<rect x="19.5" y="31" width="5" height="8" fill="#FBF7F0"/>
</svg>
<div class="icon-label">Клиент</div>
</div>
<!-- Staff geometric -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<!-- semicircle helmet -->
<path d="M11 21 Q11 10 22 10 Q33 10 33 21 Z" fill="#6B4A2B"/>
<!-- brim rect -->
<rect x="10" y="21" width="24" height="3" fill="#5A3D24"/>
<!-- circle head -->
<circle cx="22" cy="30" r="5.5" fill="#C49A6C"/>
<!-- rect body -->
<rect x="14" y="35" width="16" height="9" fill="#C49A6C"/>
<!-- wrench dark -->
<rect x="31" y="24" width="3" height="10" fill="#6B4A2B" rx="1.5"/>
<rect x="31" y="24" width="8" height="3" fill="#6B4A2B" rx="1.5"/>
</svg>
<div class="icon-label">Сотрудник</div>
</div>
</div>
</div>
<!-- ===== STYLE 6: Дуотон ===== -->
<div class="card">
<div class="card-header">
<div class="badge">6</div>
<div class="style-name">Дуотон</div>
</div>
<div class="icons-row">
<!-- Manager duotone -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<!-- head dark -->
<circle cx="22" cy="13" r="6.5" fill="#6B4A2B"/>
<!-- torso light -->
<path d="M9 44 Q9 28 17 26.5 L22 26 L27 26.5 Q35 28 35 44 Z" fill="#C49A6C"/>
<!-- collar dark -->
<path d="M17 26.5 L22 33 L27 26.5 L25 26.5 L22 31 L19 26.5 Z" fill="#6B4A2B"/>
<!-- tie white -->
<polygon points="21,33 20.5,38 22,40 23.5,38 23,33" fill="#FBF7F0"/>
<!-- lapels dark accent -->
<path d="M17 26.5 L14 31 L16 31 L22 33 L28 31 L30 31 L27 26.5 L25 26.5 L22 31 L19 26.5 Z" fill="#6B4A2B"/>
</svg>
<div class="icon-label">Менеджер</div>
</div>
<!-- Client duotone -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<!-- roof dark -->
<polygon points="6,22 22,5 38,22" fill="#6B4A2B"/>
<!-- walls light -->
<rect x="9" y="22" width="26" height="17" fill="#C49A6C"/>
<!-- windows white -->
<rect x="12" y="26" width="7" height="5" fill="#FBF7F0"/>
<rect x="25" y="26" width="7" height="5" fill="#FBF7F0"/>
<!-- door dark -->
<path d="M19.5 39 L19.5 31 Q22 28.5 24.5 31 L24.5 39 Z" fill="#6B4A2B"/>
</svg>
<div class="icon-label">Клиент</div>
</div>
<!-- Staff duotone -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<!-- helmet dark -->
<path d="M10 23 L10 22 Q10 10 22 10 Q34 10 34 22 L34 23 Z" fill="#6B4A2B"/>
<!-- brim dark -->
<rect x="9" y="22" width="26" height="2.5" fill="#5A3D24"/>
<!-- head+neck light -->
<circle cx="22" cy="29" r="5.5" fill="#C49A6C"/>
<!-- torso light -->
<path d="M11 44 Q11 36 17 34 L22 33.5 L27 34 Q33 36 33 44 Z" fill="#C49A6C"/>
<!-- face dots white -->
<circle cx="20.5" cy="28.5" r="1.2" fill="#FBF7F0"/>
<circle cx="23.5" cy="28.5" r="1.2" fill="#FBF7F0"/>
<!-- wrench dark -->
<line x1="30" y1="31" x2="38" y2="23" stroke="#6B4A2B" stroke-width="3" stroke-linecap="round"/>
<circle cx="38" cy="22.5" r="2.5" fill="#6B4A2B"/>
<rect x="36" y="20" width="4" height="2" rx="1" fill="#C49A6C"/>
</svg>
<div class="icon-label">Сотрудник</div>
</div>
</div>
</div>
<!-- ===== STYLE 7: Уютный ===== -->
<div class="card">
<div class="card-header">
<div class="badge">7</div>
<div class="style-name">Уютный</div>
</div>
<div class="icons-row">
<!-- Manager cozy -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<!-- head fill light -->
<circle cx="22" cy="13" r="6.5" fill="#FBF7F0" stroke="#6B4A2B" stroke-width="1.8"/>
<!-- hair dark fill top -->
<path d="M15.5 11 Q16 7 22 7 Q28 7 28.5 11 Q26 9.5 22 9.5 Q18 9.5 15.5 11 Z" fill="#5A3D24"/>
<!-- torso with inner detail -->
<path d="M9 44 Q9 28 17 26.5 L22 26 L27 26.5 Q35 28 35 44" fill="#FBF7F0" stroke="#6B4A2B" stroke-width="1.8"/>
<!-- lapel lines -->
<line x1="19" y1="26.5" x2="15" y2="32" stroke="#6B4A2B" stroke-width="1.5"/>
<line x1="25" y1="26.5" x2="29" y2="32" stroke="#6B4A2B" stroke-width="1.5"/>
<!-- collar V -->
<line x1="19" y1="26.5" x2="22" y2="31" stroke="#6B4A2B" stroke-width="1.5"/>
<line x1="25" y1="26.5" x2="22" y2="31" stroke="#6B4A2B" stroke-width="1.5"/>
<!-- tie polygon -->
<polygon points="21,31 20.5,37 22,39 23.5,37 23,31" fill="#6B4A2B"/>
<!-- pocket line -->
<line x1="14" y1="36" x2="18" y2="36" stroke="#6B4A2B" stroke-width="1.2"/>
</svg>
<div class="icon-label">Менеджер</div>
</div>
<!-- Client cozy -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<!-- roof -->
<path d="M6,22 L22,5 L38,22 Z" fill="#FBF7F0" stroke="#6B4A2B" stroke-width="1.8" stroke-linejoin="round"/>
<!-- chimney rect -->
<rect x="26" y="9" width="4" height="8" fill="#6B4A2B"/>
<!-- walls -->
<rect x="9" y="21" width="26" height="18" fill="#FBF7F0" stroke="#6B4A2B" stroke-width="1.8"/>
<!-- window left with glass blue + cross lines -->
<rect x="12" y="25" width="7" height="6" rx="1" fill="#D6EFF5" stroke="#6B4A2B" stroke-width="1.4"/>
<line x1="15.5" y1="25" x2="15.5" y2="31" stroke="#6B4A2B" stroke-width="0.9"/>
<line x1="12" y1="28" x2="19" y2="28" stroke="#6B4A2B" stroke-width="0.9"/>
<!-- window right -->
<rect x="25" y="25" width="7" height="6" rx="1" fill="#D6EFF5" stroke="#6B4A2B" stroke-width="1.4"/>
<line x1="28.5" y1="25" x2="28.5" y2="31" stroke="#6B4A2B" stroke-width="0.9"/>
<line x1="25" y1="28" x2="32" y2="28" stroke="#6B4A2B" stroke-width="0.9"/>
<!-- paneled door with warm sand + inner line -->
<path d="M19.5 39 L19.5 31 Q22 28 24.5 31 L24.5 39 Z" fill="#EDE0C8" stroke="#6B4A2B" stroke-width="1.4"/>
<line x1="22" y1="31" x2="22" y2="39" stroke="#6B4A2B" stroke-width="0.9"/>
<!-- knob -->
<circle cx="23.8" cy="35" r="0.9" fill="#6B4A2B"/>
</svg>
<div class="icon-label">Клиент</div>
</div>
<!-- Staff cozy -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<!-- dome -->
<path d="M13 22 Q13 11 22 11 Q31 11 31 22 Z" fill="#FBF7F0" stroke="#6B4A2B" stroke-width="1.8"/>
<!-- brim rect fill dark walnut -->
<rect x="10" y="21" width="24" height="3" rx="1" fill="#5A3D24"/>
<!-- head circle -->
<circle cx="22" cy="28.5" r="5.5" fill="#FBF7F0" stroke="#6B4A2B" stroke-width="1.8"/>
<!-- eyes dots -->
<circle cx="20" cy="28" r="1" fill="#6B4A2B"/>
<circle cx="24" cy="28" r="1" fill="#6B4A2B"/>
<!-- smile -->
<path d="M20 30.5 Q22 32 24 30.5" fill="none" stroke="#6B4A2B" stroke-width="1"/>
<!-- torso -->
<path d="M12 44 Q12 36 18 34 L22 33.5 L26 34 Q32 36 32 44" fill="#FBF7F0" stroke="#6B4A2B" stroke-width="1.8" stroke-linecap="round"/>
<!-- wrench: handle + circle head -->
<line x1="31" y1="31" x2="38" y2="24" stroke="#6B4A2B" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="38.5" cy="23.5" r="2.8" fill="#FBF7F0" stroke="#6B4A2B" stroke-width="1.8"/>
<line x1="37" y1="22" x2="40" y2="25" stroke="#6B4A2B" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="icon-label">Сотрудник</div>
</div>
</div>
</div>
<!-- ===== STYLE 8: Стик ===== -->
<div class="card">
<div class="card-header">
<div class="badge">8</div>
<div class="style-name">Стик</div>
</div>
<div class="icons-row">
<!-- Manager stick -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<!-- head filled -->
<circle cx="22" cy="10" r="5" fill="#6B4A2B"/>
<!-- neck line -->
<line x1="22" y1="15" x2="22" y2="19" stroke="#6B4A2B" stroke-width="1.8" stroke-linecap="round"/>
<!-- shoulders line -->
<line x1="12" y1="22" x2="32" y2="22" stroke="#6B4A2B" stroke-width="1.8" stroke-linecap="round"/>
<!-- arms lines -->
<line x1="12" y1="22" x2="9" y2="30" stroke="#6B4A2B" stroke-width="1.5" stroke-linecap="round"/>
<line x1="32" y1="22" x2="35" y2="30" stroke="#6B4A2B" stroke-width="1.5" stroke-linecap="round"/>
<!-- torso line -->
<line x1="22" y1="19" x2="22" y2="32" stroke="#6B4A2B" stroke-width="1.8" stroke-linecap="round"/>
<!-- legs lines -->
<line x1="22" y1="32" x2="17" y2="42" stroke="#6B4A2B" stroke-width="1.5" stroke-linecap="round"/>
<line x1="22" y1="32" x2="27" y2="42" stroke="#6B4A2B" stroke-width="1.5" stroke-linecap="round"/>
<!-- tie dot -->
<circle cx="22" cy="24" r="1.5" fill="#6B4A2B"/>
<!-- tie line -->
<line x1="22" y1="24" x2="22" y2="30" stroke="#6B4A2B" stroke-width="2.5" stroke-linecap="round"/>
</svg>
<div class="icon-label">Менеджер</div>
</div>
<!-- Client stick -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<!-- roof lines -->
<polyline points="6,22 22,6 38,22" fill="none" stroke="#6B4A2B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<!-- walls rect outline -->
<rect x="9" y="22" width="26" height="16" fill="none" stroke="#6B4A2B" stroke-width="1.8" stroke-linejoin="round"/>
<!-- circle windows filled -->
<circle cx="15.5" cy="29" r="3.5" fill="#6B4A2B"/>
<circle cx="28.5" cy="29" r="3.5" fill="#6B4A2B"/>
<!-- rounded rect door -->
<rect x="19.5" y="31" width="5" height="7" rx="2.5" fill="#6B4A2B"/>
</svg>
<div class="icon-label">Клиент</div>
</div>
<!-- Staff stick -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<!-- dome arc line -->
<path d="M13 21 Q13 11 22 11 Q31 11 31 21" fill="none" stroke="#6B4A2B" stroke-width="2" stroke-linecap="round"/>
<!-- thick brim line -->
<line x1="10" y1="21" x2="34" y2="21" stroke="#6B4A2B" stroke-width="3" stroke-linecap="round"/>
<!-- filled circle head -->
<circle cx="22" cy="27" r="5" fill="#6B4A2B"/>
<!-- body lines -->
<line x1="22" y1="32" x2="22" y2="38" stroke="#6B4A2B" stroke-width="2" stroke-linecap="round"/>
<line x1="14" y1="34" x2="30" y2="34" stroke="#6B4A2B" stroke-width="1.8" stroke-linecap="round"/>
<line x1="22" y1="38" x2="17" y2="44" stroke="#6B4A2B" stroke-width="1.5" stroke-linecap="round"/>
<line x1="22" y1="38" x2="27" y2="44" stroke="#6B4A2B" stroke-width="1.5" stroke-linecap="round"/>
<!-- wrench arm with L -->
<line x1="30" y1="34" x2="38" y2="26" stroke="#6B4A2B" stroke-width="2" stroke-linecap="round"/>
<line x1="35" y1="26" x2="38" y2="29" stroke="#6B4A2B" stroke-width="2" stroke-linecap="round"/>
</svg>
<div class="icon-label">Сотрудник</div>
</div>
</div>
</div>
<!-- ===== STYLE 9: Ретро ===== -->
<div class="card">
<div class="card-header">
<div class="badge">9</div>
<div class="style-name">Ретро</div>
</div>
<div class="icons-row">
<!-- Manager retro -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<!-- ring frame -->
<circle cx="22" cy="22" r="20.5" stroke="#6B4A2B" stroke-width="1.5" fill="#FBF7F0"/>
<!-- head filled dark -->
<circle cx="22" cy="15" r="6" fill="#6B4A2B"/>
<!-- hair suggestion top lighter -->
<path d="M16.5 13 Q17 10 22 10 Q27 10 27.5 13 Q25 11.5 22 11.5 Q19 11.5 16.5 13 Z" fill="#5A3D24"/>
<!-- suit torso -->
<path d="M10 40 Q10 28 18 27 L22 26.5 L26 27 Q34 28 34 40 Z" fill="#6B4A2B"/>
<!-- lapels pale -->
<path d="M18 27 L22 33 L26 27 L24.5 27 L22 31 L19.5 27 Z" fill="#C49A6C"/>
<!-- tie accent -->
<polygon points="21.2,33 20.8,38 22,39.5 23.2,38 22.8,33" fill="#FBF7F0"/>
</svg>
<div class="icon-label">Менеджер</div>
</div>
<!-- Client retro -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<circle cx="22" cy="22" r="20.5" stroke="#6B4A2B" stroke-width="1.5" fill="#FBF7F0"/>
<!-- roof dark -->
<polygon points="8,22 22,7 36,22" fill="#6B4A2B"/>
<!-- walls medium -->
<rect x="10" y="22" width="24" height="15" fill="#C49A6C"/>
<!-- windows filled dark -->
<rect x="13" y="25.5" width="6" height="5" fill="#6B4A2B"/>
<rect x="25" y="25.5" width="6" height="5" fill="#6B4A2B"/>
<!-- door arch pale -->
<path d="M19.5 37 L19.5 30.5 Q22 28 24.5 30.5 L24.5 37 Z" fill="#EDE0C8"/>
</svg>
<div class="icon-label">Клиент</div>
</div>
<!-- Staff retro -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<circle cx="22" cy="22" r="20.5" stroke="#6B4A2B" stroke-width="1.5" fill="#FBF7F0"/>
<!-- helmet dark -->
<path d="M12 23 L12 21 Q12 11 22 11 Q32 11 32 21 L32 23 Z" fill="#6B4A2B"/>
<!-- brim dark line -->
<rect x="11" y="22" width="22" height="2.5" fill="#5A3D24"/>
<!-- head accent -->
<circle cx="22" cy="29" r="5" fill="#C49A6C"/>
<!-- torso -->
<path d="M12 42 Q12 35 18 33.5 L22 33 L26 33.5 Q32 35 32 42 Z" fill="#6B4A2B"/>
<!-- face eyes white -->
<circle cx="20.5" cy="28.5" r="1" fill="#FBF7F0"/>
<circle cx="23.5" cy="28.5" r="1" fill="#FBF7F0"/>
<!-- wrench small in hand -->
<line x1="30" y1="30" x2="36" y2="24" stroke="#6B4A2B" stroke-width="2.5" stroke-linecap="round"/>
<path d="M34 22 Q36 21 37 23 Q38 25 36 26 L34 24 Z" fill="#6B4A2B"/>
</svg>
<div class="icon-label">Сотрудник</div>
</div>
</div>
</div>
<!-- ===== STYLE 10: Маркер ===== -->
<div class="card">
<div class="card-header">
<div class="badge">10</div>
<div class="style-name">Маркер</div>
</div>
<div class="icons-row">
<!-- Manager marker -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<!-- head circle thick -->
<circle cx="22" cy="13" r="6" fill="none" stroke="#6B4A2B" stroke-width="4.5" stroke-linecap="round"/>
<!-- shoulder arc thick -->
<path d="M9 38 Q9 27 17 25.5 Q19.5 25 22 25 Q24.5 25 27 25.5 Q35 27 35 38" fill="none" stroke="#6B4A2B" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>
<!-- collar V brief thick -->
<path d="M19 25.5 L22 30 L25 25.5" fill="none" stroke="#6B4A2B" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<div class="icon-label">Менеджер</div>
</div>
<!-- Client marker -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<!-- roof path thick -->
<polyline points="7,22 22,7 37,22" fill="none" stroke="#6B4A2B" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>
<!-- walls rect thick -->
<rect x="10" y="22" width="24" height="15" fill="none" stroke="#6B4A2B" stroke-width="4" stroke-linejoin="round"/>
<!-- small window squares thick -->
<rect x="13" y="26" width="5" height="4" fill="none" stroke="#6B4A2B" stroke-width="3" stroke-linejoin="round"/>
<rect x="26" y="26" width="5" height="4" fill="none" stroke="#6B4A2B" stroke-width="3" stroke-linejoin="round"/>
<!-- door arch thick -->
<path d="M19.5 37 L19.5 31 Q22 28 24.5 31 L24.5 37" fill="none" stroke="#6B4A2B" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<div class="icon-label">Клиент</div>
</div>
<!-- Staff marker -->
<div class="icon-wrap">
<svg viewBox="0 0 44 44" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<!-- dome arc thick -->
<path d="M13 22 Q13 11 22 11 Q31 11 31 22" fill="none" stroke="#6B4A2B" stroke-width="4.5" stroke-linecap="round"/>
<!-- brim thick line -->
<line x1="9" y1="22" x2="35" y2="22" stroke="#6B4A2B" stroke-width="5" stroke-linecap="round"/>
<!-- head circle thick -->
<circle cx="22" cy="28.5" r="5.5" fill="none" stroke="#6B4A2B" stroke-width="4.5" stroke-linecap="round"/>
<!-- torso arc thick -->
<path d="M12 43 Q12 35 18 33.5 L22 33 L26 33.5 Q32 35 32 43" fill="none" stroke="#6B4A2B" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>
<!-- wrench L thick -->
<line x1="31" y1="32" x2="38" y2="25" stroke="#6B4A2B" stroke-width="4" stroke-linecap="round"/>
<line x1="35" y1="25" x2="38" y2="28" stroke="#6B4A2B" stroke-width="4" stroke-linecap="round"/>
</svg>
<div class="icon-label">Сотрудник</div>
</div>
</div>
</div>
</div><!-- end grid -->
</body>
</html>

407
miniapp/assets/act4.js Normal file
View File

@ -0,0 +1,407 @@
/* ============================================================
Акт 4 приёмка товара (экспедитор / сборщик)
#/assembly/:id/act4
============================================================ */
const Act4Screen = (function () {
"use strict";
function escHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function el(html) {
const t = document.createElement("template");
t.innerHTML = html.trim();
return t.content.firstChild;
}
async function _api(path, body = {}) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 15000);
try {
const res = await fetch(`${BACKEND_URL}/api/${path}`, {
method: "POST", signal: ctrl.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
initData: typeof Platform !== "undefined" ? Platform.initData : (window.tg?.initData || ""),
initDataUnsafe: typeof Platform !== "undefined" ? Platform.initDataUnsafe : null,
...body,
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (e) {
if (e.name === "AbortError") throw new Error("Сервер не отвечает");
throw e;
} finally { clearTimeout(t); }
}
// Состояние акта
let _state = {
act_num: "", act_date: "", supplier: "", notes: "",
items: [], // [{id, name, qty, condition, note}]
signed_by_name: "", signed_by_phone: "", signed_via: "",
};
let _data = {}; // данные с сервера
let _container = null;
let _assemblyId = "";
function _itemId() {
return "i" + Math.random().toString(36).slice(2, 8);
}
/* ── Главный mount ──────────────────────────────────────────── */
async function mount(container, assemblyId) {
_container = container;
_assemblyId = assemblyId;
container.innerHTML = "";
document.body.classList.remove("has-bottom-nav");
document.getElementById("bottom-nav")?.remove();
const h = el(`
<header class="podbor-header">
<button class="podbor-back">${(window.ICONS || {}).arrow_left || ""}</button>
<div class="podbor-title">Акт 4 · Приёмка товара</div>
<div style="width:36px"></div>
</header>
`);
h.querySelector(".podbor-back").addEventListener("click", () => { haptic && haptic("impact"); history.back(); });
container.appendChild(h);
const screen = el(`<div class="podbor-screen" style="padding-bottom:32px;"></div>`);
screen.innerHTML = `<div class="loader-inline"><div class="spinner"></div></div>`;
container.appendChild(screen);
try {
const d = await _api("act4_preview", { assembly_id: assemblyId });
if (d.error) { screen.innerHTML = `<div class="error" style="margin:16px;">${escHtml(d.error)}</div>`; return; }
_data = d;
_state = {
act_num: d.act_num || `${assemblyId}-4`,
act_date: d.act_date || new Date().toISOString().slice(0, 10),
supplier: d.supplier || "",
notes: d.notes || "",
items: (d.items || []).map(it => ({ ...it, id: it.id || _itemId() })),
signed_by_name: d.signed_by_name || "",
signed_by_phone: d.signed_by_phone || "",
signed_via: d.signed_via || "",
};
_render(screen, d.is_signed);
} catch (e) {
screen.innerHTML = `<div class="error" style="margin:16px;">Ошибка: ${escHtml(e.message)}</div>`;
}
}
/* ── Рендер ─────────────────────────────────────────────────── */
function _render(screen, isSigned) {
screen.innerHTML = "";
// Баннер если подписан
if (isSigned) {
screen.appendChild(el(`
<div style="margin:12px 16px;padding:12px 14px;background:#27AE6015;
border:1px solid #27AE60;border-radius:12px;">
<div style="font-size:13px;font-weight:700;color:#27AE60;"> Акт подписан</div>
<div style="font-size:12px;color:var(--muted);margin-top:2px;">
${escHtml(_state.signed_by_name)}
${_state.signed_at ? " · " + escHtml(new Date(_state.signed_at).toLocaleDateString("ru-RU")) : ""}
</div>
</div>
`));
}
// Данные клиента
screen.appendChild(el(`
<div style="margin:12px 16px 0;padding:12px;background:var(--surface);
border:1px solid var(--border);border-radius:12px;">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;
letter-spacing:.06em;color:var(--muted);margin-bottom:8px;">Клиент</div>
<div style="font-size:14px;font-weight:600;color:var(--ink);">${escHtml(_data.client_name || "—")}</div>
${_data.address ? `<div style="font-size:12px;color:var(--muted);margin-top:2px;">${escHtml(_data.address)}</div>` : ""}
</div>
`));
// Реквизиты акта
const reqs = el(`
<div style="margin:12px 16px 0;padding:0 12px;background:var(--surface);
border:1px solid var(--border);border-radius:12px;">
<div style="display:flex;justify-content:space-between;align-items:center;
padding:10px 0;border-bottom:1px solid var(--border);">
<div style="font-size:12px;color:var(--muted);">Номер акта</div>
<input id="a4-num" value="${escHtml(_state.act_num)}" ${isSigned ? "disabled" : ""}
style="border:none;background:transparent;text-align:right;font-size:13px;
font-weight:500;color:var(--ink);width:140px;padding:0;">
</div>
<div style="display:flex;justify-content:space-between;align-items:center;
padding:10px 0;border-bottom:1px solid var(--border);">
<div style="font-size:12px;color:var(--muted);">Дата</div>
<input id="a4-date" type="date" value="${escHtml(_state.act_date)}" ${isSigned ? "disabled" : ""}
style="border:none;background:transparent;text-align:right;font-size:13px;
font-weight:500;color:var(--ink);">
</div>
<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 0;">
<div style="font-size:12px;color:var(--muted);">Поставщик</div>
<input id="a4-supplier" value="${escHtml(_state.supplier)}" placeholder="Название магазина/склада"
${isSigned ? "disabled" : ""}
style="border:none;background:transparent;text-align:right;font-size:13px;
color:var(--ink);width:180px;padding:0;">
</div>
</div>
`);
screen.appendChild(reqs);
if (!isSigned) {
reqs.querySelector("#a4-num").addEventListener("input", e => { _state.act_num = e.target.value; });
reqs.querySelector("#a4-date").addEventListener("change", e => { _state.act_date = e.target.value; });
reqs.querySelector("#a4-supplier").addEventListener("input", e => { _state.supplier = e.target.value; });
}
// === Список позиций ===
const itemsHead = el(`
<div style="display:flex;justify-content:space-between;align-items:center;
margin:16px 16px 0;padding-bottom:6px;border-bottom:1px solid var(--border);">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;
letter-spacing:.06em;color:var(--muted);">Позиции</div>
${!isSigned ? `<button id="a4-add-item" style="background:none;border:none;cursor:pointer;
font-size:13px;font-weight:600;color:var(--accent);padding:4px 8px;">+ Добавить</button>` : ""}
</div>
`);
screen.appendChild(itemsHead);
const itemsList = el(`<div id="a4-items-list" style="margin:0 16px;"></div>`);
screen.appendChild(itemsList);
_renderItemsList(itemsList, isSigned);
if (!isSigned) {
itemsHead.querySelector("#a4-add-item")?.addEventListener("click", () => {
haptic && haptic("impact");
_state.items.push({ id: _itemId(), name: "", qty: 1, condition: "ok", note: "" });
_renderItemsList(itemsList, false);
});
}
// Итог
const totalEl = el(`<div id="a4-total" style="margin:8px 16px 0;"></div>`);
screen.appendChild(totalEl);
_renderTotal(totalEl);
// Примечание
const noteWrap = el(`
<div style="margin:12px 16px 0;">
<div style="font-size:12px;color:var(--muted);margin-bottom:4px;">Примечания</div>
<textarea id="a4-notes" rows="2" ${isSigned ? "disabled" : ""}
placeholder="Общие замечания по доставке…"
style="width:100%;box-sizing:border-box;padding:10px;
border:1px solid var(--border);border-radius:10px;
background:var(--surface);color:var(--ink);font-size:13px;
resize:none;">${escHtml(_state.notes)}</textarea>
</div>
`);
screen.appendChild(noteWrap);
if (!isSigned) {
noteWrap.querySelector("#a4-notes").addEventListener("input", e => { _state.notes = e.target.value; });
}
// Блок подписи
if (!isSigned) {
const signWrap = el(`
<div style="margin:16px 16px 0;padding:14px;background:var(--surface);
border:1px solid var(--border);border-radius:12px;">
<div style="font-size:12px;color:var(--muted);margin-bottom:10px;font-weight:600;">
Подпись принявшего
</div>
<div style="margin-bottom:8px;">
<input id="a4-sign-name" placeholder="ФИО принявшего"
value="${escHtml(_state.signed_by_name)}"
style="width:100%;box-sizing:border-box;padding:10px;
border:1px solid var(--border);border-radius:8px;
background:var(--surface);color:var(--ink);font-size:14px;">
</div>
<div>
<input id="a4-sign-phone" placeholder="Телефон (необязательно)"
type="tel" value="${escHtml(_state.signed_by_phone)}"
style="width:100%;box-sizing:border-box;padding:10px;
border:1px solid var(--border);border-radius:8px;
background:var(--surface);color:var(--ink);font-size:14px;">
</div>
</div>
`);
screen.appendChild(signWrap);
signWrap.querySelector("#a4-sign-name").addEventListener("input", e => { _state.signed_by_name = e.target.value; });
signWrap.querySelector("#a4-sign-phone").addEventListener("input", e => { _state.signed_by_phone = e.target.value; });
// Кнопки
const btns = el(`
<div style="margin:12px 16px 0;display:flex;gap:10px;">
<button id="a4-save-btn" class="btn-secondary" style="flex:1;padding:12px;font-size:14px;">
💾 Сохранить
</button>
<button id="a4-sign-btn" class="btn-primary" style="flex:2;padding:12px;font-size:14px;">
Подтвердить приёмку
</button>
</div>
`);
screen.appendChild(btns);
const statusEl = el(`<div id="a4-status" style="margin:8px 16px;font-size:12px;text-align:center;color:var(--muted);"></div>`);
screen.appendChild(statusEl);
btns.querySelector("#a4-save-btn").addEventListener("click", () => _doSave(false, statusEl));
btns.querySelector("#a4-sign-btn").addEventListener("click", () => _doSave(true, statusEl));
}
}
/* ── Список позиций ─────────────────────────────────────────── */
function _renderItemsList(container, isSigned) {
container.innerHTML = "";
if (!_state.items.length) {
container.appendChild(el(`
<div style="padding:16px 0;text-align:center;color:var(--muted);font-size:13px;">
${isSigned ? "Позиции не добавлены" : "Нажмите «+ Добавить» чтобы внести позиции"}
</div>
`));
return;
}
_state.items.forEach((item, idx) => {
const row = el(`
<div data-item-id="${item.id}"
style="padding:10px 0;border-bottom:1px solid var(--border);">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<input class="it-name" placeholder="Наименование (шкаф, столешница…)"
value="${escHtml(item.name)}" ${isSigned ? "disabled" : ""}
style="flex:1;border:1px solid var(--border);border-radius:8px;
padding:8px 10px;background:var(--surface);color:var(--ink);font-size:13px;">
${!isSigned ? `<button class="it-del" style="background:none;border:none;cursor:pointer;
font-size:18px;color:var(--muted);padding:4px;">✕</button>` : ""}
</div>
<div style="display:flex;gap:8px;align-items:center;">
<div style="flex:1;">
<div style="font-size:11px;color:var(--muted);margin-bottom:3px;">Кол-во</div>
<input class="it-qty" type="number" min="1" value="${escHtml(String(item.qty || 1))}"
${isSigned ? "disabled" : ""}
style="width:60px;border:1px solid var(--border);border-radius:8px;
padding:7px 8px;background:var(--surface);color:var(--ink);font-size:13px;">
</div>
<div style="flex:2;">
<div style="font-size:11px;color:var(--muted);margin-bottom:3px;">Состояние</div>
<div class="it-cond-wrap" style="display:flex;gap:6px;">
<button class="cond-btn ${item.condition !== "damaged" ? "cond-active-ok" : ""}"
data-cond="ok" ${isSigned ? "disabled" : ""}
style="flex:1;padding:7px;border-radius:8px;font-size:12px;font-weight:600;
border:1px solid ${item.condition !== "damaged" ? "#27AE60" : "var(--border)"};
background:${item.condition !== "damaged" ? "#27AE6015" : "var(--surface)"};
color:${item.condition !== "damaged" ? "#27AE60" : "var(--muted)"};cursor:pointer;">
Цело
</button>
<button class="cond-btn ${item.condition === "damaged" ? "cond-active-dmg" : ""}"
data-cond="damaged" ${isSigned ? "disabled" : ""}
style="flex:1;padding:7px;border-radius:8px;font-size:12px;font-weight:600;
border:1px solid ${item.condition === "damaged" ? "#E74C3C" : "var(--border)"};
background:${item.condition === "damaged" ? "#E74C3C15" : "var(--surface)"};
color:${item.condition === "damaged" ? "#E74C3C" : "var(--muted)"};cursor:pointer;">
Повреждено
</button>
</div>
</div>
</div>
${item.condition === "damaged" && !isSigned ? `
<div style="margin-top:6px;">
<input class="it-note" placeholder="Описание повреждения…"
value="${escHtml(item.note || "")}"
style="width:100%;box-sizing:border-box;border:1px solid #E74C3C;border-radius:8px;
padding:7px 10px;background:var(--surface);color:var(--ink);font-size:12px;">
</div>` : (item.note && isSigned ? `<div style="font-size:12px;color:#E74C3C;margin-top:4px;">${escHtml(item.note)}</div>` : "")}
</div>
`);
if (!isSigned) {
row.querySelector(".it-name").addEventListener("input", e => {
_state.items[idx].name = e.target.value;
});
row.querySelector(".it-qty").addEventListener("input", e => {
_state.items[idx].qty = parseInt(e.target.value) || 1;
_renderTotal(document.getElementById("a4-total"));
});
row.querySelector(".it-del").addEventListener("click", () => {
haptic && haptic("impact");
_state.items.splice(idx, 1);
_renderItemsList(container, false);
_renderTotal(document.getElementById("a4-total"));
});
row.querySelectorAll(".cond-btn").forEach(btn => {
btn.addEventListener("click", () => {
haptic && haptic("selection");
_state.items[idx].condition = btn.dataset.cond;
_renderItemsList(container, false);
_renderTotal(document.getElementById("a4-total"));
});
});
row.querySelector(".it-note")?.addEventListener("input", e => {
_state.items[idx].note = e.target.value;
});
}
container.appendChild(row);
});
}
function _renderTotal(container) {
if (!container) return;
const total = _state.items.reduce((s, it) => s + (parseInt(it.qty) || 1), 0);
const damaged = _state.items.filter(it => it.condition === "damaged")
.reduce((s, it) => s + (parseInt(it.qty) || 1), 0);
container.innerHTML = damaged > 0
? `<div style="padding:10px 0;font-size:13px;color:#E74C3C;font-weight:600;">
Итого: ${total} позиций · <span style="color:#E74C3C;"> Повреждений: ${damaged}</span>
</div>`
: `<div style="padding:10px 0;font-size:13px;color:var(--muted);">
Итого: ${total} позиций · Без повреждений
</div>`;
}
/* ── Сохранение / подпись ───────────────────────────────────── */
async function _doSave(withSign, statusEl) {
haptic && haptic("impact");
if (withSign && !_state.signed_by_name.trim()) {
if (statusEl) { statusEl.style.color = "#E74C3C"; statusEl.textContent = "Укажите ФИО принявшего"; }
return;
}
if (statusEl) { statusEl.style.color = "var(--muted)"; statusEl.textContent = "Сохраняем…"; }
const payload = {
assembly_id: _assemblyId,
act_num: _state.act_num,
act_date: _state.act_date,
supplier: _state.supplier,
items: _state.items,
notes: _state.notes,
};
if (withSign) {
payload.signed_by_name = _state.signed_by_name;
payload.signed_by_phone = _state.signed_by_phone;
payload.signed_via = "manual";
}
try {
const res = await _api("act4_save", payload);
if (res.error) {
if (statusEl) { statusEl.style.color = "#E74C3C"; statusEl.textContent = "Ошибка: " + res.error; }
return;
}
if (withSign) {
// Перезагружаем экран — покажет баннер «Подписан»
mount(_container, _assemblyId);
} else {
if (statusEl) { statusEl.style.color = "#27AE60"; statusEl.textContent = "✅ Сохранено"; }
setTimeout(() => { if (statusEl) statusEl.textContent = ""; }, 3000);
}
} catch (e) {
if (statusEl) { statusEl.style.color = "#E74C3C"; statusEl.textContent = "Ошибка: " + e.message; }
}
}
return { mount };
})();

View File

@ -1,4 +1,4 @@
// ЗОВ MiniApp — главный скрипт. v20260518n
// ЗОВ MiniApp — главный скрипт. v20260519e
// На входе: подписанный initData от Telegram.
// Ходим на backend → получаем профиль (роль, статус) → рендерим меню.
// tg и Platform определены в platform.js (загружается первым).
@ -196,6 +196,9 @@ async function renderManagerHome(me) {
{ icon: "ruler", title: "Заказать замер", subtitle: "Назначить замерщика", href: "#/request" },
{ icon: "wrench", title: "Ставки сборки", subtitle: "% клиент / сборщик", href: "#/admin/rates" },
{ icon: "folder", title: "Аналитика", subtitle: "Занятость сборщиков", href: "#/admin/assembler-analytics" },
{ icon: "user", title: "Команда", subtitle: "Нагрузка + статусы", href: "#/admin/staff" },
{ icon: "wallet", title: "Финансы", subtitle: "Выручка · маржа · выплаты", href: "#/admin/finance" },
{ icon: "star", title: "Мои оценки", subtitle: "Рейтинг · отзывы", href: "#/feedback/my" },
];
app.appendChild(el(`<div class="section-head"><span class="label">Быстрые действия</span></div>`));
const grid = el(`<div class="quick-grid"></div>`);
@ -220,6 +223,10 @@ async function renderManagerHome(me) {
const projectsContainer = el(`<div id="projectsContainer"></div>`);
app.appendChild(projectsContainer);
// Сборки в работе (под активными проектами)
const assembliesContainer = el(`<div id="assembliesContainer"></div>`);
app.appendChild(assembliesContainer);
// Контейнер для отгрузок с завода (под активными проектами)
const shipmentsContainer = el(`<div id="shipmentsContainer"></div>`);
app.appendChild(shipmentsContainer);
@ -249,14 +256,16 @@ async function renderManagerHome(me) {
renderManagerToday(todayContainer, data.measurements || [], firstName, greetingEl);
renderManagerProjects(projectsContainer, data.measurements || []);
// Складские данные — не критичны; грузим после, ошибка не ломает дашборд
// Складские данные + сборки — не критичны; ошибка не ломает дашборд
const authBodyStr = JSON.stringify(authBody);
Promise.all([
fetch(`${BACKEND_URL}/api/shipments`, { method: "POST", body: authBodyStr }).then(r => r.json()).catch(() => ({})),
fetch(`${BACKEND_URL}/api/arrivals`, { method: "POST", body: authBodyStr }).then(r => r.json()).catch(() => ({})),
]).then(([shipmentsData, arrivalsData]) => {
fetch(`${BACKEND_URL}/api/shipments`, { method: "POST", body: authBodyStr }).then(r => r.json()).catch(() => ({})),
fetch(`${BACKEND_URL}/api/arrivals`, { method: "POST", body: authBodyStr }).then(r => r.json()).catch(() => ({})),
fetch(`${BACKEND_URL}/api/assembly_list`, { method: "POST", body: authBodyStr }).then(r => r.json()).catch(() => ({})),
]).then(([shipmentsData, arrivalsData, assemblyData]) => {
renderManagerShipments(shipmentsContainer, shipmentsData.shipments || [], "📦 Отгрузки с завода");
renderManagerShipments(arrivalsContainer, arrivalsData.shipments || [], "📥 Поступление в СПб");
renderManagerAssemblies(assembliesContainer, assemblyData.assemblies || []);
}).catch(() => { /* тихо — дашборд уже отрисован */ });
} catch (e) {
todayContainer.innerHTML = `<div class="error">Не удалось загрузить данные: ${escHtml(e.message)}</div>`;
@ -541,6 +550,52 @@ function renderManagerProjects(container, measurements) {
container.appendChild(list);
}
/* ----------------- Менеджер: секция сборок в работе ----------------- */
function renderManagerAssemblies(container, assemblies) {
container.innerHTML = "";
const ASSEMBLY_STATUS = {
created: { icon: "🆕", label: "Создана", cls: "waiting" },
scheduled: { icon: "📅", label: "Запланирована", cls: "active" },
in_progress: { icon: "🔨", label: "В процессе", cls: "active" },
done: { icon: "✅", label: "Завершена", cls: "done" },
cancelled: { icon: "❌", label: "Отменена", cls: "cancel" },
};
// Показываем только активные (не завершённые и не отменённые)
const active = (assemblies || []).filter(a => a.status !== "done" && a.status !== "cancelled");
if (!active.length) return;
container.appendChild(el(`
<div class="section-head" style="margin-top:24px;">
<span class="label">🔨 Сборки в работе <span class="count">· ${active.length}</span></span>
</div>
`));
for (const a of active) {
const sl = ASSEMBLY_STATUS[a.status] || { icon: "🔧", label: a.status, cls: "waiting" };
const dateLabel = a.scheduled_at
? new Date(a.scheduled_at).toLocaleDateString("ru-RU", { day: "numeric", month: "short" })
: "—";
const card = el(`
<article class="project-card">
<div class="project-head">
<div class="project-title">${escHtml(a.client_name || "Без имени")}</div>
<span class="project-pill ${sl.cls}">${sl.icon} ${sl.label}</span>
</div>
<div class="project-address">${escHtml(a.address || "адрес не указан")}</div>
<div class="project-foot">
<span class="stage">${escHtml(a.scope_of_work || "—")}</span>
<span>${dateLabel}</span>
</div>
</article>
`);
card.addEventListener("click", () => {
haptic("impact");
location.hash = `#/assembly/${a.id}`;
});
container.appendChild(card);
}
}
/* ----------------- Менеджер: секция отгрузок / поступлений на склад ----------------- */
function renderManagerShipments(container, groups, label = "📦 Отгрузки") {
container.innerHTML = "";
@ -661,11 +716,17 @@ function renderClient(me) {
const sections = [
{
label: "Подобрать кухню",
label: "Мой заказ",
items: [
{ icon: "ruler", color: "blue", label: "Замер кухни", href: "#/c/measure" },
{ icon: "wrench", color: "green", label: "Подобрать технику", href: "#/c/proposal" },
{ icon: "wallet", color: "gold", label: "Проверить договор", href: "#/c/contract" },
{ icon: "clipboard", color: "green", label: "Статус сборки", href: "#/c/orders", sub: "Этапы и таймлайн" },
{ icon: "ruler", color: "blue", label: "Замер кухни", href: "#/c/measure" },
{ icon: "wallet", color: "gold", label: "Проверить договор", href: "#/c/contract" },
],
},
{
label: "Подбор техники",
items: [
{ icon: "wrench", color: "green", label: "Подобрать встройку", href: "#/c/proposal" },
],
},
{
@ -826,10 +887,17 @@ async function renderStaff(me) {
const caps = me.capabilities || {};
const labels = [];
if (caps.measurer) labels.push("замерщик");
if (caps.measurer) labels.push("замерщик");
if (caps.assembler) labels.push("сборщик");
if (caps.expeditor) labels.push("экспедитор");
const subtitle = labels.length ? labels.join(" · ") : "сотрудник";
// Экспедитор — отдельный экран
if (caps.expeditor && !caps.measurer && !caps.assembler) {
_renderExpeditorScreen(app, me);
return;
}
app.appendChild(el(`
<div class="staff-head">
<div class="staff-avatar">${me.user?.avatar_initial || "?"}</div>
@ -928,6 +996,22 @@ async function renderStaff(me) {
app.appendChild(clientsBtn);
}
// Статистика замерщика
if (caps.measurer) {
const measStatsBtn = el(`
<div class="podbor-cta-row" style="margin-top:8px;">
<button class="btn-primary" style="gap:8px;">
📊 Мои замеры
</button>
</div>
`);
measStatsBtn.querySelector("button").addEventListener("click", () => {
haptic && haptic("impact");
location.hash = "#/master/measurer-stats";
});
app.appendChild(measStatsBtn);
}
// Шпаргалки + заработки сборщика
if (caps.assembler) {
const earningsBtn = el(`
@ -956,6 +1040,88 @@ async function renderStaff(me) {
});
app.appendChild(toolsBtn);
}
// Мои оценки — для всех сотрудников
const myRatingsBtn = el(`
<div class="podbor-cta-row" style="margin-top:8px;">
<button class="btn-secondary" style="gap:8px;">
Мои оценки
</button>
</div>
`);
myRatingsBtn.querySelector("button").addEventListener("click", () => {
haptic && haptic("impact");
location.hash = "#/feedback/my";
});
app.appendChild(myRatingsBtn);
}
/* ── Экран экспедитора ─────────────────────────────────────────── */
function _renderExpeditorScreen(container, me) {
const u = me.user || {};
container.appendChild(el(`
<div class="staff-head">
<div class="staff-avatar">${u.avatar_initial || "Э"}</div>
<div class="staff-name">${escHtml(u.full_name || "Экспедитор")}</div>
<div class="staff-role-label">экспедитор</div>
</div>
`));
container.appendChild(el(`
<div style="padding:12px 16px;">
<div style="font-size:13px;color:var(--muted);margin-bottom:12px;">
Выберите сборку для оформления приёмки товара
</div>
</div>
`));
// Список активных сборок
const asmWrap = el(`<div id="exp-asm-list" style="padding:0 16px;"></div>`);
container.appendChild(asmWrap);
_loadExpeditorAssemblies(asmWrap);
}
async function _loadExpeditorAssemblies(container) {
container.innerHTML = `<div class="loader-inline"><div class="spinner"></div></div>`;
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 15000);
const res = await fetch(`${BACKEND_URL}/api/assembly_list`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ initData: Platform.initData, initDataUnsafe: Platform.initDataUnsafe }),
signal: ctrl,
});
clearTimeout(t);
const data = await res.json();
if (data.error) { container.innerHTML = `<div class="error">${escHtml(data.error)}</div>`; return; }
const assemblies = (data.assemblies || []).filter(a => !["done","cancelled"].includes(a.status));
if (!assemblies.length) {
container.innerHTML = `<div style="padding:24px 0;text-align:center;color:var(--muted);font-size:13px;">
Нет активных сборок</div>`;
return;
}
container.innerHTML = "";
assemblies.forEach(a => {
const card = el(`
<div style="padding:12px 14px;background:var(--surface);border:1px solid var(--border);
border-radius:12px;margin-bottom:8px;cursor:pointer;">
<div style="font-size:14px;font-weight:600;color:var(--ink);">${escHtml(a.client_name || "Клиент")}</div>
<div style="font-size:12px;color:var(--muted);margin-top:2px;">${escHtml(a.address || "")}</div>
<div style="margin-top:8px;">
<span style="font-size:12px;font-weight:600;color:var(--accent);">📦 Оформить приёмку</span>
</div>
</div>
`);
card.addEventListener("click", () => {
haptic && haptic("impact");
location.hash = `#/expeditor/act/${a.id}`;
});
container.appendChild(card);
});
} catch (e) {
container.innerHTML = `<div class="error">${escHtml(e.message)}</div>`;
}
}
async function renderStaffAssemblies(container) {
@ -1363,6 +1529,49 @@ async function renderInboxDetail(measurementId) {
app.appendChild(dateSection);
dateSection.querySelector("#saveSched").addEventListener("click", () => saveScheduleDate(measurementId, dateSection));
}
// === Кнопка «Выставить счёт» (замерщик) ===
if (m.viewer_is_measurer) {
const invoiceWrap = el('<div style="margin:16px 16px 0;"></div>');
const hasFee = parseFloat(m.measurement_fee||0) > 0;
invoiceWrap.appendChild(el(
'<button class="btn-primary" id="invoiceBtn" style="width:100%;font-size:15px;padding:13px 20px;' +
(hasFee ? 'background:var(--surface);color:var(--accent);border:1.5px solid var(--accent);' : '') + '">' +
(hasFee ? '💳 Счёт выставлен: ' + Math.round(m.measurement_fee).toLocaleString("ru-RU") + ' ₽ — изменить' : '💳 Выставить счёт') +
'</button>'
));
invoiceWrap.querySelector('#invoiceBtn').addEventListener('click', () => {
haptic && haptic('impact');
location.hash = '#/master/invoice/' + measurementId;
});
app.appendChild(invoiceWrap);
}
// === Обратная связь — замерщик оценивает менеджера ===
if (m.status === "completed" && m.viewer_is_measurer && !m.measurer_feedback_at
&& typeof FeedbackModule !== "undefined") {
const fbWrap = el(`<div style="margin:16px;"></div>`);
app.appendChild(fbWrap);
FeedbackModule.mountMeasurerFeedback(fbWrap, {
managerName: m.manager_name || "",
managerTgId: m.manager_tg_id || "",
measurementId: m.id,
onSubmit: () => renderInboxDetail(measurementId),
});
}
// === Обратная связь — менеджер оценивает замерщика ===
if (m.status === "completed" && m.viewer_is_manager && !m.manager_feedback_at
&& typeof FeedbackModule !== "undefined") {
const fbWrap2 = el(`<div style="margin:16px;"></div>`);
app.appendChild(fbWrap2);
FeedbackModule.mountManagerFeedback(fbWrap2, {
measurerName: m.measurer_name || "",
measurerTgId: m.measurer_tg_id || "",
measurementId: m.id,
onSubmit: () => renderInboxDetail(measurementId),
});
}
}
async function saveScheduleDate(measurementId, section) {
@ -1726,6 +1935,12 @@ function routeByHash() {
else init();
} else if (location.hash.startsWith("#/assembly")) {
Assembly.mount(app);
} else if (location.hash === "#/admin/staff") {
if (typeof StaffRoster !== "undefined") StaffRoster.mount(app);
else init();
} else if (location.hash === "#/admin/finance") {
if (typeof FinanceSummary !== "undefined") FinanceSummary.mount(app);
else init();
} else if (location.hash === "#/admin/assembler-analytics") {
if (typeof AssemblerAnalytics !== "undefined") AssemblerAnalytics.mount(app);
else init();
@ -1742,6 +1957,24 @@ function routeByHash() {
const asmId = location.hash.split("/")[2];
if (typeof Contracts !== "undefined") Contracts.mount(app, asmId);
else init();
} else if (location.hash === "#/expeditor") {
if (typeof ExpeditorDashboard !== "undefined") ExpeditorDashboard.mount(app);
else init();
} else if (location.hash.startsWith("#/expeditor/act/")) {
const asmId = location.hash.replace("#/expeditor/act/", "").split("?")[0];
if (typeof ExpeditorDashboard !== "undefined") ExpeditorDashboard.mountAct(app, asmId);
else init();
} else if (location.hash.startsWith("#/assembly/") && location.hash.endsWith("/act4")) {
const asmId = location.hash.split("/")[2];
if (typeof Act4Screen !== "undefined") Act4Screen.mount(app, asmId);
else init();
} else if (location.hash === "#/master/measurer-stats") {
if (typeof MeasurerDashboard !== "undefined") MeasurerDashboard.mount(app);
else init();
} else if (location.hash.startsWith("#/master/invoice/")) {
const mId = location.hash.replace("#/master/invoice/", "").split("?")[0];
if (typeof InvoiceScreen !== "undefined") InvoiceScreen.mount(app, mId);
else init();
} else if (location.hash.startsWith("#/master/tools")) {
if (typeof MasterTools !== "undefined") {
const h = location.hash;
@ -1782,6 +2015,13 @@ function routeByHash() {
} else if (location.hash === "#/c/orders") {
if (typeof OrdersScreen !== "undefined") OrdersScreen.mount(app);
else init();
} else if (location.hash.startsWith("#/c/assembly/") && location.hash.endsWith("/timeline")) {
const parts = location.hash.split("/");
// #/c/assembly/ID/timeline → parts = ["#", "c", "assembly", "ID", "timeline"]
const asmIdRaw = parts[parts.length - 2] || "";
const asmId = decodeURIComponent(asmIdRaw);
if (typeof ClientTimeline !== "undefined") ClientTimeline.mount(app, asmId);
else init();
} else if (location.hash.startsWith("#/c/assembly/")) {
const assemblyId = decodeURIComponent(location.hash.replace("#/c/assembly/", ""));
if (typeof AssemblyDetailScreen !== "undefined") AssemblyDetailScreen.mount(app, assemblyId);
@ -1789,6 +2029,9 @@ function routeByHash() {
} else if (location.hash === "#/c/selfmeasure") {
if (typeof SelfMeasureScreen !== "undefined") SelfMeasureScreen.mount(app);
else init();
} else if (location.hash === "#/feedback/my") {
if (typeof FeedbackModule !== "undefined") FeedbackModule.mountMyScreen(app);
else init();
} else {
// Главный экран по роли
const me = window.__zovMe;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,228 @@
/* ============================================================
Таймлайн заказа клиента #/c/assembly/:id/timeline
Доступен клиенту, менеджеру, назначенному сборщику.
============================================================ */
const ClientTimeline = (function () {
function escHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function fmtDate(iso) {
if (!iso) return null;
try {
return new Date(iso).toLocaleDateString("ru-RU", {
day: "numeric", month: "long",
hour: "2-digit", minute: "2-digit",
});
} catch { return iso.slice(0, 16).replace("T", " "); }
}
async function _api(path, body = {}) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 15000);
try {
const res = await fetch(`${BACKEND_URL}/api/${path}`, {
method: "POST", signal: ctrl.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null,
...body,
}),
});
if (!res.ok) throw new Error(`Ошибка сервера (${res.status})`);
return await res.json();
} catch (e) {
if (e.name === "AbortError") throw new Error("Сервер не отвечает");
throw e;
} finally { clearTimeout(t); }
}
const STATUS_COLORS = {
created: "#8e8e8e",
scheduled: "#2980B9",
in_progress: "#F39C12",
done: "#27AE60",
cancelled: "#C0392B",
};
const STATUS_LABELS = {
created: "Создана",
scheduled: "Запланирована",
in_progress: "В процессе",
done: "Завершена",
cancelled: "Отменена",
};
function mount(container, assemblyId) {
container.innerHTML = "";
document.body.classList.remove("has-bottom-nav");
const oldNav = document.getElementById("bottom-nav");
if (oldNav) oldNav.remove();
const h = document.createElement("header");
h.className = "podbor-header";
h.innerHTML = `
<button class="podbor-back">${(window.ICONS || {}).arrow_left || ""}</button>
<div class="podbor-title">Мой заказ</div>
<div style="width:36px"></div>
`;
h.querySelector(".podbor-back").addEventListener("click", () => {
haptic && haptic("impact");
history.back();
});
container.appendChild(h);
const screen = document.createElement("div");
screen.className = "podbor-screen";
screen.style.cssText = "padding:0 0 48px;";
screen.innerHTML = `<div class="loader-inline"><div class="spinner"></div></div>`;
container.appendChild(screen);
_api("client_order_timeline", { assembly_id: assemblyId })
.then(data => {
if (data.error) {
screen.innerHTML = `<div class="error" style="margin:16px;">${escHtml(data.error)}</div>`;
return;
}
screen.innerHTML = "";
// Шапка — название + статус
const statusColor = STATUS_COLORS[data.status] || "#8e8e8e";
const statusText = STATUS_LABELS[data.status] || data.status;
const titleEl = document.createElement("div");
titleEl.style.cssText = "padding:16px 16px 12px;border-bottom:1px solid var(--border);";
titleEl.innerHTML = `
<div style="font-size:17px;font-weight:700;color:var(--ink);line-height:1.2;">
${escHtml(data.client_name || "Заказ")}
</div>
${data.address ? `
<div style="font-size:13px;color:var(--muted);margin-top:4px;">
📍 ${escHtml(data.address)}
</div>` : ""}
<div style="display:inline-block;margin-top:8px;
font-size:12px;font-weight:600;padding:3px 10px;
border-radius:10px;background:${statusColor}20;color:${statusColor};">
${escHtml(statusText)}
</div>
`;
screen.appendChild(titleEl);
// Подсказка прогресса
const milestones = data.milestones || [];
const doneCount = milestones.filter(m => m.done).length;
const total = milestones.length;
const pct = total ? Math.round((doneCount / total) * 100) : 0;
const progressEl = document.createElement("div");
progressEl.style.cssText = "padding:12px 16px;border-bottom:1px solid var(--border);";
progressEl.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;
margin-bottom:6px;">
<span style="font-size:12px;color:var(--muted);">Выполнено этапов</span>
<span style="font-size:12px;font-weight:700;color:var(--ink);">
${doneCount} / ${total}
</span>
</div>
<div style="height:4px;background:var(--border);border-radius:4px;overflow:hidden;">
<div style="height:100%;border-radius:4px;
background:var(--accent);
width:${pct}%;transition:width .4s ease;"></div>
</div>
`;
screen.appendChild(progressEl);
// Таймлайн
const tlWrap = document.createElement("div");
tlWrap.style.cssText = "padding:16px;";
milestones.forEach((ms, idx) => {
const isLast = idx === milestones.length - 1;
const row = document.createElement("div");
row.style.cssText = "display:flex;gap:12px;";
// Левая колонка: точка + линия
const lineCol = document.createElement("div");
lineCol.style.cssText = "display:flex;flex-direction:column;align-items:center;width:36px;flex-shrink:0;";
const dot = document.createElement("div");
dot.style.cssText = `
width:36px;height:36px;border-radius:50%;flex-shrink:0;
display:flex;align-items:center;justify-content:center;
font-size:17px;
background:${ms.done ? "var(--accent)" : "var(--surface)"};
border:2px solid ${ms.done ? "var(--accent)" : "var(--border)"};
`;
dot.textContent = ms.done ? ms.icon : "○";
const connLine = document.createElement("div");
if (!isLast) {
connLine.style.cssText = `
flex:1;width:2px;min-height:20px;margin:4px 0;
background:${ms.done ? "var(--accent)" : "var(--border)"};
opacity:${ms.done ? "1" : "0.4"};
`;
}
lineCol.appendChild(dot);
lineCol.appendChild(connLine);
// Правая колонка: контент
const content = document.createElement("div");
content.style.cssText = `
padding:4px 0 ${isLast ? "0" : "20px"};
flex:1;min-width:0;
`;
content.innerHTML = `
<div style="font-size:14px;
font-weight:${ms.done ? "600" : "400"};
color:${ms.done ? "var(--ink)" : "var(--muted)"};
line-height:1.3;">
${escHtml(ms.title)}
</div>
${ms.ts ? `
<div style="font-size:12px;color:var(--muted);margin-top:2px;">
${escHtml(fmtDate(ms.ts) || "")}
</div>` : (!ms.done ? `
<div style="font-size:11px;color:var(--muted);margin-top:2px;
font-style:italic;">ожидается</div>` : "")}
${ms.detail ? `
<div style="font-size:12px;color:var(--muted);margin-top:2px;">
${escHtml(ms.detail)}
</div>` : ""}
`;
row.appendChild(lineCol);
row.appendChild(content);
tlWrap.appendChild(row);
});
screen.appendChild(tlWrap);
// Кнопка «Назад к карточке сборки»
const backBtn = document.createElement("div");
backBtn.style.cssText = "margin:0 16px;";
backBtn.innerHTML = `
<button class="btn-secondary"
style="width:100%;font-size:13px;padding:11px;">
Назад к карточке
</button>
`;
backBtn.querySelector("button").addEventListener("click", () => {
haptic && haptic("impact");
history.back();
});
screen.appendChild(backBtn);
})
.catch(e => {
screen.innerHTML = `<div class="error" style="margin:16px;">Ошибка: ${escHtml(e.message)}</div>`;
});
}
return { mount };
})();

View File

@ -0,0 +1,391 @@
/* ============================================================
ExpeditorDashboard #/expeditor
Act4Screen #/expeditor/act/:assemblyId
Signature modes: telegram_otp | canvas
============================================================ */
const ExpeditorDashboard = (function () {
"use strict";
const ROOM_PRESETS = [
{ group: "Жилые", items: ["Гостиная","Спальня","Детская","Кабинет"] },
{ group: "Кухня", items: ["Кухня","Кухня-гостиная","Столовая"] },
{ group: "Санузел", items: ["Ванная","Санузел","Совмещённый"] },
{ group: "Хранение", items: ["Прихожая","Коридор","Кладовая","Гардероб"] },
{ group: "Другое", items: ["Балкон","Лоджия","Терраса","Доп. помещение"] },
];
function escHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
function el(html) {
const t = document.createElement("template");
t.innerHTML = html.trim();
return t.content.firstChild;
}
function fmtDate(s) {
if (!s) return "";
const d = new Date(s);
return isNaN(d) ? s : d.toLocaleDateString("ru-RU",{day:"2-digit",month:"2-digit",year:"numeric"});
}
async function _api(path, body) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 30000);
try {
const res = await fetch(BACKEND_URL + "/api/" + path, {
method: "POST", signal: ctrl.signal,
headers: {"Content-Type":"application/json"},
body: JSON.stringify(Object.assign({
initData: (typeof Platform !== "undefined" ? Platform.initData : ""),
initDataUnsafe: (typeof Platform !== "undefined" ? Platform.initDataUnsafe : null),
}, body)),
});
if (!res.ok) throw new Error("HTTP " + res.status);
return await res.json();
} catch(e) { if (e.name === "AbortError") throw new Error("Таймаут"); throw e; }
finally { clearTimeout(t); }
}
// ── MAIN LIST ────────────────────────────────────────────────────────────
async function mount(container) {
container.innerHTML = "";
document.body.classList.remove("has-bottom-nav");
const nav = document.getElementById("bottom-nav"); if (nav) nav.remove();
const icons = window.ICONS || {};
const header = el(
"<header class=\"podbor-header\">" +
"<button class=\"podbor-back\">" + (icons.arrow_left || "") + "</button>" +
"<div class=\"podbor-title\">Маршруты и акты</div>" +
"<div style=\"width:28px\"></div></header>"
);
header.querySelector(".podbor-back").addEventListener("click", () => {
if (typeof haptic !== "undefined") haptic("impact");
history.back();
});
const screen = el("<div class=\"podbor-screen\"></div>");
container.appendChild(header);
container.appendChild(screen);
screen.innerHTML = "<div class=\"loader-inline\"><div class=\"spinner\"></div></div>";
try {
const data = await _api("expeditor_inbox", {});
if (data.error) throw new Error(data.error);
_renderList(screen, data.assemblies || []);
} catch(e) {
screen.innerHTML = "<div class=\"error\" style=\"margin:16px;\">Ошибка: " + escHtml(e.message) + "</div>";
}
}
function _groupByDate(items) {
var today = new Date(); today.setHours(0,0,0,0);
var tom = new Date(today); tom.setDate(tom.getDate()+1);
var groups = {}, order = [];
items.forEach(function(a) {
var d = a.scheduled_at ? new Date(a.scheduled_at) : null;
var label;
if (!d || isNaN(d)) {
label = "Без даты";
} else {
var day = new Date(d); day.setHours(0,0,0,0);
if (day.getTime() === today.getTime()) label = "Сегодня";
else if (day.getTime() === tom.getTime()) label = "Завтра";
else label = day.toLocaleDateString("ru-RU",{day:"2-digit",month:"long",weekday:"short"});
}
if (!groups[label]) { groups[label] = []; order.push(label); }
groups[label].push(a);
});
return {groups: groups, order: order};
}
function _renderList(screen, items) {
screen.innerHTML = "";
if (!items.length) {
screen.innerHTML =
"<div style=\"text-align:center;padding:48px 20px;color:var(--muted);\">" +
"<div style=\"font-size:40px;margin-bottom:12px;\">🚚</div>" +
"<div style=\"font-weight:600;\">Маршрутов нет</div>" +
"<div style=\"font-size:13px;margin-top:6px;\">Менеджер назначит вас на доставку</div></div>";
return;
}
const pending = items.filter(a => !a.is_signed);
const done = items.filter(a => a.is_signed);
if (pending.length) {
screen.appendChild(el("<div style=\"padding:12px 16px 4px;font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em;\">К приёмке (" + pending.length + ")</div>"));
var gd = _groupByDate(pending);
gd.order.forEach(function(label) {
screen.appendChild(el('<div style="padding:6px 16px 4px;font-size:12px;font-weight:600;color:var(--accent);">📅 ' + label + '</div>'));
gd.groups[label].forEach(function(a) { screen.appendChild(_card(a, false)); });
});
}
if (done.length) {
screen.appendChild(el("<div style=\"padding:16px 16px 4px;font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em;\">Подписано (" + done.length + ")</div>"));
done.forEach(a => screen.appendChild(_card(a, true)));
}
}
function _card(a, signed) {
const badge = signed
? "<span style=\"font-size:11px;padding:2px 8px;background:#e8f5e9;color:#2e7d32;border-radius:20px;font-weight:600;\">✅ Подписан</span>"
: "<span style=\"font-size:11px;padding:2px 8px;background:#fff3e0;color:#e65100;border-radius:20px;font-weight:600;\">⏳ Ожидает</span>";
const card = el(
"<div style=\"margin:0 16px 10px;padding:14px;background:var(--surface);border:1px solid var(--border);border-radius:14px;cursor:pointer;\">" +
"<div style=\"display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px;\">" +
"<div style=\"font-size:14px;font-weight:700;\">" + escHtml(a.client_name || "—") + "</div>" +
badge + "</div>" +
"<div style=\"font-size:12px;color:var(--muted);margin-bottom:4px;\">📍 " + escHtml(a.address || "адрес не указан") + "</div>" +
(a.scheduled_at ? "<div style=\"font-size:11px;color:var(--muted);\">📅 " + fmtDate(a.scheduled_at) + "</div>" : "") +
(signed && a.signed_at ? "<div style=\"font-size:11px;color:#2e7d32;margin-top:4px;\">Подписан " + fmtDate(a.signed_at) + "</div>" : "") +
"</div>"
);
card.addEventListener("click", () => {
if (typeof haptic !== "undefined") haptic("selection");
location.hash = "#/expeditor/act/" + a.id;
});
return card;
}
// ── ACT4 SCREEN ──────────────────────────────────────────────────────────
async function mountAct(container, assemblyId) {
container.innerHTML = "";
document.body.classList.remove("has-bottom-nav");
const nav = document.getElementById("bottom-nav"); if (nav) nav.remove();
const icons = window.ICONS || {};
const header = el(
"<header class=\"podbor-header\">" +
"<button class=\"podbor-back\">" + (icons.arrow_left || "") + "</button>" +
"<div class=\"podbor-title\">Акт приёмки</div>" +
"<div style=\"width:28px\"></div></header>"
);
header.querySelector(".podbor-back").addEventListener("click", () => {
if (typeof haptic !== "undefined") haptic("impact");
history.back();
});
const screen = el("<div class=\"podbor-screen\"></div>");
container.appendChild(header);
container.appendChild(screen);
screen.innerHTML = "<div class=\"loader-inline\"><div class=\"spinner\"></div></div>";
try {
const data = await _api("act4_preview", {assembly_id: assemblyId});
if (data.error) throw new Error(data.error);
_renderAct(screen, data, assemblyId);
} catch(e) {
screen.innerHTML = "<div class=\"error\" style=\"margin:16px;\">Ошибка: " + escHtml(e.message) + "</div>";
}
}
function _renderAct(screen, act, assemblyId) {
screen.innerHTML = "";
// Client info
screen.appendChild(el(
"<div style=\"margin:12px 16px;padding:14px;background:var(--surface);border:1px solid var(--border);border-radius:12px;\">" +
"<div style=\"font-size:14px;font-weight:700;margin-bottom:4px;\">" + escHtml(act.client_name || "—") + "</div>" +
"<div style=\"font-size:12px;color:var(--muted);\">📍 " + escHtml(act.address || "—") + "</div>" +
(act.client_phone ? "<div style=\"font-size:12px;color:var(--muted);margin-top:2px;\">📞 " + escHtml(act.client_phone) + "</div>" : "") +
"<div style=\"font-size:11px;color:var(--muted);margin-top:6px;\">Акт № " + escHtml(act.act_num) + " · " + escHtml(act.act_date) + "</div>" +
"</div>"
));
// Already signed banner
if (act.is_signed) {
screen.appendChild(el(
"<div style=\"margin:0 16px 12px;padding:12px 14px;background:#e8f5e9;border:1px solid #a5d6a7;border-radius:12px;\">" +
"<div style=\"font-size:13px;font-weight:700;color:#2e7d32;margin-bottom:2px;\">✅ Акт подписан</div>" +
"<div style=\"font-size:12px;color:#388e3c;\">" + escHtml(act.signed_by_name) + " · " + fmtDate(act.signed_at) + " · " + escHtml(act.signed_via || "") + "</div>" +
"</div>"
));
}
// Items list (existing)
const itemsList = act.items || [];
const itemsWrap = el("<div style=\"margin:0 16px 10px;\"></div>");
if (itemsList.length) {
itemsList.forEach(item => itemsWrap.appendChild(_itemCard(item)));
}
screen.appendChild(itemsWrap);
// If not signed — show signature section
if (!act.is_signed) {
_renderSignatureSection(screen, assemblyId);
}
}
function _itemCard(item) {
const cond = item.condition === "damaged"
? "<span style=\"color:#e53935;font-weight:600;\">⚠ Повреждение</span>"
: "<span style=\"color:#43a047;\">✓ OK</span>";
return el(
"<div style=\"display:flex;justify-content:space-between;align-items:center;padding:10px 12px;margin-bottom:6px;background:var(--surface);border:1px solid var(--border);border-radius:10px;\">" +
"<div><div style=\"font-size:13px;font-weight:600;\">" + escHtml(item.name || "Позиция") + "</div>" +
"<div style=\"font-size:11px;color:var(--muted);\">Кол-во: " + escHtml(String(item.qty || 1)) + "</div></div>" +
"<div>" + cond + "</div></div>"
);
}
// ── SIGNATURE SECTION ────────────────────────────────────────────────────
function _renderSignatureSection(screen, assemblyId) {
const wrap = el("<div style=\"margin:0 16px 24px;\"></div>");
screen.appendChild(wrap);
const tabs = el(
"<div style=\"display:flex;gap:8px;margin-bottom:14px;\">" +
"<button data-tab=\"otp\" class=\"sig-tab sig-active\" style=\"flex:1;padding:10px;border-radius:10px;border:1.5px solid var(--accent);background:var(--accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;\">📱 Код в Telegram</button>" +
"<button data-tab=\"canvas\" class=\"sig-tab\" style=\"flex:1;padding:10px;border-radius:10px;border:1.5px solid var(--border);background:none;color:var(--ink);font-size:13px;font-weight:600;cursor:pointer;\">✍️ Подпись рукой</button>" +
"</div>"
);
const otpPanel = el("<div class=\"sig-panel\" data-panel=\"otp\"></div>");
const canvasPanel = el("<div class=\"sig-panel\" data-panel=\"canvas\" style=\"display:none;\"></div>");
_buildOtpPanel(otpPanel, assemblyId, wrap);
_buildCanvasPanel(canvasPanel, assemblyId, wrap);
tabs.querySelectorAll(".sig-tab").forEach(btn => {
btn.addEventListener("click", () => {
const tgt = btn.dataset.tab;
tabs.querySelectorAll(".sig-tab").forEach(b => {
const active = b.dataset.tab === tgt;
b.style.background = active ? "var(--accent)" : "none";
b.style.color = active ? "#fff" : "var(--ink)";
b.style.borderColor = active ? "var(--accent)" : "var(--border)";
});
[otpPanel, canvasPanel].forEach(p => {
p.style.display = p.dataset.panel === tgt ? "" : "none";
});
});
});
wrap.appendChild(tabs);
wrap.appendChild(otpPanel);
wrap.appendChild(canvasPanel);
}
function _buildOtpPanel(panel, assemblyId, wrap) {
panel.innerHTML = "";
panel.appendChild(el(
"<div style=\"font-size:12px;color:var(--muted);margin-bottom:10px;\">Бот пришлёт 6-значный код в этот чат. Введите его для подписи.</div>"
));
const nameField = el(
"<div style=\"margin-bottom:10px;\"><label style=\"font-size:12px;color:var(--muted);display:block;margin-bottom:4px;\">Подписант (ФИО или должность)</label>" +
"<input id=\"otpName\" type=\"text\" placeholder=\"Иванов И.И.\" style=\"width:100%;box-sizing:border-box;padding:10px;border:1px solid var(--border);border-radius:8px;font-size:14px;background:var(--surface);\"></div>"
);
panel.appendChild(nameField);
const sendBtn = el(
"<button id=\"sendOtpBtn\" style=\"width:100%;padding:12px;background:var(--accent);color:#fff;border:none;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer;margin-bottom:12px;\">Получить код</button>"
);
const codeSection = el("<div id=\"codeSection\" style=\"display:none;\"></div>");
const codeField = el(
"<div><label style=\"font-size:12px;color:var(--muted);display:block;margin-bottom:4px;\">Введите код из Telegram</label>" +
"<input id=\"otpInput\" type=\"number\" inputmode=\"numeric\" maxlength=\"6\" placeholder=\"123456\" style=\"width:100%;box-sizing:border-box;padding:12px;border:1.5px solid var(--accent);border-radius:8px;font-size:20px;letter-spacing:6px;text-align:center;background:var(--surface);\"></div>"
);
const verifyBtn = el(
"<button id=\"verifyOtpBtn\" style=\"width:100%;padding:12px;background:var(--accent);color:#fff;border:none;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer;margin-top:10px;\">Подтвердить</button>"
);
const errEl = el("<div id=\"otpErr\" style=\"color:#e53935;font-size:12px;margin-top:6px;\"></div>");
codeSection.appendChild(codeField);
codeSection.appendChild(verifyBtn);
codeSection.appendChild(errEl);
panel.appendChild(sendBtn);
panel.appendChild(codeSection);
sendBtn.addEventListener("click", async () => {
sendBtn.disabled = true; sendBtn.textContent = "Отправляем…";
try {
const data = await _api("act4_request_otp", {assembly_id: assemblyId});
if (data.error) { sendBtn.textContent = "Ошибка: " + data.error; sendBtn.disabled = false; return; }
sendBtn.textContent = "✅ Код отправлен — проверьте Telegram";
codeSection.style.display = "";
panel.querySelector("#otpInput").focus();
} catch(e) { sendBtn.textContent = "Ошибка: " + e.message; sendBtn.disabled = false; }
});
verifyBtn.addEventListener("click", async () => {
const code = panel.querySelector("#otpInput").value.trim();
const name = panel.querySelector("#otpName").value.trim();
errEl.textContent = "";
if (code.length < 6) { errEl.textContent = "Введите 6-значный код"; return; }
verifyBtn.disabled = true; verifyBtn.textContent = "Проверяем…";
try {
const data = await _api("act4_verify_otp", {assembly_id: assemblyId, code, signed_by_name: name});
if (data.error) {
const msgs = {invalid_code:"Неверный код",code_expired:"Код устарел, запросите новый",act_not_found:"Акт не найден"};
errEl.textContent = msgs[data.error] || data.error;
verifyBtn.disabled = false; verifyBtn.textContent = "Подтвердить"; return;
}
if (typeof haptic !== "undefined") haptic("success");
wrap.innerHTML = "<div style=\"padding:16px;background:#e8f5e9;border:2px solid #43a047;border-radius:14px;text-align:center;\"><div style=\"font-size:22px;margin-bottom:8px;\">✅</div><div style=\"font-weight:700;color:#2e7d32;font-size:15px;\">Акт подписан</div><div style=\"font-size:12px;color:#388e3c;margin-top:4px;\">" + escHtml(data.signed_by_name) + "</div></div>";
} catch(e) { errEl.textContent = e.message; verifyBtn.disabled = false; verifyBtn.textContent = "Подтвердить"; }
});
}
function _buildCanvasPanel(panel, assemblyId, wrap) {
panel.innerHTML = "";
panel.appendChild(el("<div style=\"font-size:12px;color:var(--muted);margin-bottom:10px;\">Нарисуйте подпись пальцем на экране.</div>"));
const nameField = el(
"<div style=\"margin-bottom:10px;\"><label style=\"font-size:12px;color:var(--muted);display:block;margin-bottom:4px;\">Подписант (ФИО или должность)</label>" +
"<input id=\"canvasName\" type=\"text\" placeholder=\"Иванов И.И.\" style=\"width:100%;box-sizing:border-box;padding:10px;border:1px solid var(--border);border-radius:8px;font-size:14px;background:var(--surface);\"></div>"
);
panel.appendChild(nameField);
const canvasWrap = el(
"<div style=\"border:1.5px solid var(--border);border-radius:10px;overflow:hidden;background:#fafafa;margin-bottom:10px;position:relative;\">" +
"<canvas id=\"sigCanvas\" style=\"width:100%;height:140px;display:block;touch-action:none;\"></canvas>" +
"<button id=\"clearCanvas\" style=\"position:absolute;top:6px;right:8px;font-size:11px;color:var(--muted);background:none;border:none;cursor:pointer;\">Очистить</button>" +
"</div>"
);
panel.appendChild(canvasWrap);
const saveBtn = el(
"<button id=\"saveCanvasBtn\" style=\"width:100%;padding:12px;background:var(--accent);color:#fff;border:none;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer;\">Подписать</button>"
);
const errEl = el("<div id=\"canvasErr\" style=\"color:#e53935;font-size:12px;margin-top:6px;\"></div>");
panel.appendChild(saveBtn);
panel.appendChild(errEl);
// Init canvas after DOM insertion (needs layout)
requestAnimationFrame(() => {
const canvas = panel.querySelector("#sigCanvas");
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
const ctx = canvas.getContext("2d");
ctx.scale(dpr, dpr);
ctx.lineWidth = 2.5;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = "#1a1a1a";
let drawing = false, lastX = 0, lastY = 0, hasStrokes = false;
function pos(e) {
const r = canvas.getBoundingClientRect();
const src = e.touches ? e.touches[0] : e;
return [src.clientX - r.left, src.clientY - r.top];
}
canvas.addEventListener("pointerdown", e => {
drawing = true; [lastX, lastY] = pos(e);
ctx.beginPath(); ctx.moveTo(lastX, lastY);
e.preventDefault();
});
canvas.addEventListener("pointermove", e => {
if (!drawing) return;
const [x, y] = pos(e);
ctx.lineTo(x, y); ctx.stroke();
lastX = x; lastY = y; hasStrokes = true;
e.preventDefault();
});
canvas.addEventListener("pointerup", () => { drawing = false; });
canvas.addEventListener("pointerleave",() => { drawing = false; });
panel.querySelector("#clearCanvas").addEventListener("click", () => {
ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
hasStrokes = false;
});
saveBtn.addEventListener("click", async () => {
if (!hasStrokes) { errEl.textContent = "Нарисуйте подпись"; return; }
const name = panel.querySelector("#canvasName").value.trim();
const b64 = canvas.toDataURL("image/png").replace("data:image/png;base64,", "");
saveBtn.disabled = true; saveBtn.textContent = "Сохраняем…"; errEl.textContent = "";
try {
const data = await _api("act4_save_signature", {assembly_id: assemblyId, signature_b64: b64, signed_by_name: name});
if (data.error) throw new Error(data.error);
if (typeof haptic !== "undefined") haptic("success");
wrap.innerHTML = "<div style=\"padding:16px;background:#e8f5e9;border:2px solid #43a047;border-radius:14px;text-align:center;\"><div style=\"font-size:22px;margin-bottom:8px;\">✅</div><div style=\"font-weight:700;color:#2e7d32;font-size:15px;\">Акт подписан</div><div style=\"font-size:12px;color:#388e3c;margin-top:4px;\">" + escHtml(data.signed_by_name) + "</div></div>";
} catch(e) { errEl.textContent = e.message; saveBtn.disabled = false; saveBtn.textContent = "Подписать"; }
});
});
}
return { mount, mountAct };
})();

404
miniapp/assets/feedback.js Normal file
View File

@ -0,0 +1,404 @@
/* ============================================================
Система оценок виджет + экран #/feedback/my
Используется в: assembly_detail.js, app.js (замерщик, менеджер)
============================================================ */
const FeedbackModule = (function () {
function escHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
async function _api(path, body = {}) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 15000);
try {
const res = await fetch(`${BACKEND_URL}/api/${path}`, {
method: "POST", signal: ctrl.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null,
...body,
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (e) {
if (e.name === "AbortError") throw new Error("Сервер не отвечает");
throw e;
} finally { clearTimeout(t); }
}
// ── Отрисовка звёздочек (только чтение) ────────────────────────
function starsHtml(avg, size) {
if (avg == null) return "";
const sz = size || 14;
const full = Math.floor(avg);
const half = (avg - full) >= 0.4 ? 1 : 0;
const empty = 5 - full - half;
return (
"★".repeat(full) +
(half ? "½" : "") +
"☆".repeat(empty)
).split("").map((c, i) => {
const col = i < full ? "#F39C12" : (c === "½" ? "#F39C12" : "#ddd");
return `<span style="color:${col};font-size:${sz}px;">${c === "½" ? "★" : c}</span>`;
}).join("");
}
// ── Интерактивный виджет звёзд ─────────────────────────────────
// Возвращает {el, getValue()}
function createStarWidget(label, sublabel) {
const wrap = document.createElement("div");
wrap.style.cssText = "margin-bottom:14px;";
wrap.innerHTML = `
<div style="font-size:13px;font-weight:600;color:var(--ink);margin-bottom:2px;">
${escHtml(label)}
</div>
${sublabel ? `<div style="font-size:11px;color:var(--muted);margin-bottom:4px;">${escHtml(sublabel)}</div>` : ""}
<div class="fb-stars" style="display:flex;gap:4px;" data-value="0">
${[1,2,3,4,5].map(i => `
<button type="button" data-v="${i}"
style="font-size:28px;line-height:1;background:none;border:none;
cursor:pointer;padding:2px;color:#ddd;"></button>
`).join("")}
</div>
`;
const row = wrap.querySelector(".fb-stars");
const btns = [...row.querySelectorAll("button")];
let selected = 0;
function paint(n) {
btns.forEach((b, i) => {
b.style.color = i < n ? "#F39C12" : "#ddd";
});
}
btns.forEach((btn, idx) => {
btn.addEventListener("mouseenter", () => paint(idx + 1));
btn.addEventListener("mouseleave", () => paint(selected));
btn.addEventListener("click", () => {
selected = idx + 1;
row.dataset.value = selected;
haptic && haptic("impact");
paint(selected);
});
});
return {
el: wrap,
getValue: () => selected,
isValid: () => selected >= 1,
};
}
// ── Форма оценки после сборки (для клиента) ────────────────────
// container — DOM-элемент куда рендерить
// config = { assemblerName, assemblerTgId, managerName, managerTgId,
// assemblyId, onSubmit() }
function mountAssemblyFeedback(container, cfg) {
container.innerHTML = "";
container.style.cssText = "margin:12px 16px 0;padding:14px;background:var(--surface);" +
"border:2px solid var(--accent);border-radius:14px;";
const title = document.createElement("div");
title.style.cssText = "font-size:14px;font-weight:700;color:var(--ink);margin-bottom:2px;";
title.textContent = "⭐ Оцените нашу работу";
const sub = document.createElement("div");
sub.style.cssText = "font-size:12px;color:var(--muted);margin-bottom:12px;";
sub.textContent = "Займёт 10 секунд — помогает нам становиться лучше";
container.appendChild(title);
container.appendChild(sub);
const wAsm = cfg.assemblerName
? createStarWidget(`👷 ${cfg.assemblerName}`, "Качество сборки")
: null;
const wMgr = cfg.managerName
? createStarWidget(`🗂 ${cfg.managerName}`, "Работа менеджера")
: null;
const wSvc = createStarWidget("🏠 Сервис в целом", "Насколько довольны компанией?");
if (wAsm) container.appendChild(wAsm.el);
if (wMgr) container.appendChild(wMgr.el);
container.appendChild(wSvc.el);
// Комментарий
const cmtWrap = document.createElement("div");
cmtWrap.style.cssText = "margin-bottom:10px;";
cmtWrap.innerHTML = `
<textarea id="fb-comment" rows="2"
placeholder="Комментарий (необязательно)…"
style="width:100%;padding:9px;border:1px solid var(--border);
border-radius:8px;background:var(--surface);color:var(--ink);
font-size:13px;resize:none;box-sizing:border-box;"></textarea>
`;
container.appendChild(cmtWrap);
const sendBtn = document.createElement("button");
sendBtn.className = "btn-primary";
sendBtn.style.cssText = "width:100%;font-size:14px;padding:11px;";
sendBtn.textContent = "Отправить оценку";
const statusEl = document.createElement("div");
statusEl.style.cssText = "font-size:12px;color:var(--muted);min-height:16px;margin-top:6px;";
container.appendChild(sendBtn);
container.appendChild(statusEl);
sendBtn.addEventListener("click", async () => {
// Нужна хотя бы одна оценка
const hasAny = (wAsm && wAsm.isValid()) || (wMgr && wMgr.isValid()) || wSvc.isValid();
if (!hasAny) { statusEl.textContent = "Поставьте хотя бы одну звезду"; return; }
haptic && haptic("impact");
sendBtn.disabled = true; sendBtn.textContent = "Отправляем…";
const comment = container.querySelector("#fb-comment")?.value.trim() || "";
const ratings = [];
if (wAsm && wAsm.isValid()) {
ratings.push({ target_tg_id: cfg.assemblerTgId, target_role: "assembler",
stars: wAsm.getValue() });
}
if (wMgr && wMgr.isValid()) {
ratings.push({ target_tg_id: cfg.managerTgId, target_role: "manager",
stars: wMgr.getValue() });
}
if (wSvc.isValid()) {
ratings.push({ target_role: "service", stars: wSvc.getValue(), comment });
}
try {
const res = await _api("feedback_submit", {
ref_id: cfg.assemblyId,
ref_type: "assembly",
ratings,
});
if (res.ok) {
haptic && haptic("success");
container.innerHTML = `
<div style="text-align:center;padding:12px;">
<div style="font-size:28px;margin-bottom:8px;">🙏</div>
<div style="font-size:15px;font-weight:700;color:var(--ink);">Спасибо за оценку!</div>
<div style="font-size:13px;color:var(--muted);margin-top:4px;">
Ваш отзыв помогает нам работать лучше
</div>
</div>`;
if (cfg.onSubmit) cfg.onSubmit();
} else {
statusEl.textContent = res.msg || res.error || "Ошибка";
sendBtn.disabled = false; sendBtn.textContent = "Отправить оценку";
}
} catch (e) {
statusEl.textContent = e.message;
sendBtn.disabled = false; sendBtn.textContent = "Отправить оценку";
}
});
}
// ── Форма оценки замерщиком → менеджера (после завершения замера) ──
// container — куда рендерить
// cfg = { managerName, managerTgId, measurementId, onSubmit() }
function mountMeasurerFeedback(container, cfg) {
container.innerHTML = "";
container.style.cssText = "margin:12px 0 0;padding:12px;background:var(--surface);" +
"border:1px solid var(--border);border-radius:12px;";
const title = document.createElement("div");
title.style.cssText = "font-size:13px;font-weight:700;color:var(--ink);margin-bottom:8px;";
title.textContent = "💬 Оценка заявки от менеджера";
container.appendChild(title);
const w = createStarWidget(
`🗂 ${cfg.managerName || "Менеджер"}`,
"Насколько полно была подготовлена заявка?"
);
container.appendChild(w.el);
const sendBtn = document.createElement("button");
sendBtn.className = "btn-secondary";
sendBtn.style.cssText = "width:100%;font-size:13px;padding:9px;";
sendBtn.textContent = "Оценить";
const statusEl = document.createElement("div");
statusEl.style.cssText = "font-size:11px;color:var(--muted);min-height:14px;margin-top:4px;";
container.appendChild(sendBtn);
container.appendChild(statusEl);
sendBtn.addEventListener("click", async () => {
if (!w.isValid()) { statusEl.textContent = "Поставьте оценку"; return; }
haptic && haptic("impact");
sendBtn.disabled = true; sendBtn.textContent = "…";
try {
const res = await _api("feedback_submit", {
ref_id: cfg.measurementId,
ref_type: "measurement",
ratings: [{ target_tg_id: cfg.managerTgId, target_role: "manager", stars: w.getValue() }],
});
if (res.ok) {
container.innerHTML = `<div style="font-size:12px;color:#27AE60;padding:4px 0;">✅ Оценка отправлена</div>`;
if (cfg.onSubmit) cfg.onSubmit();
} else {
statusEl.textContent = res.error || "Ошибка";
sendBtn.disabled = false; sendBtn.textContent = "Оценить";
}
} catch (e) {
statusEl.textContent = e.message;
sendBtn.disabled = false; sendBtn.textContent = "Оценить";
}
});
}
// ── Форма оценки менеджером → замерщика ────────────────────────
// cfg = { measurerName, measurerTgId, measurementId, onSubmit() }
function mountManagerFeedback(container, cfg) {
container.innerHTML = "";
container.style.cssText = "margin:8px 0 0;padding:12px;background:var(--surface);" +
"border:1px solid var(--border);border-radius:12px;";
const w = createStarWidget(
`📐 ${cfg.measurerName || "Замерщик"}`,
"Качество замера и документации"
);
container.appendChild(w.el);
const sendBtn = document.createElement("button");
sendBtn.className = "btn-secondary";
sendBtn.style.cssText = "width:100%;font-size:13px;padding:9px;";
sendBtn.textContent = "Оценить замерщика";
const statusEl = document.createElement("div");
statusEl.style.cssText = "font-size:11px;color:var(--muted);min-height:14px;margin-top:4px;";
container.appendChild(sendBtn);
container.appendChild(statusEl);
sendBtn.addEventListener("click", async () => {
if (!w.isValid()) { statusEl.textContent = "Поставьте оценку"; return; }
haptic && haptic("impact");
sendBtn.disabled = true; sendBtn.textContent = "…";
try {
const res = await _api("feedback_submit", {
ref_id: cfg.measurementId,
ref_type: "measurement",
ratings: [{ target_tg_id: cfg.measurerTgId, target_role: "measurer", stars: w.getValue() }],
});
if (res.ok) {
container.innerHTML = `<div style="font-size:12px;color:#27AE60;padding:4px 0;">✅ Оценка отправлена</div>`;
if (cfg.onSubmit) cfg.onSubmit();
} else {
statusEl.textContent = res.error || "Ошибка";
sendBtn.disabled = false; sendBtn.textContent = "Оценить замерщика";
}
} catch (e) {
statusEl.textContent = e.message;
sendBtn.disabled = false; sendBtn.textContent = "Оценить замерщика";
}
});
}
// ── Экран «Мои оценки» — #/feedback/my ─────────────────────────
function mountMyScreen(container) {
container.innerHTML = "";
document.body.classList.remove("has-bottom-nav");
const oldNav = document.getElementById("bottom-nav");
if (oldNav) oldNav.remove();
const h = document.createElement("header");
h.className = "podbor-header";
h.innerHTML = `
<button class="podbor-back">${(window.ICONS || {}).arrow_left || ""}</button>
<div class="podbor-title">Мои оценки</div>
<div style="width:36px"></div>
`;
h.querySelector(".podbor-back").addEventListener("click", () => {
haptic && haptic("impact"); history.back();
});
container.appendChild(h);
const screen = document.createElement("div");
screen.className = "podbor-screen";
screen.style.cssText = "padding:0 0 48px;";
screen.innerHTML = `<div class="loader-inline"><div class="spinner"></div></div>`;
container.appendChild(screen);
_api("feedback_my").then(data => {
if (data.error) {
screen.innerHTML = `<div class="error" style="margin:16px;">${escHtml(data.error)}</div>`;
return;
}
screen.innerHTML = "";
if (!data.total) {
screen.innerHTML = `
<div style="margin:48px 16px;text-align:center;color:var(--muted);font-size:14px;">
Оценок пока нет.<br>Они появятся после завершения работ.
</div>`;
return;
}
// Общий балл (среднее по всем ролям)
const allVals = (data.aggregated || []).map(a => a.avg);
const overall = allVals.length
? (allVals.reduce((s, v) => s + v, 0) / allVals.length).toFixed(1)
: null;
const heroEl = document.createElement("div");
heroEl.style.cssText = "padding:20px 16px;text-align:center;border-bottom:1px solid var(--border);";
heroEl.innerHTML = `
<div style="font-size:48px;line-height:1;">${overall || "—"}</div>
<div style="margin:6px 0 2px;">${starsHtml(parseFloat(overall), 18)}</div>
<div style="font-size:12px;color:var(--muted);">${data.total} оценок</div>
`;
screen.appendChild(heroEl);
// По ролям
for (const agg of (data.aggregated || [])) {
const rowEl = document.createElement("div");
rowEl.style.cssText = "padding:12px 16px;border-bottom:1px solid var(--border);" +
"display:flex;justify-content:space-between;align-items:center;";
rowEl.innerHTML = `
<div>
<div style="font-size:13px;font-weight:600;color:var(--ink);">${escHtml(agg.label)}</div>
<div style="font-size:11px;color:var(--muted);">${agg.count} оценок</div>
</div>
<div style="text-align:right;">
<div style="font-size:20px;font-weight:700;color:var(--accent);">${agg.avg}</div>
<div style="font-size:13px;">${starsHtml(agg.avg, 13)}</div>
</div>
`;
screen.appendChild(rowEl);
}
// Комментарии
if (data.comments && data.comments.length) {
const cmtHead = document.createElement("div");
cmtHead.className = "section-head";
cmtHead.style.marginTop = "16px";
cmtHead.innerHTML = `<span class="label">Комментарии</span>`;
screen.appendChild(cmtHead);
for (const c of data.comments) {
const cEl = document.createElement("div");
cEl.style.cssText = "margin:0 16px 8px;padding:10px 12px;background:var(--surface);" +
"border:1px solid var(--border);border-radius:10px;";
cEl.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
<span style="font-size:12px;color:var(--muted);">${escHtml(c.role || "Клиент")}</span>
<span style="font-size:13px;">${"★".repeat(parseInt(c.stars)||0)}</span>
</div>
<div style="font-size:13px;color:var(--ink);">${escHtml(c.comment)}</div>
`;
screen.appendChild(cEl);
}
}
}).catch(e => {
screen.innerHTML = `<div class="error" style="margin:16px;">Ошибка: ${escHtml(e.message)}</div>`;
});
}
return {
starsHtml,
createStarWidget,
mountAssemblyFeedback,
mountMeasurerFeedback,
mountManagerFeedback,
mountMyScreen,
};
})();

View File

@ -0,0 +1,287 @@
/* ============================================================
Финансовая сводка менеджера #/admin/finance
============================================================ */
const FinanceSummary = (function () {
function escHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function fmt(n) {
if (n == null || n === "") return "—";
return Number(n).toLocaleString("ru-RU", { maximumFractionDigits: 0 }) + " ₽";
}
function fmtDate(iso) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
} catch { return iso.slice(0, 10); }
}
async function _api(path, body = {}) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 20000);
try {
const res = await fetch(`${BACKEND_URL}/api/${path}`, {
method: "POST", signal: ctrl.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null,
...body,
}),
});
if (!res.ok) throw new Error(`Ошибка сервера (${res.status})`);
return await res.json();
} catch (e) {
if (e.name === "AbortError") throw new Error("Сервер не отвечает");
throw e;
} finally { clearTimeout(t); }
}
let _currentPeriod = "current_month";
function mount(container) {
container.innerHTML = "";
document.body.classList.remove("has-bottom-nav");
const oldNav = document.getElementById("bottom-nav");
if (oldNav) oldNav.remove();
const h = document.createElement("header");
h.className = "podbor-header";
h.innerHTML = `
<button class="podbor-back">${(window.ICONS || {}).arrow_left || ""}</button>
<div class="podbor-title">Финансы</div>
<div style="width:36px"></div>
`;
h.querySelector(".podbor-back").addEventListener("click", () => {
haptic && haptic("impact");
history.back();
});
container.appendChild(h);
// Period switcher
const periodWrap = document.createElement("div");
periodWrap.style.cssText = "padding:12px 16px;border-bottom:1px solid var(--border);";
periodWrap.innerHTML = `
<div style="display:flex;gap:6px;">
<button class="fs-period-btn${_currentPeriod === "current_month" ? " active" : ""}"
data-p="current_month"
style="flex:1;padding:8px 4px;font-size:12px;font-weight:600;
border-radius:8px;border:1px solid var(--border);
background:${_currentPeriod === "current_month" ? "var(--accent)" : "var(--surface)"};
color:${_currentPeriod === "current_month" ? "#fff" : "var(--ink)"};
cursor:pointer;">
Этот месяц
</button>
<button class="fs-period-btn${_currentPeriod === "prev_month" ? " active" : ""}"
data-p="prev_month"
style="flex:1;padding:8px 4px;font-size:12px;font-weight:600;
border-radius:8px;border:1px solid var(--border);
background:${_currentPeriod === "prev_month" ? "var(--accent)" : "var(--surface)"};
color:${_currentPeriod === "prev_month" ? "#fff" : "var(--ink)"};
cursor:pointer;">
Пред. месяц
</button>
<button class="fs-period-btn${_currentPeriod === "quarter" ? " active" : ""}"
data-p="quarter"
style="flex:1;padding:8px 4px;font-size:12px;font-weight:600;
border-radius:8px;border:1px solid var(--border);
background:${_currentPeriod === "quarter" ? "var(--accent)" : "var(--surface)"};
color:${_currentPeriod === "quarter" ? "#fff" : "var(--ink)"};
cursor:pointer;">
3 месяца
</button>
</div>
`;
container.appendChild(periodWrap);
const screen = document.createElement("div");
screen.className = "podbor-screen";
screen.style.cssText = "padding:0 0 48px;";
container.appendChild(screen);
periodWrap.querySelectorAll(".fs-period-btn").forEach(btn => {
btn.addEventListener("click", () => {
haptic && haptic("impact");
_currentPeriod = btn.dataset.p;
periodWrap.querySelectorAll(".fs-period-btn").forEach(b => {
const active = b.dataset.p === _currentPeriod;
b.style.background = active ? "var(--accent)" : "var(--surface)";
b.style.color = active ? "#fff" : "var(--ink)";
});
_load(screen, _currentPeriod);
});
});
_load(screen, _currentPeriod);
}
function _load(screen, period) {
screen.innerHTML = `<div class="loader-inline"><div class="spinner"></div></div>`;
_api("manager_finance_summary", { period }).then(data => {
if (data.error) {
screen.innerHTML = `<div class="error" style="margin:16px;">${escHtml(data.error)}</div>`;
return;
}
_render(screen, data);
}).catch(e => {
screen.innerHTML = `<div class="error" style="margin:16px;">Ошибка: ${escHtml(e.message)}</div>`;
});
}
function _kpiCard(icon, label, value, sub, valueColor) {
return `
<div style="padding:12px;background:var(--surface);border:1px solid var(--border);
border-radius:12px;">
<div style="font-size:20px;margin-bottom:4px;">${icon}</div>
<div style="font-size:11px;color:var(--muted);text-transform:uppercase;
letter-spacing:.05em;font-weight:600;">${escHtml(label)}</div>
<div style="font-size:20px;font-weight:700;
color:${valueColor || "var(--ink)"};margin-top:2px;line-height:1.1;">
${escHtml(value)}
</div>
${sub ? `<div style="font-size:11px;color:var(--muted);margin-top:2px;">${escHtml(sub)}</div>` : ""}
</div>
`;
}
function _render(screen, data) {
screen.innerHTML = "";
// Period label
const titleEl = document.createElement("div");
titleEl.style.cssText = "padding:12px 16px 0;";
titleEl.innerHTML = `
<div style="font-size:13px;font-weight:700;color:var(--muted);
text-transform:uppercase;letter-spacing:.06em;">
${escHtml(data.period_label)}
</div>
`;
screen.appendChild(titleEl);
// KPI grid — 2 колонки
const kpiGrid = document.createElement("div");
kpiGrid.style.cssText = "display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:12px 16px;";
const noRevMsg = data.revenue_client === 0 ? "нет цен кухни" : null;
kpiGrid.innerHTML = [
_kpiCard("📐", "Замеры", `${data.meas_done} / ${data.meas_total}`,
data.meas_total ? `${data.meas_done} выполнено` : "нет замеров"),
_kpiCard("🔨", "Сборки", `${data.asm_done} / ${data.asm_total}`,
data.asm_active > 0 ? `${data.asm_active} в работе` : ""),
_kpiCard("💰", "Выручка", data.revenue_client ? fmt(data.revenue_client) : "—",
noRevMsg || (data.asm_done > 0 ? `${data.asm_done} сдано` : "нет завершённых"),
data.revenue_client > 0 ? "var(--accent)" : undefined),
_kpiCard("👷", "Выплаты мастерам", data.payout_assembler ? fmt(data.payout_assembler) : "—",
data.payout_assembler > 0 ? `от ${fmt(data.revenue_client)}` : noRevMsg),
_kpiCard("📊", "Маржа", data.margin ? fmt(data.margin) : "—",
data.revenue_client > 0
? `${Math.round(data.margin / data.revenue_client * 100)}% от выручки`
: noRevMsg,
data.margin > 0 ? "#27AE60" : data.margin < 0 ? "#C0392B" : undefined),
_kpiCard("🧾", "Доп работы", data.extras_total ? fmt(data.extras_total) : "—",
data.extras_count > 0 ? `${data.extras_count} позиций одобрено` : "нет доп работ"),
].join("");
screen.appendChild(kpiGrid);
// Детали сборок с финансами
const asmList = data.asm_list || [];
if (asmList.length) {
const headEl = document.createElement("div");
headEl.className = "section-head";
headEl.style.cssText = "margin-top:8px;";
headEl.innerHTML = `<span class="label">Сборки с финансами <span class="count">· ${asmList.length}</span></span>`;
screen.appendChild(headEl);
for (const asm of asmList) {
const card = document.createElement("div");
card.style.cssText = "margin:0 16px 8px;padding:10px 12px;background:var(--surface);" +
"border:1px solid var(--border);border-radius:12px;cursor:pointer;";
card.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;">
<div style="flex:1;min-width:0;">
<div style="font-size:13px;font-weight:700;color:var(--ink);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
${escHtml(asm.client_name || "Клиент")}
</div>
<div style="font-size:11px;color:var(--muted);margin-top:1px;">
${escHtml(fmtDate(asm.completed_at))}
${asm.address ? " · " + escHtml(asm.address.slice(0, 35)) + (asm.address.length > 35 ? "…" : "") : ""}
</div>
</div>
<div style="text-align:right;flex-shrink:0;">
<div style="font-size:13px;font-weight:700;color:var(--accent);">${fmt(asm.client_pay)}</div>
<div style="font-size:11px;color:var(--muted);">мастеру ${fmt(asm.asm_pay)}</div>
</div>
</div>
<div style="margin-top:6px;display:flex;gap:12px;">
<span style="font-size:11px;color:var(--muted);">
Кухня: <strong style="color:var(--ink);">${fmt(asm.kitchen_price)}</strong>
</span>
<span style="font-size:11px;color:#27AE60;font-weight:600;">
Маржа: ${fmt(asm.margin)}
</span>
</div>
`;
card.addEventListener("click", () => {
haptic && haptic("impact");
location.hash = `#/c/assembly/${encodeURIComponent(asm.id)}`;
});
screen.appendChild(card);
}
} else if (data.asm_done === 0) {
const emptyEl = document.createElement("div");
emptyEl.style.cssText = "margin:16px;text-align:center;color:var(--muted);font-size:13px;";
emptyEl.textContent = "Завершённых сборок с ценой кухни за этот период нет";
screen.appendChild(emptyEl);
}
// Итоговая строка (если есть данные)
if (data.revenue_client > 0) {
const totalEl = document.createElement("div");
totalEl.style.cssText = "margin:8px 16px 0;padding:12px;background:var(--surface);" +
"border:2px solid var(--accent);border-radius:12px;";
totalEl.innerHTML = `
<div style="font-size:11px;color:var(--muted);text-transform:uppercase;
letter-spacing:.05em;font-weight:600;margin-bottom:6px;">Итого за период</div>
<div style="display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;">
<div>
<div style="font-size:11px;color:var(--muted);">Выручка</div>
<div style="font-size:17px;font-weight:700;color:var(--accent);">
${fmt(data.revenue_client)}
</div>
</div>
<div>
<div style="font-size:11px;color:var(--muted);">Мастерам</div>
<div style="font-size:17px;font-weight:700;color:var(--ink);">
${fmt(data.payout_assembler)}
</div>
</div>
<div>
<div style="font-size:11px;color:var(--muted);">Маржа</div>
<div style="font-size:17px;font-weight:700;color:#27AE60;">
${fmt(data.margin)}
</div>
</div>
${data.extras_total > 0 ? `
<div>
<div style="font-size:11px;color:var(--muted);">Доп работы</div>
<div style="font-size:17px;font-weight:700;color:var(--ink);">
${fmt(data.extras_total)}
</div>
</div>` : ""}
</div>
`;
screen.appendChild(totalEl);
}
}
return { mount };
})();

215
miniapp/assets/invoice.js Normal file
View File

@ -0,0 +1,215 @@
/* InvoiceScreen #/master/invoice/:measurementId
Rooms: chip grid (3 cols, mono-add) + list with rename/remove */
const InvoiceScreen = (function () {
'use strict';
const ROOM_GROUPS = [
['Гостиная','Спальня','Детская'],
['Кабинет','Кухня','Кухня-гостиная'],
['Ванная','Санузел','Прихожая'],
['Коридор','Кладовая','Балкон'],
['Лоджия','Столовая','Доп. помещение'],
];
const ALL_CHIPS = ROOM_GROUPS.flat();
function escHtml(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function el(html){const t=document.createElement('template');t.innerHTML=html.trim();return t.content.firstChild;}
function fmtMoney(n){return Math.round(n||0).toLocaleString('ru-RU')+' ₽';}
const FEE_BASE=2500,FEE_EXTRA=1000;
function calcTotal(rooms){if(!rooms.length)return 0;return FEE_BASE+Math.max(0,rooms.length-1)*FEE_EXTRA;}
async function _api(path,body){
const ctrl=new AbortController();const t=setTimeout(()=>ctrl.abort(),30000);
try{
const res=await fetch(BACKEND_URL+'/api/'+path,{method:'POST',signal:ctrl.signal,
headers:{'Content-Type':'application/json'},
body:JSON.stringify(Object.assign({
initData:(typeof Platform!=='undefined'?Platform.initData:''),
initDataUnsafe:(typeof Platform!=='undefined'?Platform.initDataUnsafe:null),
},body))});
if(!res.ok)throw new Error('HTTP '+res.status);return await res.json();
}catch(e){if(e.name==='AbortError')throw new Error('Timeout');throw e;}
finally{clearTimeout(t);}
}
async function mount(container,measurementId){
container.innerHTML='';
document.body.classList.remove('has-bottom-nav');
const nav=document.getElementById('bottom-nav');if(nav)nav.remove();
const icons=window.ICONS||{};
const header=el('<header class="podbor-header"><button class="podbor-back">'+(icons.arrow_left||'')+'</button><div class="podbor-title">Счёт на оплату</div><div style="width:28px"></div></header>');
header.querySelector('.podbor-back').addEventListener('click',()=>{if(typeof haptic!=='undefined')haptic('impact');history.back();});
const screen=el('<div class="podbor-screen"></div>');
container.appendChild(header);container.appendChild(screen);
screen.innerHTML='<div class="loader-inline"><div class="spinner"></div></div>';
try{
const data=await _api('measurement_detail',{measurement_id:measurementId});
if(data.error)throw new Error(data.error);
_renderForm(screen,data,measurementId);
}catch(e){screen.innerHTML='<div class="error" style="margin:16px;">Ошибка: '+escHtml(e.message)+'</div>';}
}
function _renderForm(screen,meas,measurementId){
screen.innerHTML='';
const existingFee=parseFloat(meas.measurement_fee)||0;
const rooms=[];let nextId=0;
// Client card
screen.appendChild(el(
'<div style="margin:12px 16px;padding:14px;background:var(--surface);border:1px solid var(--border);border-radius:12px;">'+
'<div style="font-size:12px;color:var(--muted);margin-bottom:6px;">Клиент</div>'+
'<div style="font-size:14px;font-weight:600;">'+escHtml(meas.client_name||'—')+'</div>'+
(meas.client_phone?'<div style="font-size:12px;color:var(--muted);margin-top:2px;">'+escHtml(meas.client_phone)+'</div>':'')+
(meas.address?'<div style="font-size:12px;color:var(--ink);margin-top:6px;">📍 '+escHtml(meas.address)+'</div>':'')+
'</div>'
));
// Already invoiced warning
if(existingFee>0){
const eb=el('<div style="margin:0 16px 12px;padding:12px 14px;background:#fff8e1;border:1px solid #ffe082;border-radius:12px;">'+
'<div style="font-size:12px;color:#8a6d00;font-weight:600;margin-bottom:4px;">⚠ Счёт уже выставлен</div>'+
'<div style="font-size:18px;font-weight:800;color:#8a6d00;">'+fmtMoney(existingFee)+'</div>'+
'<button id="reviseBtn" style="margin-top:8px;padding:5px 12px;font-size:12px;background:none;border:1px solid #8a6d00;border-radius:8px;color:#8a6d00;cursor:pointer;">Пересмотреть</button></div>');
eb.querySelector('#reviseBtn').addEventListener('click',()=>{eb.remove();if(typeof haptic!=='undefined')haptic('impact');});
screen.appendChild(eb);
}
// Rooms list
const listWrap=el('<div style="margin:0 16px 8px;"></div>');
screen.appendChild(listWrap);
// Total bar
const totalWrap=el('<div style="margin:0 16px 10px;padding:10px 14px;display:flex;justify-content:space-between;align-items:center;background:var(--surface);border:1px solid var(--border);border-radius:12px;"><div style="font-size:13px;color:var(--muted);">Итого</div><div id="totalAmt" style="font-size:22px;font-weight:800;color:var(--accent);">0 ₽</div></div>');
screen.appendChild(totalWrap);
const totalEl=totalWrap.querySelector('#totalAmt');
// Issue button
const bw=el('<div style="padding:8px 16px 12px;"><button id="issueBtn" style="width:100%;padding:14px;background:var(--accent);color:#fff;border:none;border-radius:12px;font-size:16px;font-weight:700;cursor:pointer;opacity:0.45;" disabled>Выставить счёт</button></div>');
const rb=el('<div style="padding:0 16px 32px;"></div>');
const issueBtn=bw.querySelector('#issueBtn');
// ── CHIP GRID ──────────────────────────────────────────────────────────
const chipLabel=el('<div style="padding:4px 16px 6px;font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em;">Выберите помещения</div>');
const chipGrid=el('<div style="margin:0 16px 16px;display:grid;grid-template-columns:repeat(3,1fr);gap:6px;"></div>');
function updateTotal(){
totalEl.textContent=rooms.length?fmtMoney(calcTotal(rooms)):'0 ₽';
issueBtn.disabled=!rooms.length;
issueBtn.style.opacity=rooms.length?'1':'0.45';
}
function removeRoom(id){
const idx=rooms.findIndex(r=>r.id===id);if(idx===-1)return;
rooms.splice(idx,1);
const card=listWrap.querySelector('[data-room-id="'+id+'"]');
if(card)card.remove();
updateTotal();
}
function addRoomCard(room){
const isBase=rooms.length===1&&rooms[0].id===room.id;
const price=isBase?FEE_BASE:FEE_EXTRA;
const card=el(
'<div data-room-id="'+room.id+'" style="display:flex;align-items:center;gap:8px;padding:10px 12px;margin-bottom:6px;'+
'background:var(--surface);border:1px solid var(--border);border-radius:10px;">'+
'<input type="text" value="'+escHtml(room.name)+'" style="flex:1;border:none;background:transparent;font-size:14px;color:var(--ink);outline:none;min-width:0;"/>'+
'<span style="font-size:11px;color:var(--muted);white-space:nowrap;margin-right:4px;">'+fmtMoney(price)+'</span>'+
'<button style="width:26px;height:26px;border-radius:50%;border:1px solid var(--border);background:none;color:var(--muted);font-size:16px;cursor:pointer;flex-shrink:0;">×</button>'+
'</div>'
);
card.querySelector('input').addEventListener('input',e=>{room.name=e.target.value;});
card.querySelector('button').addEventListener('click',()=>{
if(typeof haptic!=='undefined')haptic('selection');
removeRoom(room.id);
// re-render first card price if needed
_refreshPriceLabels();
});
listWrap.appendChild(card);
}
function _refreshPriceLabels(){
const cards=listWrap.querySelectorAll('[data-room-id]');
cards.forEach((card,i)=>{
const span=card.querySelector('span');
if(span)span.textContent=fmtMoney(i===0?FEE_BASE:FEE_EXTRA);
});
}
// Chip click: count how many rooms have this base name, auto-number
function addFromChip(chipName){
const existing=rooms.filter(r=>r.name===chipName||r.name.startsWith(chipName+' ')).length;
let name=chipName;
if(existing===1)name=chipName+' 2';
else if(existing>1)name=chipName+' '+(existing+1);
const room={id:nextId++,name};
rooms.push(room);
addRoomCard(room);
updateTotal();
}
ALL_CHIPS.forEach(chipName=>{
const chip=el(
'<button style="padding:8px 4px;border:1px solid var(--border);border-radius:8px;background:var(--surface);'+
'color:var(--ink);font-size:12px;font-weight:500;cursor:pointer;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">'+
escHtml(chipName)+'</button>'
);
chip.addEventListener('click',()=>{
if(typeof haptic!=='undefined')haptic('selection');
addFromChip(chipName);
// flash chip
chip.style.background='var(--accent)';chip.style.color='#fff';chip.style.borderColor='var(--accent)';
setTimeout(()=>{chip.style.background='';chip.style.color='';chip.style.borderColor='';},180);
});
chipGrid.appendChild(chip);
});
// Pre-fill if rooms_count set
const existingCount=parseInt(meas.rooms_count)||0;
for(let i=0;i<existingCount;i++)addFromChip(i===0?'Основное помещение':'Помещение '+(i+1));
screen.appendChild(chipLabel);
screen.appendChild(chipGrid);
screen.appendChild(bw);
screen.appendChild(rb);
issueBtn.addEventListener('click',()=>{
if(typeof haptic!=='undefined')haptic('impact');
issueBtn.disabled=true;issueBtn.textContent='Создаём счёт…';
const names=rooms.map((r,i)=>r.name||(i===0?'Основное помещение':'Помещение '+(i+1)));
_api('invoice_create',{measurement_id:measurementId,rooms_count:rooms.length,rooms_names:names})
.then(data=>{
if(data.error)throw new Error(data.error);
_renderResult(rb,data);issueBtn.style.display='none';
chipLabel.style.display='none';chipGrid.style.display='none';
})
.catch(e=>{
rb.innerHTML='<div class="error">Ошибка: '+escHtml(e.message)+'</div>';
issueBtn.disabled=false;issueBtn.textContent='Выставить счёт';
});
});
updateTotal();
}
function _renderResult(container,data){
container.innerHTML='';
const qr=data.qr_b64
?'<div style="text-align:center;margin-top:14px;"><div style="font-size:11px;color:var(--muted);margin-bottom:6px;">QR для оплаты (СБП)</div><img src="data:image/png;base64,'+escHtml(data.qr_b64)+'" alt="QR" style="width:180px;height:180px;border-radius:8px;"></div>'
:'';
container.appendChild(el(
'<div style="padding:16px;background:var(--surface);border:2px solid var(--accent);border-radius:16px;">'+
'<div style="font-size:13px;font-weight:700;color:var(--accent);margin-bottom:12px;">✅ Счёт выставлен</div>'+
'<div style="font-size:22px;font-weight:800;margin-bottom:12px;">'+fmtMoney(data.amount)+'</div>'+
'<div style="font-size:12px;color:var(--muted);line-height:1.8;">'+
'<div><b>Получатель:</b> '+escHtml(data.ip_name||'—')+'</div>'+
'<div><b>ИНН:</b> '+escHtml(data.ip_inn||'—')+'</div>'+
'<div><b>Банк:</b> '+escHtml(data.bank_name||'—')+'</div>'+
'<div><b>БИК:</b> '+escHtml(data.bic||'—')+'</div>'+
'<div><b>Р/С:</b> '+escHtml(data.rs||'—')+'</div>'+
(data.ks?'<div><b>К/С:</b> '+escHtml(data.ks)+'</div>':'')+
'<div style="margin-top:6px;"><b>Назначение:</b> '+escHtml(data.purpose||'—')+'</div>'+
'</div>'+qr+'</div>'
));
}
return{mount};
})();

View File

@ -105,20 +105,44 @@ const MeScreen = (function () {
container.appendChild(screen);
}
const EQUIPMENT_ITEMS = [
{ key: "tablet", label: "Планшет с ПО для замеров", icon: "📱" },
{ key: "laser_tape", label: "Лазерная рулетка (интеграция с ПО)", icon: "📡" },
{ key: "angle_meter", label: "Угломер", icon: "📐" },
{ key: "tape", label: "Обычная рулетка", icon: "📏" },
{ key: "laser_level", label: "Лазерный уровень", icon: "🔴" },
];
function renderStaffMe(container, me) {
const u = me.user || {};
const caps = me.capabilities || {};
const eqList = me.equipment || [];
const eqOk = me.equipment_ok !== false;
const chips = [
caps.measurer && roleChip("замерщик", "blue"),
caps.assembler && roleChip("сборщик", "green"),
].filter(Boolean).join("");
// Бейдж укомплектованности
const eqBadge = caps.measurer ? `
<div style="margin:0 16px 12px;padding:10px 14px;border-radius:10px;
background:${eqOk ? "#27AE6015" : "#E74C3C15"};
border:1px solid ${eqOk ? "#27AE60" : "#E74C3C"};">
<div style="font-size:13px;font-weight:700;color:${eqOk ? "#27AE60" : "#E74C3C"};">
${eqOk ? "✅ Укомплектован — допуск к замерам открыт" : "⚠️ Не укомплектован — допуск ограничен"}
</div>
${!eqOk ? `<div style="font-size:12px;color:var(--muted);margin-top:4px;">
Заполните список оборудования ниже
</div>` : ""}
</div>` : "";
const screen = document.createElement("div");
screen.className = "podbor-screen";
screen.innerHTML = `
${avatarBlock(u.avatar_initial || "?", u.full_name || "Сотрудник", "")}
<div style="padding:4px 16px 12px;">${chips}</div>
<div style="padding:4px 16px 10px;">${chips}</div>
${eqBadge}
<div class="block" style="margin:0 16px;">
<div class="block-head">Мои задачи</div>
@ -128,14 +152,84 @@ const MeScreen = (function () {
${caps.assembler ? `<button class="btn-secondary" data-href="#/assembly">🔨 Мои сборки</button>` : ""}
</div>
</div>
${caps.measurer ? `
<div class="block" style="margin:12px 16px 0;" id="equipment-block">
<div class="block-head">Оборудование</div>
<div style="font-size:12px;color:var(--muted);margin-bottom:10px;">
Все 5 пунктов обязательны для допуска к замерам
</div>
<div id="eq-checklist"></div>
<button id="eq-save-btn" class="btn-primary"
style="width:100%;padding:12px;font-size:14px;margin-top:12px;">
Сохранить оборудование
</button>
<div id="eq-status" style="font-size:12px;text-align:center;margin-top:8px;color:var(--muted);"></div>
</div>` : ""}
`;
screen.querySelectorAll("[data-href]").forEach(btn => {
btn.addEventListener("click", () => {
haptic && haptic("impact");
location.hash = btn.dataset.href;
});
});
container.appendChild(screen);
// Чеклист оборудования
if (caps.measurer) {
const checklist = screen.querySelector("#eq-checklist");
EQUIPMENT_ITEMS.forEach(item => {
const checked = eqList.includes(item.key);
const row = document.createElement("label");
row.style.cssText = `display:flex;align-items:center;gap:10px;padding:10px 0;
border-bottom:1px solid var(--border);cursor:pointer;`;
row.innerHTML = `
<input type="checkbox" data-key="${item.key}" ${checked ? "checked" : ""}
style="width:18px;height:18px;accent-color:var(--accent);flex-shrink:0;">
<span style="font-size:14px;">${item.icon}</span>
<span style="font-size:13px;color:var(--ink);">${escHtml(item.label)}</span>
`;
checklist.appendChild(row);
});
// Кнопка сохранить
const saveBtn = screen.querySelector("#eq-save-btn");
const statusEl = screen.querySelector("#eq-status");
saveBtn.addEventListener("click", async () => {
haptic && haptic("impact");
saveBtn.disabled = true;
saveBtn.textContent = "Сохраняем…";
const selected = Array.from(checklist.querySelectorAll("input[data-key]:checked"))
.map(cb => cb.dataset.key);
try {
const res = await _fetchWithTimeout(`${BACKEND_URL}/api/equipment_save`, {
initData: typeof Platform !== "undefined" ? Platform.initData : (window.tg?.initData || ""),
initDataUnsafe: typeof Platform !== "undefined" ? Platform.initDataUnsafe : null,
equipment: selected,
});
if (res.ok) {
statusEl.style.color = res.equipment_ok ? "#27AE60" : "#E74C3C";
statusEl.textContent = res.equipment_ok
? "✅ Сохранено. Допуск открыт."
: "⚠️ Сохранено. Заполните все пункты для допуска.";
// Обновить бейдж
const badge = container.querySelector("#equipment-block")?.closest(".podbor-screen")?.querySelector("[style*='Укомплектован']") ||
container.querySelector("[style*='допуск']");
} else {
statusEl.style.color = "#E74C3C";
statusEl.textContent = "Ошибка: " + (res.error || "неизвестно");
}
} catch (e) {
statusEl.style.color = "#E74C3C";
statusEl.textContent = "Ошибка: " + e.message;
} finally {
saveBtn.disabled = false;
saveBtn.textContent = "Сохранить оборудование";
}
});
}
}
function renderClientMe(container, me) {

View File

@ -0,0 +1,198 @@
/* ============================================================
MeasurerDashboard личная статистика замерщика
#/master/measurer-stats
============================================================ */
const MeasurerDashboard = (function () {
"use strict";
function escHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function el(html) {
const t = document.createElement("template");
t.innerHTML = html.trim();
return t.content.firstChild;
}
function fmtMoney(n) {
return Math.round(n || 0).toLocaleString("ru-RU") + " ₽";
}
function fmtMonth(ym) {
try {
const d = new Date(ym + "-01");
return d.toLocaleDateString("ru-RU", { month: "long", year: "numeric" });
} catch { return ym; }
}
async function _api(path, body = {}) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 30000);
try {
const res = await fetch(`${BACKEND_URL}/api/${path}`, {
method: "POST", signal: ctrl.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
initData: (typeof Platform !== "undefined" ? Platform.initData : (window.tg?.initData || "")),
initDataUnsafe: (typeof Platform !== "undefined" ? Platform.initDataUnsafe : null),
...body,
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (e) {
if (e.name === "AbortError") throw new Error("Таймаут");
throw e;
} finally { clearTimeout(t); }
}
async function mount(container) {
container.innerHTML = "";
document.body.classList.remove("has-bottom-nav");
document.getElementById("bottom-nav")?.remove();
const h = el(`
<header class="podbor-header">
<button class="podbor-back">${(window.ICONS || {}).arrow_left || ""}</button>
<div class="podbor-title">Мои замеры</div>
<button id="reloadBtn" style="background:none;border:none;font-size:18px;cursor:pointer;padding:4px 8px;"></button>
</header>
`);
h.querySelector(".podbor-back").addEventListener("click", () => { haptic && haptic("impact"); history.back(); });
const yearEl = el(`
<div style="padding:0 16px 8px;display:flex;align-items:center;gap:10px;">
<label style="font-size:12px;color:var(--muted);">Год:</label>
<select id="yearSelect" style="padding:5px 10px;border:1px solid var(--border);border-radius:8px;
background:var(--surface);color:var(--ink);font-size:13px;">
<option value="2026" selected>2026</option>
<option value="2025">2025</option>
<option value="">Все</option>
</select>
</div>
`);
const screen = el(`<div class="podbor-screen"></div>`);
container.appendChild(h);
container.appendChild(yearEl);
container.appendChild(screen);
const load = async (year) => {
screen.innerHTML = `<div class="loader-inline"><div class="spinner"></div></div>`;
try {
const data = await _api("measurer_earnings", { year });
if (data.error) {
screen.innerHTML = `<div class="error" style="margin:16px;">${escHtml(data.error)}</div>`;
return;
}
_render(screen, data);
} catch (e) {
screen.innerHTML = `<div class="error" style="margin:16px;">Ошибка: ${escHtml(e.message)}</div>`;
}
};
yearEl.querySelector("#yearSelect").addEventListener("change", function () { load(this.value); });
h.querySelector("#reloadBtn").addEventListener("click", () => { haptic && haptic("impact"); load(yearEl.querySelector("#yearSelect").value); });
load("2026");
}
function _render(screen, data) {
screen.innerHTML = "";
const months = data.months || {};
const monthKeys = Object.keys(months).sort().reverse();
const now = new Date();
const curYM = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
const prevD = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const prevYM = `${prevD.getFullYear()}-${String(prevD.getMonth() + 1).padStart(2, "0")}`;
const curMonth = months[curYM] || null;
const prevMonth = months[prevYM] || null;
// Hero
screen.appendChild(el(`
<div style="margin:0 16px 16px;padding:20px;background:var(--accent);border-radius:16px;color:#fff;">
<div style="font-size:11px;opacity:.75;margin-bottom:4px;">Всего за период</div>
<div style="font-size:28px;font-weight:800;">${escHtml(fmtMoney(data.total_amount))}</div>
<div style="font-size:12px;opacity:.75;margin-top:4px;">
${escHtml(String(data.total_measurements))} замеров
${data.total_amount > 0 ? ` · ${escHtml(fmtMoney(data.total_amount / data.total_measurements))} в среднем` : ""}
</div>
</div>
`));
// Мини-карточки
if (curMonth || prevMonth) {
const row = el(`<div style="display:flex;gap:10px;margin:0 16px 16px;"></div>`);
const mini = (label, m) => !m
? el(`<div style="flex:1;padding:14px;background:var(--surface);border:1px solid var(--border);border-radius:12px;opacity:.4;">
<div style="font-size:10px;color:var(--muted);">${escHtml(label)}</div>
<div style="font-size:15px;font-weight:700;margin-top:4px;"></div></div>`)
: el(`<div style="flex:1;padding:14px;background:var(--surface);border:1px solid var(--border);border-radius:12px;">
<div style="font-size:10px;color:var(--muted);">${escHtml(label)}</div>
<div style="font-size:15px;font-weight:700;color:var(--accent);margin-top:4px;">${escHtml(fmtMoney(m.total_amount))}</div>
<div style="font-size:10px;color:var(--muted);margin-top:2px;">${m.measurements} замеров · ${m.paid} оплачено</div></div>`);
row.appendChild(mini("Текущий месяц", curMonth));
row.appendChild(mini("Прошлый месяц", prevMonth));
screen.appendChild(row);
}
if (!monthKeys.length) {
screen.appendChild(el(`
<div style="text-align:center;padding:40px 16px;color:var(--muted);">
<div style="font-size:32px;margin-bottom:12px;">📐</div>
<div style="font-size:14px;color:var(--ink);">Замеров за этот период нет</div>
<div style="font-size:12px;margin-top:8px;">Данные появятся после выставления счёта за замер</div>
</div>
`));
return;
}
// Таблица по месяцам
screen.appendChild(el(`<div class="section-head"><span class="label">📅 По месяцам</span></div>`));
const maxAmt = Math.max(...monthKeys.map(k => months[k].total_amount), 1);
monthKeys.forEach(ym => {
const m = months[ym];
const pct = Math.round((m.total_amount / maxAmt) * 100);
const isCur = ym === curYM;
screen.appendChild(el(`
<div style="margin:4px 16px;padding:12px 14px;background:var(--surface);
border:1px solid ${isCur ? "var(--accent)" : "var(--border)"};border-radius:12px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">
<div>
<span style="font-size:13px;font-weight:600;color:var(--ink);">${escHtml(fmtMonth(ym))}</span>
${isCur ? `<span style="font-size:10px;background:var(--accent);color:#fff;padding:1px 6px;border-radius:10px;margin-left:6px;">сейчас</span>` : ""}
</div>
<div style="text-align:right;">
<div style="font-size:15px;font-weight:700;color:${m.total_amount > 0 ? "var(--accent)" : "var(--muted)"};">
${m.total_amount > 0 ? escHtml(fmtMoney(m.total_amount)) : "—"}
</div>
<div style="font-size:10px;color:var(--muted);">
${m.measurements} замеров · ${m.paid} со счётом
</div>
</div>
</div>
${m.total_amount > 0 ? `
<div style="height:3px;background:var(--border);border-radius:2px;">
<div style="height:3px;background:var(--accent);border-radius:2px;width:${pct}%;"></div>
</div>` : ""}
</div>
`));
});
screen.appendChild(el(`
<div style="margin:12px 16px;padding:10px 14px;background:var(--surface-2,var(--surface));
border:1px solid var(--border);border-radius:10px;
font-size:12px;color:var(--muted);line-height:1.5;">
💡 Сумма учитывается когда вы выставляете счёт за замер через кнопку «💳 Выставить счёт» в карточке клиента
</div>
`));
screen.appendChild(el(`<div style="height:32px;"></div>`));
}
return { mount };
})();

View File

@ -388,8 +388,26 @@ const MeasurementRequest = (function () {
sel.innerHTML =
`<option value="">— Не назначать —</option>` +
measurers.map(m =>
`<option value="${m.tg_id}">${escHtml(m.full_name || "?")}${m.tg_username ? " (@" + m.tg_username + ")" : ""}</option>`
`<option value="${m.tg_id}" data-eq-ok="${m.equipment_ok !== false ? "1" : "0"}">${
escHtml(m.full_name || "?")}${m.tg_username ? " (@" + m.tg_username + ")" : ""}${
m.equipment_ok === false ? " ⚠️" : ""}</option>`
).join("");
// Алерт при выборе неукомплектованного замерщика
sel.addEventListener("change", () => {
const opt = sel.options[sel.selectedIndex];
if (opt && opt.dataset.eqOk === "0") {
if (hint) {
hint.style.color = "#E74C3C";
hint.textContent = "⚠️ Замерщик не укомплектован — не все инструменты заполнены в профиле. Рекомендуется выбрать другого или попросить заполнить профиль.";
}
} else {
if (hint) {
hint.style.color = "";
hint.textContent = "Замерщик получит уведомление в Telegram";
}
}
});
}
function _renderManagerSelect() {

View File

@ -386,13 +386,91 @@ const StaffClients = (function () {
_openScheduleOverlay(m.id, "measurement", c.client_name, () => mount(container));
});
}
// Кнопка «💳 Выставить счёт» — только для замерщика (is_measurer)
if (data.is_measurer) {
const invoiceBtn = document.createElement("button");
invoiceBtn.className = "btn-secondary";
invoiceBtn.style.cssText = "width:100%;padding:10px;font-size:13px;margin-top:8px;";
invoiceBtn.textContent = "💳 Выставить счёт";
invoiceBtn.addEventListener("click", () => {
haptic && haptic("impact");
location.hash = `#/master/invoice/${m.id}`;
});
mCard.appendChild(invoiceBtn);
}
screen.appendChild(mCard);
});
}
// Подбор техники — загружается для первого замера с podbor_lead_id
const measWithPodbor = c.measurements.find(m => m.podbor_lead_id);
const firstMeasId = c.measurements[0]?.id;
const pobdorMeasId = measWithPodbor?.id || firstMeasId;
if (pobdorMeasId) {
const pobdorSection = el(`
<div style="margin-top:16px;">
<div class="section-head"><span class="label">🛒 Подбор техники</span></div>
<div id="podbor-content" style="margin:4px 16px;">
<div style="font-size:12px;color:var(--muted);padding:8px 0;">Загружаем</div>
</div>
</div>
`);
screen.appendChild(pobdorSection);
// Асинхронная загрузка подбора
_loadPodbor(pobdorMeasId, pobdorSection.querySelector("#podbor-content"));
}
screen.appendChild(el(`<div style="height:32px;"></div>`));
}
async function _loadPodbor(measurementId, container) {
try {
const res = await _api("assembler_client_podbor", { measurement_id: measurementId });
if (!res.ok || !res.has_podbor) {
container.innerHTML = `<div style="font-size:12px;color:var(--muted);padding:8px 0;">Подбор техники не назначен</div>`;
return;
}
const items = res.items || [];
if (!items.length) {
container.innerHTML = `<div style="font-size:12px;color:var(--muted);padding:8px 0;">Варианты ещё не добавлены</div>`;
return;
}
const STATUS_LABEL = { draft: "Черновик", sent: "Отправлен", reviewed: "Просмотрен", done: "Выбор сделан" };
container.innerHTML = `
<div style="font-size:11px;color:var(--muted);margin-bottom:8px;">
Статус: <strong>${escHtml(STATUS_LABEL[res.proposal_status] || res.proposal_status || "—")}</strong>
· ${items.length} позиций
</div>
`;
items.forEach(item => {
const card = el(`
<div style="display:flex;align-items:center;gap:10px;padding:8px 0;
border-bottom:1px solid var(--border);">
${item.image_url ? `<img src="${escHtml(item.image_url)}" alt=""
style="width:40px;height:40px;object-fit:cover;border-radius:6px;flex-shrink:0;border:1px solid var(--border);">` :
`<div style="width:40px;height:40px;background:var(--border);border-radius:6px;flex-shrink:0;"></div>`}
<div style="flex:1;min-width:0;">
<div style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;">${escHtml(item.category)}</div>
<div style="font-size:13px;font-weight:500;color:var(--ink);margin-top:1px;">${escHtml(item.name)}</div>
${item.price ? `<div style="font-size:12px;color:var(--accent);margin-top:1px;">${Number(item.price).toLocaleString("ru-RU")} ₽</div>` : ""}
</div>
${item.voted ? `<div style="font-size:16px;">✅</div>` : ""}
</div>
`);
container.appendChild(card);
});
} catch (e) {
container.innerHTML = `<div style="font-size:12px;color:var(--muted);padding:8px 0;">Ошибка загрузки подбора</div>`;
}
}
/* ── Оверлей выбора даты/времени ───────────────────────────── */
function _openScheduleOverlay(itemId, type, clientName, onSuccess) {
document.getElementById("schedule-overlay")?.remove();

View File

@ -0,0 +1,183 @@
/* ============================================================
Обзор команды #/admin/staff
Доступен: менеджер.
============================================================ */
const StaffRoster = (function () {
function escHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
async function _api(path, body = {}) {
const res = await fetch(`${BACKEND_URL}/api/${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null, ...body }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
const ROLE_LABELS = {
assembler: "Сборщик",
measurer: "Замерщик",
expeditor: "Экспедитор",
};
function mount(container) {
container.innerHTML = "";
document.body.classList.remove("has-bottom-nav");
const oldNav = document.getElementById("bottom-nav");
if (oldNav) oldNav.remove();
const h = document.createElement("header");
h.className = "podbor-header";
h.innerHTML = `
<button class="podbor-back">${(window.ICONS || {}).arrow_left || ""}</button>
<div class="podbor-title">Команда</div>
<div style="width:36px"></div>
`;
h.querySelector(".podbor-back").addEventListener("click", () => {
haptic && haptic("impact");
history.back();
});
container.appendChild(h);
const screen = document.createElement("div");
screen.className = "podbor-screen";
screen.style.padding = "0 0 32px";
screen.innerHTML = `<div class="loader-inline"><div class="spinner"></div></div>`;
container.appendChild(screen);
_api("staff_roster").then(data => {
if (data.error) {
screen.innerHTML = `<div class="error" style="margin:16px;">${escHtml(data.error)}</div>`;
return;
}
const staff = data.staff || [];
if (!staff.length) {
screen.innerHTML = `<div style="margin:32px 16px;text-align:center;color:var(--muted);font-size:14px;">Сотрудников пока нет</div>`;
return;
}
screen.innerHTML = "";
// Разбиваем по ролям для отображения
const groups = [
{ key: "assembler", label: "🔨 Сборщики", items: staff.filter(s => s.roles.includes("assembler")) },
{ key: "measurer", label: "📐 Замерщики", items: staff.filter(s => s.roles.includes("measurer") && !s.roles.includes("assembler")) },
{ key: "expeditor", label: "📦 Экспедиторы", items: staff.filter(s => s.roles.includes("expeditor") && !s.roles.includes("assembler") && !s.roles.includes("measurer")) },
].filter(g => g.items.length);
for (const group of groups) {
const headEl = document.createElement("div");
headEl.className = "section-head";
headEl.style.marginTop = "16px";
headEl.innerHTML = `<span class="label">${group.label} <span class="count">· ${group.items.length}</span></span>`;
screen.appendChild(headEl);
for (const person of group.items) {
const card = document.createElement("div");
card.style.cssText = "margin:0 16px 8px;padding:12px;background:var(--surface);border:1px solid var(--border);border-radius:12px;";
// Статус-теги
const tags = [];
if (person.on_probation) tags.push(`<span style="font-size:11px;padding:2px 7px;border-radius:10px;background:#fff3cd;color:#856404;">Испытательный срок</span>`);
if (person.equipment_ok === false) tags.push(`<span style="font-size:11px;padding:2px 7px;border-radius:10px;background:#f8d7da;color:#721c24;">⚠️ Не укомплектован</span>`);
if (person.equipment_ok === true) tags.push(`<span style="font-size:11px;padding:2px 7px;border-radius:10px;background:#d4edda;color:#155724;">✅ Оборудование OK</span>`);
// Нагрузка
const loadBits = [];
if (person.active_assemblies > 0)
loadBits.push(`🔨 ${person.active_assemblies} сборок`);
if (person.month_measures > 0)
loadBits.push(`📐 ${person.month_measures} замеров (мес.)`);
const rolesStr = person.roles
.filter(r => r !== "manager" && r !== "client")
.map(r => ROLE_LABELS[r] || r)
.join(", ");
const starsEl = (person.avg_stars != null && typeof FeedbackModule !== "undefined")
? `<div style="margin-top:4px;line-height:1;">${FeedbackModule.starsHtml(person.avg_stars, 13)}
<span style="font-size:11px;color:var(--muted);margin-left:3px;">${Number(person.avg_stars).toFixed(1)}</span></div>`
: "";
card.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;">
<div style="flex:1;min-width:0;">
<div style="font-size:14px;font-weight:700;color:var(--ink);">${escHtml(person.full_name)}</div>
<div style="font-size:12px;color:var(--muted);margin-top:1px;">${escHtml(rolesStr)}${person.tg_username ? ` · @${escHtml(person.tg_username)}` : ""}</div>
${starsEl}
</div>
${loadBits.length ? `<div style="text-align:right;font-size:12px;color:var(--accent);white-space:nowrap;">${loadBits.join("<br>")}</div>` : `<div style="font-size:12px;color:var(--muted);">Свободен</div>`}
</div>
${tags.length ? `<div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:8px;">${tags.join("")}</div>` : ""}
`;
// Клик → действия (toggle испытательного срока)
if (person.roles.includes("assembler")) {
card.style.cursor = "pointer";
card.addEventListener("click", () => _showPersonActions(person, card));
}
screen.appendChild(card);
}
}
}).catch(e => {
screen.innerHTML = `<div class="error" style="margin:16px;">Ошибка: ${escHtml(e.message)}</div>`;
});
}
function _showPersonActions(person, card) {
haptic && haptic("impact");
// Inline toggle испытательного срока прямо на карточке
const existing = card.querySelector(".roster-actions");
if (existing) { existing.remove(); return; }
const actEl = document.createElement("div");
actEl.className = "roster-actions";
actEl.style.cssText = "margin-top:10px;padding-top:10px;border-top:1px solid var(--border);display:flex;gap:8px;flex-wrap:wrap;";
const probBtn = document.createElement("button");
probBtn.className = person.on_probation ? "btn-primary" : "btn-secondary";
probBtn.style.cssText = "font-size:12px;padding:7px 12px;";
probBtn.textContent = person.on_probation ? "✅ Снять испытательный" : "📋 Назначить испытательный";
probBtn.addEventListener("click", async (e) => {
e.stopPropagation();
probBtn.disabled = true;
try {
const res = await _api("assembler_set_probation", {
assembler_tg_id: person.tg_id,
on_probation: !person.on_probation,
});
if (res.ok) {
person.on_probation = !person.on_probation;
actEl.remove();
// Перезапускаем экран
mount(document.getElementById("app"));
}
} catch (e) { probBtn.disabled = false; }
});
actEl.appendChild(probBtn);
if (person.tg_username) {
const msgBtn = document.createElement("a");
msgBtn.href = `https://t.me/${person.tg_username}`;
msgBtn.target = "_blank";
msgBtn.className = "btn-secondary";
msgBtn.style.cssText = "font-size:12px;padding:7px 12px;text-decoration:none;display:inline-block;";
msgBtn.textContent = "✉️ Написать";
actEl.appendChild(msgBtn);
}
card.appendChild(actEl);
}
return { mount };
})();

View File

@ -43,10 +43,10 @@
<script src="assets/clients.js?v=20260518e"></script>
<script src="assets/zamer-picts.js?v=20260516h"></script>
<script src="assets/measurements.js?v=20260518f"></script>
<script src="assets/request.js?v=20260518p"></script>
<script src="assets/request.js?v=20260519f"></script>
<script src="assets/assembly.js?v=20260518f"></script>
<script src="assets/proposals.js?v=20260518f"></script>
<script src="assets/me.js?v=20260518h"></script>
<script src="assets/me.js?v=20260519a"></script>
<script src="assets/inbox.js?v=20260518i"></script>
<script src="assets/cabinet.js?v=20260518j"></script>
<script src="assets/selfmeasure.js?v=20260518k"></script>
@ -56,9 +56,17 @@
<script src="assets/admin_rates.js?v=20260519a"></script>
<script src="assets/assembler_analytics.js?v=20260519a"></script>
<script src="assets/assembler_dashboard.js?v=20260519b"></script>
<script src="assets/staff_clients.js?v=20260519a"></script>
<script src="assets/assembly_detail.js?v=20260519c"></script>
<script src="assets/staff_clients.js?v=20260519c"></script>
<script src="assets/assembly_detail.js?v=20260519o"></script>
<script src="assets/staff_roster.js?v=20260519b"></script>
<script src="assets/client_timeline.js?v=20260519a"></script>
<script src="assets/finance_summary.js?v=20260519a"></script>
<script src="assets/contracts.js?v=20260519a"></script>
<script src="assets/app.js?v=20260519d"></script>
<script src="assets/invoice.js?v=20260521d"></script>
<script src="assets/measurer_dashboard.js?v=20260519a"></script>
<script src="assets/act4.js?v=20260519a"></script>
<script src="assets/feedback.js?v=20260519a"></script>
<script src="assets/expeditor_dashboard.js?v=20260521b"></script>
<script src="assets/app.js?v=20260521c"></script>
</body>
</html>

Binary file not shown.

300
tests/test_manager.py Normal file
View File

@ -0,0 +1,300 @@
"""
Тест кабинета менеджера полная проверка всех API-модулей
с реальной Telegram-аутентификацией.
Запуск: python -X utf8 tests/test_manager.py
"""
import hmac
import hashlib
import json
import time
import sys
import urllib.request
import urllib.parse
import urllib.error
from typing import Any
# ─── Конфигурация ───────────────────────────────────────────────────────────
BOT_TOKEN = "8281503057:AAEXmOepY8quH8E3RqOjFbgn7owV1ngnbGA"
ADMIN_TG_ID = 5937498515
ADMIN_USERNAME = "wasrusgen"
ADMIN_NAME = "Руслан"
BASE_URL = "https://api.wasrusgen1.pro"
# ─── Генерация валидного initData ───────────────────────────────────────────
def make_init_data(tg_id: int, username: str, first_name: str) -> str:
user_obj = json.dumps({
"id": tg_id,
"first_name": first_name,
"username": username,
"language_code": "ru",
"allows_write_to_pm": True,
}, separators=(",", ":"))
fields = {
"auth_date": str(int(time.time())),
"user": user_obj,
}
data_check_string = "\n".join(f"{k}={fields[k]}" for k in sorted(fields))
secret_key = hmac.new(b"WebAppData", BOT_TOKEN.encode(), hashlib.sha256).digest()
sig = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
params = {**fields, "hash": sig}
return urllib.parse.urlencode(params)
INIT_DATA = make_init_data(ADMIN_TG_ID, ADMIN_USERNAME, ADMIN_NAME)
# ─── HTTP-хелперы ───────────────────────────────────────────────────────────
def post(path: str, payload: dict, timeout=15) -> tuple[int, Any]:
url = f"{BASE_URL}{path}"
data = json.dumps(payload).encode()
try:
req = urllib.request.Request(
url, data=data,
headers={"Content-Type": "application/json", "User-Agent": "zov-manager-test/1.0"},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status, json.loads(r.read())
except urllib.error.HTTPError as e:
try:
return e.code, json.loads(e.read())
except Exception:
return e.code, {}
except Exception as e:
return None, {"_net_error": str(e)}
# ─── Отчёт ──────────────────────────────────────────────────────────────────
RESULTS: list[tuple[bool, str, str]] = [] # (ok, test_name, detail)
def ok(name: str, detail: str = ""):
RESULTS.append((True, name, detail))
icon = ""
print(f" {icon} {name}" + (f"{detail}" if detail else ""))
def fail(name: str, detail: str = ""):
RESULTS.append((False, name, detail))
icon = ""
print(f" {icon} {name}" + (f"{detail}" if detail else ""))
def section(title: str):
print(f"\n{''*55}")
print(f" {title}")
print(f"{''*55}")
# ─── Тесты ──────────────────────────────────────────────────────────────────
def test_auth():
section("🔐 Аутентификация")
status, data = post("/api/me", {
"initData": INIT_DATA,
"role": "manager",
})
if status != 200 or "error" in data:
fail("POST /api/me — вход менеджера", f"status={status} error={data.get('error','?')}")
return False
role = data.get("role", "?")
name = data.get("name", "?")
ok("POST /api/me — вход менеджера", f"role={role} name={name}")
if role not in ("manager", "admin"):
fail("Роль должна быть manager или admin", f"получили: {role}")
return False
ok("Роль подтверждена", role)
return True
def test_clients():
section("👥 Модуль Клиенты")
# Список клиентов
status, data = post("/api/clients", {"initData": INIT_DATA})
if status != 200 or "error" in data:
fail("POST /api/clients — список", f"status={status} {data.get('error','')}")
else:
clients = data.get("clients", [])
ok("POST /api/clients — список", f"{len(clients)} клиентов")
# Проверяем структуру первого клиента
if clients:
c = clients[0]
required_fields = ["client_name", "client_phone"]
missing = [f for f in required_fields if f not in c]
if missing:
fail("Структура клиента — обязательные поля", f"отсутствуют: {missing}")
else:
ok("Структура клиента — обязательные поля", "client_name, client_phone ✓")
def test_measurements():
section("📐 Модуль Замеры")
# Входящие заявки
status, data = post("/api/measurement_inbox", {"initData": INIT_DATA})
if status != 200 or "error" in data:
fail("POST /api/measurement_inbox", f"status={status} {data.get('error','')}")
else:
items = data.get("requests", data.get("items", []))
ok("POST /api/measurement_inbox", f"{len(items)} заявок")
# Список замеров
status, data = post("/api/measurements", {"initData": INIT_DATA})
if status != 200 or "error" in data:
fail("POST /api/measurements — список", f"status={status} {data.get('error','')}")
else:
items = data.get("measurements", [])
ok("POST /api/measurements — список", f"{len(items)} замеров")
if items:
m = items[0]
ok("Первый замер — ID", m.get("id", "?")[:8] + "")
# Следующий номер
status, data = post("/api/measurement_next_no", {"initData": INIT_DATA})
if status != 200 or "error" in data:
fail("POST /api/measurement_next_no", f"status={status} {data.get('error','')}")
else:
ok("POST /api/measurement_next_no", f"следующий №{data.get('next_no','?')}")
def test_assembly():
section("🔧 Модуль Сборки")
status, data = post("/api/assembly_list", {"initData": INIT_DATA})
if status != 200 or "error" in data:
fail("POST /api/assembly_list", f"status={status} {data.get('error','')}")
else:
items = data.get("assemblies", [])
ok("POST /api/assembly_list", f"{len(items)} сборок")
if items:
a = items[0]
has_status = "status" in a
has_address = "address" in a
if has_status and has_address:
ok("Структура сборки", f"status={a['status']} address={a['address'][:20]}")
else:
fail("Структура сборки — поля status/address", f"has_status={has_status} has_address={has_address}")
def test_proposals():
section("📋 Модуль Предложения")
status, data = post("/api/proposal_list", {"initData": INIT_DATA})
if status != 200 or "error" in data:
fail("POST /api/proposal_list", f"status={status} {data.get('error','')}")
else:
items = data.get("proposals", data.get("items", []))
ok("POST /api/proposal_list", f"{len(items)} предложений")
def test_manager_pending():
section("📬 Менеджер — входящие задачи")
status, data = post("/api/manager_pending", {"initData": INIT_DATA})
if status != 200 or "error" in data:
fail("POST /api/manager_pending", f"status={status} {data.get('error','')}")
else:
count = len(data.get("items", data.get("pending", [])))
ok("POST /api/manager_pending", f"{count} задач в очереди")
def test_staff_list():
section("👷 Сотрудники")
for role in ["measurer", "assembler"]:
status, data = post("/api/staff_list", {"initData": INIT_DATA, "role": role})
if status != 200 or "error" in data:
fail(f"POST /api/staff_list role={role}", f"status={status} {data.get('error','')}")
else:
staff = data.get("staff", [])
ok(f"POST /api/staff_list role={role}", f"{len(staff)} сотрудников")
def test_shipments_arrivals():
section("📦 Отгрузки и поступления")
for endpoint in ["/api/shipments", "/api/arrivals"]:
status, data = post(endpoint, {"initData": INIT_DATA})
if status != 200 or "error" in data:
fail(f"POST {endpoint}", f"status={status} {data.get('error','')}")
else:
key = "shipments" if "shipments" in endpoint else "arrivals"
items = data.get(key, data.get("items", data.get("rows", [])))
ok(f"POST {endpoint}", f"{len(items)} записей")
def test_no_500_on_bad_input():
section("🛡️ Устойчивость — невалидные данные не дают 500")
bad_cases = [
("/api/measurement_detail", {"initData": INIT_DATA, "measurement_id": "nonexistent-000"}),
("/api/assembly_detail", {"initData": INIT_DATA, "assembly_id": "nonexistent-000"}),
("/api/client_create", {"initData": INIT_DATA, "client_name": "", "client_phone": ""}),
("/api/assembly_create", {"initData": INIT_DATA, "client_name": "", "address": "", "scope_of_work": ""}),
]
for path, payload in bad_cases:
status, data = post(path, payload)
if status == 500:
fail(f"POST {path} с плохими данными → 500!", f"ответ: {str(data)[:80]}")
else:
ok(f"POST {path} с плохими данными → не 500", f"status={status} error={data.get('error','?')[:40]}")
# ─── Main ───────────────────────────────────────────────────────────────────
def main():
print(f"\n{'='*55}")
print(f" ТЕСТ КАБИНЕТА МЕНЕДЖЕРА — @wasrusgen1bot")
print(f" Пользователь: @{ADMIN_USERNAME} (id={ADMIN_TG_ID})")
print(f" Сервер: {BASE_URL}")
print(f"{'='*55}")
t0 = time.time()
auth_ok = test_auth()
if not auth_ok:
print("\n🚫 Аутентификация провалена — дальнейшие тесты невозможны.\n")
sys.exit(1)
test_clients()
test_measurements()
test_assembly()
test_proposals()
test_manager_pending()
test_staff_list()
test_shipments_arrivals()
test_no_500_on_bad_input()
elapsed = time.time() - t0
passed = sum(1 for ok_, _, _ in RESULTS if ok_)
failed = len(RESULTS) - passed
print(f"\n{'='*55}")
print(f" ИТОГО: {passed} ✅ / {failed} ❌ ({elapsed:.1f}s)")
print(f"{'='*55}\n")
if failed:
print("📋 ЗАМЕЧАНИЯ К УСТРАНЕНИЮ:\n")
for ok_, name, detail in RESULTS:
if not ok_:
print(f"{name}")
if detail:
print(f"{detail}")
print()
sys.exit(1)
else:
print("✅ Кабинет менеджера работает штатно.\n")
sys.exit(0)
if __name__ == "__main__":
main()