mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 15:44:47 +00:00
feat: assembler dashboard, contracts module (Act №3), assembly rates
Frontend:
- assembler_dashboard.js — personal earnings screen for assemblers (#/master/dashboard)
- contracts.js — Act №3 preview + edit + SignRequest ПЭП (#/assembly/:id/contract)
- assembly_detail.js — add "📄 Акт сдачи-приёмки" button
- app.js — routes for #/master/dashboard and #/assembly/:id/contract
Backend:
- main.py — /api/assembler_earnings (fuzzy name match vs Excel)
- main.py — /api/contract_preview, /api/contract_save (Contracts sheet)
- main.py — _ensure_contracts_sheet(), _name_match_score()
- assembler_parser.py — fix tuple index out of range on short rows (2021 sheet)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
76fce9ec58
commit
21fd0ff3e5
@ -116,6 +116,9 @@ def parse_sheet(ws) -> list[dict]:
|
|||||||
for ri, row in enumerate(rows):
|
for ri, row in enumerate(rows):
|
||||||
if ri <= date_row_idx:
|
if ri <= date_row_idx:
|
||||||
continue
|
continue
|
||||||
|
if len(row) < 3:
|
||||||
|
current_assembler = None
|
||||||
|
continue
|
||||||
|
|
||||||
col_a = str(row[0] or "").strip().lower()
|
col_a = str(row[0] or "").strip().lower()
|
||||||
col_b = str(row[1] or "").strip()
|
col_b = str(row[1] or "").strip()
|
||||||
|
|||||||
@ -153,6 +153,9 @@ async def _dispatch_post(request: Request):
|
|||||||
"assembly_rate_save": _handle_assembly_rate_save,
|
"assembly_rate_save": _handle_assembly_rate_save,
|
||||||
"assembly_rate_delete": _handle_assembly_rate_delete,
|
"assembly_rate_delete": _handle_assembly_rate_delete,
|
||||||
"assembler_analytics": _handle_assembler_analytics,
|
"assembler_analytics": _handle_assembler_analytics,
|
||||||
|
"assembler_earnings": _handle_assembler_earnings,
|
||||||
|
"contract_preview": _handle_contract_preview,
|
||||||
|
"contract_save": _handle_contract_save,
|
||||||
"proposal_brief": proposals_mod.handle_brief,
|
"proposal_brief": proposals_mod.handle_brief,
|
||||||
"proposal_create": proposals_mod.handle_create,
|
"proposal_create": proposals_mod.handle_create,
|
||||||
"proposal_upsert_variant": proposals_mod.handle_upsert_variant,
|
"proposal_upsert_variant": proposals_mod.handle_upsert_variant,
|
||||||
@ -2637,6 +2640,22 @@ _rates_cache: dict = {"data": None, "ts": 0.0}
|
|||||||
_RATES_CACHE_TTL = 120 # секунд
|
_RATES_CACHE_TTL = 120 # секунд
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_contracts_sheet() -> None:
|
||||||
|
"""Создаёт лист Contracts если не существует."""
|
||||||
|
HEADERS = [
|
||||||
|
"contract_id", "assembly_id", "contract_num", "contract_date",
|
||||||
|
"travel_spb", "travel_outside", "tech_list",
|
||||||
|
"created_at", "created_by_tg_id", "updated_at",
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
wb = sheets._get_workbook()
|
||||||
|
if "Contracts" not in [ws.title for ws in wb.worksheets()]:
|
||||||
|
ws = wb.add_worksheet("Contracts", rows=200, cols=len(HEADERS))
|
||||||
|
ws.append_row(HEADERS)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("_ensure_contracts_sheet: %s", e)
|
||||||
|
|
||||||
|
|
||||||
def _ensure_rates_sheet() -> None:
|
def _ensure_rates_sheet() -> None:
|
||||||
try:
|
try:
|
||||||
ws = sheets._ws("Assembly_Rates")
|
ws = sheets._ws("Assembly_Rates")
|
||||||
@ -2965,6 +2984,227 @@ async def api_assembler_analytics(request: Request):
|
|||||||
return _handle_assembler_analytics(body)
|
return _handle_assembler_analytics(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _name_match_score(excel_name: str, full_name: str) -> int:
|
||||||
|
"""Возвращает score (0-3) схожести имени из Excel с full_name из Users."""
|
||||||
|
en = excel_name.strip().lower()
|
||||||
|
fn = full_name.strip().lower()
|
||||||
|
if not en or not fn:
|
||||||
|
return 0
|
||||||
|
if en == fn:
|
||||||
|
return 3
|
||||||
|
# Первое слово (фамилия) совпадает
|
||||||
|
en_first = en.split()[0] if en.split() else ""
|
||||||
|
fn_first = fn.split()[0] if fn.split() else ""
|
||||||
|
if en_first and fn_first and en_first == fn_first:
|
||||||
|
# Дополнительно: совпадает второе слово или инициал
|
||||||
|
en_parts = en.split()
|
||||||
|
fn_parts = fn.split()
|
||||||
|
if len(en_parts) > 1 and len(fn_parts) > 1:
|
||||||
|
if en_parts[1] == fn_parts[1] or en_parts[1][:1] == fn_parts[1][:1]:
|
||||||
|
return 2
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_assembler_earnings(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Личная аналитика сборщика — его заработки из Excel-расписания.
|
||||||
|
body: {initData, year?: '2026'}
|
||||||
|
Доступен сборщику, замерщику, менеджеру."""
|
||||||
|
cfg = get_config()
|
||||||
|
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
|
||||||
|
if not auth or not auth.get("user"):
|
||||||
|
unsafe = body.get("initDataUnsafe") or {}
|
||||||
|
if isinstance(unsafe, dict) and unsafe.get("user", {}).get("id"):
|
||||||
|
auth = {"user": unsafe["user"]}
|
||||||
|
else:
|
||||||
|
return {"error": "invalid_init_data"}
|
||||||
|
tg_id = auth["user"]["id"]
|
||||||
|
user = sheets.find_user(tg_id)
|
||||||
|
if not user:
|
||||||
|
return {"error": "user_not_found"}
|
||||||
|
if not (sheets.has_role(user, "assembler") or sheets.has_role(user, "measurer") or
|
||||||
|
sheets.has_role(user, "manager") or sheets.has_role(user, "admin")):
|
||||||
|
return {"error": "forbidden"}
|
||||||
|
|
||||||
|
full_name = (user.get("full_name") or "").strip()
|
||||||
|
if not full_name:
|
||||||
|
return {"error": "no_name", "message": "Имя не задано в профиле"}
|
||||||
|
|
||||||
|
data = _get_schedule_data()
|
||||||
|
if "error" in data:
|
||||||
|
return data
|
||||||
|
|
||||||
|
year = str(body.get("year") or "").strip()
|
||||||
|
|
||||||
|
# Находим лучшее совпадение по имени
|
||||||
|
best_name = None
|
||||||
|
best_score = 0
|
||||||
|
for excel_name in data.get("by_assembler", {}).keys():
|
||||||
|
score = _name_match_score(excel_name, full_name)
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best_name = excel_name
|
||||||
|
|
||||||
|
if not best_name or best_score == 0:
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"matched_name": None,
|
||||||
|
"full_name": full_name,
|
||||||
|
"months": {},
|
||||||
|
"total_amount": 0,
|
||||||
|
"total_orders": 0,
|
||||||
|
"message": "Данные по вашему имени не найдены в таблице занятости",
|
||||||
|
}
|
||||||
|
|
||||||
|
months_raw = data["by_assembler"][best_name]
|
||||||
|
if year and year.isdigit():
|
||||||
|
months_raw = {k: v for k, v in months_raw.items() if k.startswith(year)}
|
||||||
|
|
||||||
|
total_amount = sum(m["total_amount"] for m in months_raw.values())
|
||||||
|
total_orders = sum(m["orders"] for m in months_raw.values())
|
||||||
|
|
||||||
|
# Сортируем по дате desc
|
||||||
|
months_sorted = dict(sorted(months_raw.items(), reverse=True))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"matched_name": best_name,
|
||||||
|
"full_name": full_name,
|
||||||
|
"match_score": best_score,
|
||||||
|
"months": months_sorted,
|
||||||
|
"total_amount": total_amount,
|
||||||
|
"total_orders": total_orders,
|
||||||
|
"parsed_at": data.get("parsed_at"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/assembler_earnings")
|
||||||
|
async def api_assembler_earnings(request: Request):
|
||||||
|
body = await _safe_json(request)
|
||||||
|
return _handle_assembler_earnings(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_contract_preview(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Возвращает данные сборки + сохранённые поля контракта для предпросмотра акта.
|
||||||
|
body: {initData, initDataUnsafe, assembly_id}
|
||||||
|
Доступен менеджеру и сборщику."""
|
||||||
|
cfg = get_config()
|
||||||
|
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
|
||||||
|
if not auth or not auth.get("user"):
|
||||||
|
unsafe = body.get("initDataUnsafe") or {}
|
||||||
|
if isinstance(unsafe, dict) and unsafe.get("user", {}).get("id"):
|
||||||
|
auth = {"user": unsafe["user"]}
|
||||||
|
else:
|
||||||
|
return {"error": "invalid_init_data"}
|
||||||
|
tg_id = auth["user"]["id"]
|
||||||
|
user = sheets.find_user(tg_id)
|
||||||
|
if not user:
|
||||||
|
return {"error": "user_not_found"}
|
||||||
|
if not (sheets.has_role(user, "manager") or sheets.has_role(user, "assembler") or
|
||||||
|
sheets.has_role(user, "admin")):
|
||||||
|
return {"error": "forbidden"}
|
||||||
|
|
||||||
|
assembly_id = str(body.get("assembly_id") or "").strip()
|
||||||
|
if not assembly_id:
|
||||||
|
return {"error": "missing_assembly_id"}
|
||||||
|
|
||||||
|
asm = sheets.find_row("Assemblies", "id", assembly_id)
|
||||||
|
if not asm:
|
||||||
|
return {"error": "assembly_not_found"}
|
||||||
|
|
||||||
|
# Загружаем сохранённые поля контракта (если есть)
|
||||||
|
contract = sheets.find_row("Contracts", "assembly_id", assembly_id) or {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"assembly": {
|
||||||
|
"id": asm.get("id", ""),
|
||||||
|
"client_name": asm.get("client_name", ""),
|
||||||
|
"client_tg_id": asm.get("client_tg_id", ""),
|
||||||
|
"address": asm.get("address", ""),
|
||||||
|
"scheduled_at": asm.get("scheduled_at", ""),
|
||||||
|
"assembly_price_for_client": asm.get("assembly_price_for_client") or asm.get("kitchen_price", ""),
|
||||||
|
"signed_by_name": asm.get("signed_by_name", ""),
|
||||||
|
"signed_at": asm.get("signed_at", ""),
|
||||||
|
"signed_via": asm.get("signed_via", ""),
|
||||||
|
"status": asm.get("status", ""),
|
||||||
|
},
|
||||||
|
"contract": {
|
||||||
|
"contract_num": contract.get("contract_num", assembly_id),
|
||||||
|
"contract_date": contract.get("contract_date", ""),
|
||||||
|
"travel_spb": contract.get("travel_spb", "0"),
|
||||||
|
"travel_outside": contract.get("travel_outside", "0"),
|
||||||
|
"tech_list": contract.get("tech_list", ""),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/contract_preview")
|
||||||
|
async def api_contract_preview(request: Request):
|
||||||
|
body = await _safe_json(request)
|
||||||
|
return _handle_contract_preview(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_contract_save(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Сохраняет дополнительные поля акта в лист Contracts.
|
||||||
|
body: {initData, initDataUnsafe, assembly_id, contract_num, contract_date, travel_spb, travel_outside, tech_list}
|
||||||
|
Доступен менеджеру."""
|
||||||
|
cfg = get_config()
|
||||||
|
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
|
||||||
|
if not auth or not auth.get("user"):
|
||||||
|
unsafe = body.get("initDataUnsafe") or {}
|
||||||
|
if isinstance(unsafe, dict) and unsafe.get("user", {}).get("id"):
|
||||||
|
auth = {"user": unsafe["user"]}
|
||||||
|
else:
|
||||||
|
return {"error": "invalid_init_data"}
|
||||||
|
tg_id = auth["user"]["id"]
|
||||||
|
user = sheets.find_user(tg_id)
|
||||||
|
if not user:
|
||||||
|
return {"error": "user_not_found"}
|
||||||
|
if not (sheets.has_role(user, "manager") or sheets.has_role(user, "admin")):
|
||||||
|
return {"error": "forbidden"}
|
||||||
|
|
||||||
|
assembly_id = str(body.get("assembly_id") or "").strip()
|
||||||
|
if not assembly_id:
|
||||||
|
return {"error": "missing_assembly_id"}
|
||||||
|
|
||||||
|
_ensure_contracts_sheet()
|
||||||
|
|
||||||
|
contract_num = str(body.get("contract_num") or assembly_id).strip()
|
||||||
|
contract_date = str(body.get("contract_date") or "").strip()
|
||||||
|
travel_spb = str(body.get("travel_spb") or "0").strip()
|
||||||
|
travel_outside = str(body.get("travel_outside") or "0").strip()
|
||||||
|
tech_list = str(body.get("tech_list") or "").strip()
|
||||||
|
now_iso = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
existing = sheets.find_row("Contracts", "assembly_id", assembly_id)
|
||||||
|
if existing:
|
||||||
|
# Обновляем существующую запись
|
||||||
|
sheets.update_cell_by_key("Contracts", "assembly_id", assembly_id, "contract_num", contract_num)
|
||||||
|
sheets.update_cell_by_key("Contracts", "assembly_id", assembly_id, "contract_date", contract_date)
|
||||||
|
sheets.update_cell_by_key("Contracts", "assembly_id", assembly_id, "travel_spb", travel_spb)
|
||||||
|
sheets.update_cell_by_key("Contracts", "assembly_id", assembly_id, "travel_outside", travel_outside)
|
||||||
|
sheets.update_cell_by_key("Contracts", "assembly_id", assembly_id, "tech_list", tech_list)
|
||||||
|
sheets.update_cell_by_key("Contracts", "assembly_id", assembly_id, "updated_at", now_iso)
|
||||||
|
else:
|
||||||
|
# Создаём новую запись
|
||||||
|
import uuid
|
||||||
|
contract_id = str(uuid.uuid4())[:8]
|
||||||
|
sheets.append_row("Contracts", [
|
||||||
|
contract_id, assembly_id, contract_num, contract_date,
|
||||||
|
travel_spb, travel_outside, tech_list,
|
||||||
|
now_iso, str(tg_id), "",
|
||||||
|
])
|
||||||
|
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/contract_save")
|
||||||
|
async def api_contract_save(request: Request):
|
||||||
|
body = await _safe_json(request)
|
||||||
|
return _handle_contract_save(body)
|
||||||
|
|
||||||
|
|
||||||
# =================================================================
|
# =================================================================
|
||||||
# SignRequest — цифровая подпись акта сборки (ФЗ-63 ПЭП)
|
# SignRequest — цифровая подпись акта сборки (ФЗ-63 ПЭП)
|
||||||
# =================================================================
|
# =================================================================
|
||||||
|
|||||||
@ -912,10 +912,23 @@ async function renderStaff(me) {
|
|||||||
renderStaffAssemblies(assemblySection.querySelector("#assemblyList"));
|
renderStaffAssemblies(assemblySection.querySelector("#assemblyList"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Шпаргалки сборщика — прайс, рейки, полкодержатели
|
// Шпаргалки + заработки сборщика
|
||||||
if (caps.assembler) {
|
if (caps.assembler) {
|
||||||
const toolsBtn = el(`
|
const earningsBtn = el(`
|
||||||
<div class="podbor-cta-row" style="margin-top:16px;">
|
<div class="podbor-cta-row" style="margin-top:16px;">
|
||||||
|
<button class="btn-primary" style="gap:8px;">
|
||||||
|
💰 Мои заработки
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
earningsBtn.querySelector("button").addEventListener("click", () => {
|
||||||
|
haptic && haptic("impact");
|
||||||
|
location.hash = "#/master/dashboard";
|
||||||
|
});
|
||||||
|
app.appendChild(earningsBtn);
|
||||||
|
|
||||||
|
const toolsBtn = el(`
|
||||||
|
<div class="podbor-cta-row" style="margin-top:8px;">
|
||||||
<button class="btn-secondary" style="gap:8px;">
|
<button class="btn-secondary" style="gap:8px;">
|
||||||
📚 Шпаргалки сборщика
|
📚 Шпаргалки сборщика
|
||||||
</button>
|
</button>
|
||||||
@ -1703,6 +1716,13 @@ function routeByHash() {
|
|||||||
} else if (location.hash.startsWith("#/admin/rates")) {
|
} else if (location.hash.startsWith("#/admin/rates")) {
|
||||||
if (typeof AdminRates !== "undefined") AdminRates.mount(app);
|
if (typeof AdminRates !== "undefined") AdminRates.mount(app);
|
||||||
else init();
|
else init();
|
||||||
|
} else if (location.hash === "#/master/dashboard") {
|
||||||
|
if (typeof AssemblerDashboard !== "undefined") AssemblerDashboard.mount(app);
|
||||||
|
else init();
|
||||||
|
} else if (location.hash.startsWith("#/assembly/") && location.hash.endsWith("/contract")) {
|
||||||
|
const asmId = location.hash.split("/")[2];
|
||||||
|
if (typeof Contracts !== "undefined") Contracts.mount(app, asmId);
|
||||||
|
else init();
|
||||||
} else if (location.hash.startsWith("#/master/tools")) {
|
} else if (location.hash.startsWith("#/master/tools")) {
|
||||||
if (typeof MasterTools !== "undefined") {
|
if (typeof MasterTools !== "undefined") {
|
||||||
const h = location.hash;
|
const h = location.hash;
|
||||||
|
|||||||
225
miniapp/assets/assembler_dashboard.js
Normal file
225
miniapp/assets/assembler_dashboard.js
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
/* ============================================================
|
||||||
|
AssemblerDashboard — личная аналитика сборщика
|
||||||
|
#/master/dashboard
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
const AssemblerDashboard = (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" aria-label="Назад">${(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;" title="Обновить">↻</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="2024">2024</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 style="margin-top:8px;font-size:12px;color:var(--muted);">Загружаем данные…</div></div>`;
|
||||||
|
try {
|
||||||
|
const data = await _api("assembler_earnings", { year });
|
||||||
|
if (data.error) {
|
||||||
|
screen.innerHTML = `<div class="error" style="margin:16px;">${escHtml(data.error === "no_name" ? "Имя не задано в профиле. Обратитесь к менеджеру." : 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 = "";
|
||||||
|
|
||||||
|
// Имя не нашли
|
||||||
|
if (!data.matched_name) {
|
||||||
|
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;font-weight:600;color:var(--ink);margin-bottom:8px;">Данные не найдены</div>
|
||||||
|
<div style="font-size:13px;">${escHtml(data.message || "Ваше имя не найдено в таблице занятости")}</div>
|
||||||
|
<div style="font-size:11px;margin-top:12px;color:var(--muted);">Имя в профиле: ${escHtml(data.full_name || "—")}</div>
|
||||||
|
</div>
|
||||||
|
`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 prevDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
|
const prevYM = `${prevDate.getFullYear()}-${String(prevDate.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
const curMonth = months[curYM] || null;
|
||||||
|
const prevMonth = months[prevYM] || null;
|
||||||
|
|
||||||
|
// === Hero-карточка ===
|
||||||
|
const heroCard = el(`
|
||||||
|
<div style="margin:0 16px 16px;padding:20px;background:var(--accent);border-radius:16px;color:#fff;">
|
||||||
|
<div style="font-size:11px;opacity:0.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:0.75;margin-top:4px;">${escHtml(String(data.total_orders))} заказов</div>
|
||||||
|
${data.match_score < 2 ? `<div style="font-size:10px;opacity:0.6;margin-top:8px;">⚠ Неточное совпадение: «${escHtml(data.matched_name)}»</div>` : ""}
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
screen.appendChild(heroCard);
|
||||||
|
|
||||||
|
// === Мини-карточки текущий / прошлый месяц ===
|
||||||
|
if (curMonth || prevMonth) {
|
||||||
|
const row = el(`<div style="display:flex;gap:10px;margin:0 16px 16px;"></div>`);
|
||||||
|
const _miniCard = (label, m) => {
|
||||||
|
if (!m) return el(`<div style="flex:1;padding:14px;background:var(--surface);border:1px solid var(--border);border-radius:12px;opacity:0.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>`);
|
||||||
|
return 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;">${escHtml(String(m.orders))} заказов</div>
|
||||||
|
</div>`);
|
||||||
|
};
|
||||||
|
row.appendChild(_miniCard("Текущий месяц", curMonth));
|
||||||
|
row.appendChild(_miniCard("Прошлый месяц", prevMonth));
|
||||||
|
screen.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Таблица по месяцам ===
|
||||||
|
if (monthKeys.length) {
|
||||||
|
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 avgPer = m.orders ? Math.round(m.total_amount / m.orders) : 0;
|
||||||
|
const isCurrentMonth = ym === curYM;
|
||||||
|
|
||||||
|
const card = el(`
|
||||||
|
<div style="margin:4px 16px;padding:12px 14px;background:var(--surface);
|
||||||
|
border:1px solid ${isCurrentMonth ? "var(--accent)" : "var(--border)"};border-radius:12px;
|
||||||
|
${isCurrentMonth ? "box-shadow:0 0 0 1px var(--accent)20;" : ""}">
|
||||||
|
<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>
|
||||||
|
${isCurrentMonth ? `<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:var(--accent);">${escHtml(fmtMoney(m.total_amount))}</div>
|
||||||
|
<div style="font-size:10px;color:var(--muted);">${escHtml(String(m.orders))} зак. · ср. ${escHtml(fmtMoney(avgPer))}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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(card);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
screen.appendChild(el(`
|
||||||
|
<div style="text-align:center;padding:40px 16px;color:var(--muted);">
|
||||||
|
Нет данных за выбранный период.<br>
|
||||||
|
<span style="font-size:12px;">Попробуй выбрать другой год.</span>
|
||||||
|
</div>
|
||||||
|
`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
if (data.parsed_at) {
|
||||||
|
const parsedAt = new Date(data.parsed_at).toLocaleString("ru-RU");
|
||||||
|
screen.appendChild(el(`
|
||||||
|
<div style="margin:12px 16px 4px;font-size:11px;color:var(--muted);text-align:center;">
|
||||||
|
Данные обновлены: ${escHtml(parsedAt)}
|
||||||
|
</div>
|
||||||
|
`));
|
||||||
|
}
|
||||||
|
screen.appendChild(el(`<div style="height:32px;"></div>`));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mount };
|
||||||
|
})();
|
||||||
@ -192,6 +192,20 @@ const AssemblyDetailScreen = (function () {
|
|||||||
screen.innerHTML = statusBanner + mainBlock + noteBlock + photosBlock + signBlock + calBtn +
|
screen.innerHTML = statusBanner + mainBlock + noteBlock + photosBlock + signBlock + calBtn +
|
||||||
`<div style="height:32px;"></div>`;
|
`<div style="height:32px;"></div>`;
|
||||||
|
|
||||||
|
// Кнопка «Акт сдачи-приёмки» — для менеджера всегда доступна
|
||||||
|
const actWrap = document.createElement("div");
|
||||||
|
actWrap.style.cssText = "margin:8px 16px 0;";
|
||||||
|
const actBtn = document.createElement("button");
|
||||||
|
actBtn.className = "btn-secondary";
|
||||||
|
actBtn.style.cssText = "width:100%;font-size:14px;padding:11px;";
|
||||||
|
actBtn.textContent = "📄 Акт сдачи-приёмки";
|
||||||
|
actBtn.addEventListener("click", () => {
|
||||||
|
haptic && haptic("impact");
|
||||||
|
location.hash = `#/assembly/${data.id}/contract`;
|
||||||
|
});
|
||||||
|
actWrap.appendChild(actBtn);
|
||||||
|
screen.appendChild(actWrap);
|
||||||
|
|
||||||
// Кнопка «Подписать акт» — только если ещё не подписано
|
// Кнопка «Подписать акт» — только если ещё не подписано
|
||||||
if (!data.signed_by_name) {
|
if (!data.signed_by_name) {
|
||||||
const btnWrap = screen.querySelector("#sr-sign-btn-wrap");
|
const btnWrap = screen.querySelector("#sr-sign-btn-wrap");
|
||||||
@ -207,7 +221,6 @@ const AssemblyDetailScreen = (function () {
|
|||||||
clientName: data.client_name || "",
|
clientName: data.client_name || "",
|
||||||
clientTgId: data.client_tg_id || "",
|
clientTgId: data.client_tg_id || "",
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Перерисовываем экран после подписания
|
|
||||||
mount(container, assemblyId);
|
mount(container, assemblyId);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
436
miniapp/assets/contracts.js
Normal file
436
miniapp/assets/contracts.js
Normal file
@ -0,0 +1,436 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Contracts — предпросмотр и подпись акта сдачи-приёмки №3
|
||||||
|
mount(container, assemblyId) | route: #/assembly/:id/contract
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
const Contracts = (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) {
|
||||||
|
const num = parseFloat(n) || 0;
|
||||||
|
return num.toLocaleString("ru-RU", { minimumFractionDigits: 0 }) + " р.";
|
||||||
|
}
|
||||||
|
function today() {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
function fmtDateParts(dateStr) {
|
||||||
|
// "2026-05-19" → { day:"19", month:"мая", year:"2026" }
|
||||||
|
if (!dateStr) {
|
||||||
|
const d = new Date();
|
||||||
|
dateStr = d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
const months = [
|
||||||
|
"января","февраля","марта","апреля","мая","июня",
|
||||||
|
"июля","августа","сентября","октября","ноября","декабря"
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
const parts = dateStr.split("-");
|
||||||
|
return {
|
||||||
|
day: String(parseInt(parts[2])),
|
||||||
|
month: months[parseInt(parts[1]) - 1] || parts[1],
|
||||||
|
year: parts[0],
|
||||||
|
};
|
||||||
|
} catch { return { day: "—", month: "—", year: "—" }; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── API ─────────────────────────────────────────────────── */
|
||||||
|
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: (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); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Шаблон акта ─────────────────────────────────────────── */
|
||||||
|
function buildActHtml(fields) {
|
||||||
|
const {
|
||||||
|
contract_num, contract_date, client_name, address,
|
||||||
|
total_sum, assembly_price, travel_spb, travel_outside, tech_list,
|
||||||
|
} = fields;
|
||||||
|
|
||||||
|
const dp = fmtDateParts(contract_date);
|
||||||
|
const totalFmt = fmtMoney(total_sum);
|
||||||
|
const asmFmt = fmtMoney(assembly_price);
|
||||||
|
const spbFmt = fmtMoney(travel_spb);
|
||||||
|
const outsideFmt = fmtMoney(travel_outside);
|
||||||
|
|
||||||
|
const techBlock = tech_list && tech_list.trim()
|
||||||
|
? `<p style="margin:10px 0 4px;"><strong>Перечень техники, подлежащей бесплатной установке:</strong></p>
|
||||||
|
<p style="white-space:pre-wrap;margin:0 0 10px;">${escHtml(tech_list.trim())}</p>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div style="font-family:'Courier New',Courier,monospace;font-size:13px;line-height:1.65;
|
||||||
|
color:var(--ink);padding:20px 22px;background:var(--surface);
|
||||||
|
border:1px solid var(--border);border-radius:10px;word-break:break-word;">
|
||||||
|
|
||||||
|
<div style="text-align:center;margin-bottom:16px;">
|
||||||
|
<strong style="font-size:15px;">АКТ СДАЧИ-ПРИЁМКИ РАБОТ</strong><br>
|
||||||
|
<span>по договору на сборку и установку мебели</span><br>
|
||||||
|
<span>№${escHtml(contract_num)} от ${escHtml(dp.day)} ${escHtml(dp.month)} ${escHtml(dp.year)} г.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;justify-content:space-between;margin-bottom:14px;flex-wrap:wrap;gap:4px;">
|
||||||
|
<span>г. Санкт-Петербург</span>
|
||||||
|
<span>«${escHtml(dp.day)}» ${escHtml(dp.month)} ${escHtml(dp.year)} г.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin:0 0 12px;">
|
||||||
|
Индивидуальный предприниматель, именуемый в дальнейшем «Исполнитель», с одной стороны
|
||||||
|
и <strong>${escHtml(client_name || "—")}</strong>, именуемый(ая) в дальнейшем «Заказчик»
|
||||||
|
с другой стороны, составили настоящий акт сдачи-приёмки работ о нижеследующем:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin:0 0 12px;">
|
||||||
|
Работы по установке мебели на объекте Заказчика по адресу: <strong>${escHtml(address || "—")}</strong>
|
||||||
|
по договору №${escHtml(contract_num)} от ${escHtml(dp.day)} ${escHtml(dp.month)} ${escHtml(dp.year)} г.,
|
||||||
|
на общую сумму <strong>${escHtml(totalFmt)}</strong>,
|
||||||
|
выполнены Исполнителем в полном объёме надлежащего качества.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table style="width:100%;border-collapse:collapse;margin-bottom:12px;font-size:13px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:3px 0;">Стоимость услуг по сборке и установке:</td>
|
||||||
|
<td style="text-align:right;white-space:nowrap;">${escHtml(asmFmt)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:3px 0;">Стоимость выезда сборщика по СПб:</td>
|
||||||
|
<td style="text-align:right;white-space:nowrap;">${escHtml(spbFmt)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:3px 0;">Стоимость выезда сборщика за пределы условной границы СПб:</td>
|
||||||
|
<td style="text-align:right;white-space:nowrap;">${escHtml(outsideFmt)}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin:0 0 12px;">
|
||||||
|
Стороны не имеют претензий друг к другу по исполнению Договора, в том числе
|
||||||
|
по срокам выполнения работ, качеству и объёму работ.<br>
|
||||||
|
Настоящий акт составлен в двух экземплярах.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
${techBlock}
|
||||||
|
|
||||||
|
<p style="margin:12px 0 8px;font-size:12px;color:#c0392b;font-weight:bold;">
|
||||||
|
ВНИМАНИЕ! Перед подписанием акта тщательно осмотрите мебель на предмет возможных
|
||||||
|
недостатков. После подписания акта приёмки претензии по качеству не принимаются.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin:0 0 16px;font-size:12px;">
|
||||||
|
При наличии вопросов обращайтесь в отдел сервиса: +7-952-379-63-25
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="display:flex;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-top:8px;">
|
||||||
|
<div>ЗАКАЗЧИК _________________ / ${escHtml(client_name || "—")}</div>
|
||||||
|
<div>ИСПОЛНИТЕЛЬ _______________ / Васильев Р.Г.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:16px;text-align:right;">
|
||||||
|
<button id="printActBtn" style="background:none;border:none;color:var(--accent);
|
||||||
|
font-size:13px;cursor:pointer;text-decoration:underline;padding:0;">
|
||||||
|
📄 Предпросмотр акта
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Главный экран ─────────────────────────────────────────── */
|
||||||
|
async function mount(container, assemblyId) {
|
||||||
|
// Читаем id из параметра или из хэша
|
||||||
|
const asmId = assemblyId || location.hash.split("/").pop();
|
||||||
|
|
||||||
|
container.innerHTML = "";
|
||||||
|
document.body.classList.remove("has-bottom-nav");
|
||||||
|
document.getElementById("bottom-nav")?.remove();
|
||||||
|
|
||||||
|
/* Заголовок */
|
||||||
|
const header = el(`
|
||||||
|
<header class="podbor-header">
|
||||||
|
<button class="podbor-back" aria-label="Назад">${(window.ICONS || {}).arrow_left || "‹"}</button>
|
||||||
|
<div class="podbor-title">Акт сдачи-приёмки</div>
|
||||||
|
<div style="width:32px;"></div>
|
||||||
|
</header>
|
||||||
|
`);
|
||||||
|
header.querySelector(".podbor-back").addEventListener("click", () => {
|
||||||
|
haptic && haptic("impact");
|
||||||
|
history.back();
|
||||||
|
});
|
||||||
|
|
||||||
|
const screen = el(`<div class="podbor-screen" style="padding:12px 14px 40px;"></div>`);
|
||||||
|
container.appendChild(header);
|
||||||
|
container.appendChild(screen);
|
||||||
|
|
||||||
|
/* Loader */
|
||||||
|
screen.innerHTML = `<div class="loader-inline"><div class="spinner"></div>
|
||||||
|
<div style="margin-top:8px;font-size:12px;color:var(--muted);">Загружаем данные…</div></div>`;
|
||||||
|
|
||||||
|
/* Загружаем данные */
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await _api("contract_preview", { assembly_id: asmId });
|
||||||
|
} catch (e) {
|
||||||
|
screen.innerHTML = `<div style="padding:24px;text-align:center;color:#e74c3c;">
|
||||||
|
Ошибка загрузки:<br>${escHtml(e.message)}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data.ok) {
|
||||||
|
screen.innerHTML = `<div style="padding:24px;text-align:center;color:#e74c3c;">
|
||||||
|
${escHtml(data.error || "Не удалось загрузить данные")}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const asm = data.assembly || {};
|
||||||
|
const contract = data.contract || {};
|
||||||
|
|
||||||
|
/* Начальные значения редактируемых полей */
|
||||||
|
let extras = {
|
||||||
|
contract_num: contract.contract_num || String(asmId),
|
||||||
|
contract_date: contract.contract_date || today(),
|
||||||
|
travel_spb: contract.travel_spb != null ? contract.travel_spb : 0,
|
||||||
|
travel_outside: contract.travel_outside != null ? contract.travel_outside : 0,
|
||||||
|
tech_list: contract.tech_list || "",
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Вычисляем total_sum */
|
||||||
|
function calcTotal() {
|
||||||
|
return (parseFloat(asm.assembly_price) || 0)
|
||||||
|
+ (parseFloat(extras.travel_spb) || 0)
|
||||||
|
+ (parseFloat(extras.travel_outside) || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Рендерим всё */
|
||||||
|
function render() {
|
||||||
|
screen.innerHTML = "";
|
||||||
|
|
||||||
|
/* === Блок: Акт === */
|
||||||
|
const actSection = el(`<div></div>`);
|
||||||
|
actSection.innerHTML = buildActHtml({
|
||||||
|
contract_num: extras.contract_num,
|
||||||
|
contract_date: extras.contract_date,
|
||||||
|
client_name: asm.client_name || "",
|
||||||
|
address: asm.address || "",
|
||||||
|
total_sum: calcTotal(),
|
||||||
|
assembly_price: asm.assembly_price || 0,
|
||||||
|
travel_spb: extras.travel_spb,
|
||||||
|
travel_outside: extras.travel_outside,
|
||||||
|
tech_list: extras.tech_list,
|
||||||
|
});
|
||||||
|
actSection.querySelector("#printActBtn")?.addEventListener("click", () => {
|
||||||
|
window.print();
|
||||||
|
});
|
||||||
|
screen.appendChild(actSection);
|
||||||
|
|
||||||
|
/* === Блок: Статус подписи === */
|
||||||
|
if (asm.signed_by_name) {
|
||||||
|
const signedBadge = el(`
|
||||||
|
<div style="margin-top:16px;padding:12px 16px;background:#eafaf1;border:1px solid #27ae60;
|
||||||
|
border-radius:10px;display:flex;align-items:center;gap:10px;font-size:13px;color:#1e8449;">
|
||||||
|
<span style="font-size:20px;">✅</span>
|
||||||
|
<div>
|
||||||
|
<div><strong>Акт подписан</strong></div>
|
||||||
|
<div style="color:var(--muted);font-size:12px;">
|
||||||
|
${escHtml(asm.signed_by_name)}
|
||||||
|
${asm.signed_at ? " · " + escHtml(asm.signed_at) : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
screen.appendChild(signedBadge);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Блок: Дополнительные данные === */
|
||||||
|
const extrasSection = el(`
|
||||||
|
<div style="margin-top:20px;">
|
||||||
|
<div class="section-head" style="font-size:12px;color:var(--muted);
|
||||||
|
text-transform:uppercase;letter-spacing:.5px;margin-bottom:10px;">
|
||||||
|
✏️ Дополнительные данные
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field" style="margin-bottom:12px;">
|
||||||
|
<label class="field-label" style="font-size:12px;color:var(--muted);display:block;margin-bottom:4px;">
|
||||||
|
Номер договора
|
||||||
|
</label>
|
||||||
|
<input id="inp_contract_num" type="text"
|
||||||
|
value="${escHtml(extras.contract_num)}"
|
||||||
|
style="width:100%;box-sizing:border-box;padding:9px 12px;
|
||||||
|
border:1px solid var(--border);border-radius:8px;
|
||||||
|
background:var(--surface);color:var(--ink);font-size:14px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field" style="margin-bottom:12px;">
|
||||||
|
<label class="field-label" style="font-size:12px;color:var(--muted);display:block;margin-bottom:4px;">
|
||||||
|
Дата договора
|
||||||
|
</label>
|
||||||
|
<input id="inp_contract_date" type="date"
|
||||||
|
value="${escHtml(extras.contract_date)}"
|
||||||
|
style="width:100%;box-sizing:border-box;padding:9px 12px;
|
||||||
|
border:1px solid var(--border);border-radius:8px;
|
||||||
|
background:var(--surface);color:var(--ink);font-size:14px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field" style="margin-bottom:12px;">
|
||||||
|
<label class="field-label" style="font-size:12px;color:var(--muted);display:block;margin-bottom:4px;">
|
||||||
|
Стоимость выезда по СПб (₽)
|
||||||
|
</label>
|
||||||
|
<input id="inp_travel_spb" type="number" min="0" step="100"
|
||||||
|
value="${escHtml(String(extras.travel_spb))}"
|
||||||
|
style="width:100%;box-sizing:border-box;padding:9px 12px;
|
||||||
|
border:1px solid var(--border);border-radius:8px;
|
||||||
|
background:var(--surface);color:var(--ink);font-size:14px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field" style="margin-bottom:12px;">
|
||||||
|
<label class="field-label" style="font-size:12px;color:var(--muted);display:block;margin-bottom:4px;">
|
||||||
|
Стоимость выезда за пределы СПб (₽)
|
||||||
|
</label>
|
||||||
|
<input id="inp_travel_outside" type="number" min="0" step="100"
|
||||||
|
value="${escHtml(String(extras.travel_outside))}"
|
||||||
|
style="width:100%;box-sizing:border-box;padding:9px 12px;
|
||||||
|
border:1px solid var(--border);border-radius:8px;
|
||||||
|
background:var(--surface);color:var(--ink);font-size:14px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field" style="margin-bottom:16px;">
|
||||||
|
<label class="field-label" style="font-size:12px;color:var(--muted);display:block;margin-bottom:4px;">
|
||||||
|
Перечень техники для бесплатной установки (необязательно)
|
||||||
|
</label>
|
||||||
|
<textarea id="inp_tech_list" rows="3"
|
||||||
|
placeholder="Например: стиральная машина, посудомойка…"
|
||||||
|
style="width:100%;box-sizing:border-box;padding:9px 12px;
|
||||||
|
border:1px solid var(--border);border-radius:8px;
|
||||||
|
background:var(--surface);color:var(--ink);font-size:14px;
|
||||||
|
resize:vertical;">${escHtml(extras.tech_list)}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:10px;flex-wrap:wrap;">
|
||||||
|
<button id="btnSave" class="btn-secondary"
|
||||||
|
style="flex:1;min-width:120px;padding:11px 0;border-radius:10px;
|
||||||
|
font-size:14px;font-weight:600;cursor:pointer;">
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
${!asm.signed_by_name ? `
|
||||||
|
<button id="btnSign" class="btn-primary"
|
||||||
|
style="flex:1;min-width:140px;padding:11px 0;border-radius:10px;
|
||||||
|
font-size:14px;font-weight:600;cursor:pointer;">
|
||||||
|
✍️ Подписать акт
|
||||||
|
</button>` : ""}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="saveStatus" style="margin-top:10px;font-size:13px;min-height:18px;"></div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
screen.appendChild(extrasSection);
|
||||||
|
|
||||||
|
/* === Обработчики изменений — live-обновление акта === */
|
||||||
|
const liveInputs = [
|
||||||
|
["inp_contract_num", "contract_num", false],
|
||||||
|
["inp_contract_date", "contract_date", false],
|
||||||
|
["inp_travel_spb", "travel_spb", true],
|
||||||
|
["inp_travel_outside", "travel_outside", true],
|
||||||
|
["inp_tech_list", "tech_list", false],
|
||||||
|
];
|
||||||
|
liveInputs.forEach(([id, key, isNum]) => {
|
||||||
|
const inp = screen.querySelector("#" + id);
|
||||||
|
if (!inp) return;
|
||||||
|
inp.addEventListener("input", () => {
|
||||||
|
extras[key] = isNum ? (parseFloat(inp.value) || 0) : inp.value;
|
||||||
|
// Обновляем только акт, не весь экран (чтобы не потерять фокус ввода)
|
||||||
|
const actDiv = actSection.querySelector("div");
|
||||||
|
if (actDiv) {
|
||||||
|
const newInner = buildActHtml({
|
||||||
|
contract_num: extras.contract_num,
|
||||||
|
contract_date: extras.contract_date,
|
||||||
|
client_name: asm.client_name || "",
|
||||||
|
address: asm.address || "",
|
||||||
|
total_sum: calcTotal(),
|
||||||
|
assembly_price: asm.assembly_price || 0,
|
||||||
|
travel_spb: extras.travel_spb,
|
||||||
|
travel_outside: extras.travel_outside,
|
||||||
|
tech_list: extras.tech_list,
|
||||||
|
});
|
||||||
|
const tmp = document.createElement("div");
|
||||||
|
tmp.innerHTML = newInner;
|
||||||
|
const newActDiv = tmp.firstElementChild;
|
||||||
|
actDiv.replaceWith(newActDiv);
|
||||||
|
newActDiv.querySelector("#printActBtn")?.addEventListener("click", () => window.print());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* === Кнопка: Сохранить === */
|
||||||
|
screen.querySelector("#btnSave")?.addEventListener("click", async () => {
|
||||||
|
haptic && haptic("impact");
|
||||||
|
const statusEl = screen.querySelector("#saveStatus");
|
||||||
|
statusEl.textContent = "Сохраняем…";
|
||||||
|
statusEl.style.color = "var(--muted)";
|
||||||
|
try {
|
||||||
|
const res = await _api("contract_save", {
|
||||||
|
assembly_id: asmId,
|
||||||
|
contract_num: extras.contract_num,
|
||||||
|
contract_date: extras.contract_date,
|
||||||
|
travel_spb: extras.travel_spb,
|
||||||
|
travel_outside: extras.travel_outside,
|
||||||
|
tech_list: extras.tech_list,
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
statusEl.textContent = "✅ Сохранено";
|
||||||
|
statusEl.style.color = "#27ae60";
|
||||||
|
setTimeout(() => { statusEl.textContent = ""; }, 3000);
|
||||||
|
} else {
|
||||||
|
throw new Error(res.error || "Ошибка сервера");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
statusEl.textContent = "❌ " + e.message;
|
||||||
|
statusEl.style.color = "#e74c3c";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* === Кнопка: Подписать акт === */
|
||||||
|
screen.querySelector("#btnSign")?.addEventListener("click", () => {
|
||||||
|
haptic && haptic("impact");
|
||||||
|
if (typeof SignRequest !== "undefined") {
|
||||||
|
SignRequest.open(asmId, {
|
||||||
|
clientName: asm.client_name || "",
|
||||||
|
clientTgId: asm.client_tg_id || null,
|
||||||
|
onSuccess: () => {
|
||||||
|
// Перезагружаем экран после успешной подписи
|
||||||
|
mount(container, asmId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
alert("Модуль подписания недоступен");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} // end render()
|
||||||
|
|
||||||
|
render();
|
||||||
|
} // end mount()
|
||||||
|
|
||||||
|
return { mount };
|
||||||
|
})();
|
||||||
@ -55,7 +55,9 @@
|
|||||||
<script src="assets/signrequest.js?v=20260518o"></script>
|
<script src="assets/signrequest.js?v=20260518o"></script>
|
||||||
<script src="assets/admin_rates.js?v=20260519a"></script>
|
<script src="assets/admin_rates.js?v=20260519a"></script>
|
||||||
<script src="assets/assembler_analytics.js?v=20260519a"></script>
|
<script src="assets/assembler_analytics.js?v=20260519a"></script>
|
||||||
<script src="assets/assembly_detail.js?v=20260519a"></script>
|
<script src="assets/assembler_dashboard.js?v=20260519b"></script>
|
||||||
<script src="assets/app.js?v=20260519a"></script>
|
<script src="assets/assembly_detail.js?v=20260519c"></script>
|
||||||
|
<script src="assets/contracts.js?v=20260519a"></script>
|
||||||
|
<script src="assets/app.js?v=20260519c"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user