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:
wasrusgen 2026-05-19 12:23:44 +03:00
parent 76fce9ec58
commit 21fd0ff3e5
7 changed files with 944 additions and 5 deletions

View File

@ -116,6 +116,9 @@ def parse_sheet(ws) -> list[dict]:
for ri, row in enumerate(rows):
if ri <= date_row_idx:
continue
if len(row) < 3:
current_assembler = None
continue
col_a = str(row[0] or "").strip().lower()
col_b = str(row[1] or "").strip()

View File

@ -153,6 +153,9 @@ async def _dispatch_post(request: Request):
"assembly_rate_save": _handle_assembly_rate_save,
"assembly_rate_delete": _handle_assembly_rate_delete,
"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_create": proposals_mod.handle_create,
"proposal_upsert_variant": proposals_mod.handle_upsert_variant,
@ -2637,6 +2640,22 @@ _rates_cache: dict = {"data": None, "ts": 0.0}
_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:
try:
ws = sheets._ws("Assembly_Rates")
@ -2965,6 +2984,227 @@ async def api_assembler_analytics(request: Request):
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 ПЭП)
# =================================================================

View File

@ -912,10 +912,23 @@ async function renderStaff(me) {
renderStaffAssemblies(assemblySection.querySelector("#assemblyList"));
}
// Шпаргалки сборщика — прайс, рейки, полкодержатели
// Шпаргалки + заработки сборщика
if (caps.assembler) {
const toolsBtn = el(`
const earningsBtn = el(`
<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>
@ -1703,6 +1716,13 @@ function routeByHash() {
} else if (location.hash.startsWith("#/admin/rates")) {
if (typeof AdminRates !== "undefined") AdminRates.mount(app);
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")) {
if (typeof MasterTools !== "undefined") {
const h = location.hash;

View File

@ -0,0 +1,225 @@
/* ============================================================
AssemblerDashboard личная аналитика сборщика
#/master/dashboard
============================================================ */
const AssemblerDashboard = (function () {
"use strict";
function escHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function el(html) {
const t = document.createElement("template");
t.innerHTML = html.trim();
return t.content.firstChild;
}
function fmtMoney(n) {
return Math.round(n || 0).toLocaleString("ru-RU") + " ₽";
}
function fmtMonth(ym) {
try {
const d = new Date(ym + "-01");
return d.toLocaleDateString("ru-RU", { month: "long", year: "numeric" });
} catch { return ym; }
}
async function _api(path, body = {}) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 30000);
try {
const res = await fetch(`${BACKEND_URL}/api/${path}`, {
method: "POST", signal: ctrl.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
initData: (typeof Platform !== "undefined" ? Platform.initData : (window.tg?.initData || "")),
initDataUnsafe: (typeof Platform !== "undefined" ? Platform.initDataUnsafe : null),
...body,
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (e) {
if (e.name === "AbortError") throw new Error("Таймаут — попробуй ещё раз");
throw e;
} finally { clearTimeout(t); }
}
/* ── Главный экран ─────────────────────────────────────────── */
async function mount(container) {
container.innerHTML = "";
document.body.classList.remove("has-bottom-nav");
document.getElementById("bottom-nav")?.remove();
const h = el(`
<header class="podbor-header">
<button class="podbor-back" 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 };
})();

View File

@ -192,6 +192,20 @@ const AssemblyDetailScreen = (function () {
screen.innerHTML = statusBanner + mainBlock + noteBlock + photosBlock + signBlock + calBtn +
`<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) {
const btnWrap = screen.querySelector("#sr-sign-btn-wrap");
@ -207,7 +221,6 @@ const AssemblyDetailScreen = (function () {
clientName: data.client_name || "",
clientTgId: data.client_tg_id || "",
onSuccess: () => {
// Перерисовываем экран после подписания
mount(container, assemblyId);
},
});

436
miniapp/assets/contracts.js Normal file
View 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, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function el(html) {
const t = document.createElement("template");
t.innerHTML = html.trim();
return t.content.firstChild;
}
function fmtMoney(n) {
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 };
})();

View File

@ -55,7 +55,9 @@
<script src="assets/signrequest.js?v=20260518o"></script>
<script src="assets/admin_rates.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/app.js?v=20260519a"></script>
<script src="assets/assembler_dashboard.js?v=20260519b"></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>
</html>