mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 13:24:48 +00:00
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:
parent
44379576f2
commit
02f8dba469
@ -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
@ -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
|
||||
|
||||
|
||||
@ -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
345
icon-picker.html
Normal 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
712
icon-preview.html
Normal 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
407
miniapp/assets/act4.js
Normal file
@ -0,0 +1,407 @@
|
||||
/* ============================================================
|
||||
Акт №4 — приёмка товара (экспедитор / сборщик)
|
||||
#/assembly/:id/act4
|
||||
============================================================ */
|
||||
|
||||
const Act4Screen = (function () {
|
||||
"use strict";
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&").replace(/</g, "<")
|
||||
.replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
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 };
|
||||
})();
|
||||
@ -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
228
miniapp/assets/client_timeline.js
Normal file
228
miniapp/assets/client_timeline.js
Normal file
@ -0,0 +1,228 @@
|
||||
/* ============================================================
|
||||
Таймлайн заказа клиента — #/c/assembly/:id/timeline
|
||||
Доступен клиенту, менеджеру, назначенному сборщику.
|
||||
============================================================ */
|
||||
|
||||
const ClientTimeline = (function () {
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&").replace(/</g, "<")
|
||||
.replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
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 };
|
||||
})();
|
||||
391
miniapp/assets/expeditor_dashboard.js
Normal file
391
miniapp/assets/expeditor_dashboard.js
Normal 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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||||
}
|
||||
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
404
miniapp/assets/feedback.js
Normal 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, "&").replace(/</g, "<")
|
||||
.replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
})();
|
||||
287
miniapp/assets/finance_summary.js
Normal file
287
miniapp/assets/finance_summary.js
Normal file
@ -0,0 +1,287 @@
|
||||
/* ============================================================
|
||||
Финансовая сводка менеджера — #/admin/finance
|
||||
============================================================ */
|
||||
|
||||
const FinanceSummary = (function () {
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&").replace(/</g, "<")
|
||||
.replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
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
215
miniapp/assets/invoice.js
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||||
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};
|
||||
})();
|
||||
@ -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) {
|
||||
|
||||
198
miniapp/assets/measurer_dashboard.js
Normal file
198
miniapp/assets/measurer_dashboard.js
Normal file
@ -0,0 +1,198 @@
|
||||
/* ============================================================
|
||||
MeasurerDashboard — личная статистика замерщика
|
||||
#/master/measurer-stats
|
||||
============================================================ */
|
||||
|
||||
const MeasurerDashboard = (function () {
|
||||
"use strict";
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&").replace(/</g, "<")
|
||||
.replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
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 };
|
||||
})();
|
||||
@ -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() {
|
||||
|
||||
@ -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();
|
||||
|
||||
183
miniapp/assets/staff_roster.js
Normal file
183
miniapp/assets/staff_roster.js
Normal file
@ -0,0 +1,183 @@
|
||||
/* ============================================================
|
||||
Обзор команды — #/admin/staff
|
||||
Доступен: менеджер.
|
||||
============================================================ */
|
||||
|
||||
const StaffRoster = (function () {
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&").replace(/</g, "<")
|
||||
.replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
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 };
|
||||
})();
|
||||
@ -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>
|
||||
|
||||
BIN
tests/expeditor_scenarios.md
Normal file
BIN
tests/expeditor_scenarios.md
Normal file
Binary file not shown.
300
tests/test_manager.py
Normal file
300
tests/test_manager.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user