/* ============================================================
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, """);
}
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()
? `
Перечень техники, подлежащей бесплатной установке:
${escHtml(tech_list.trim())}
`
: "";
return `
АКТ СДАЧИ-ПРИЁМКИ РАБОТ
по договору на сборку и установку мебели
№${escHtml(contract_num)} от ${escHtml(dp.day)} ${escHtml(dp.month)} ${escHtml(dp.year)} г.
г. Санкт-Петербург
«${escHtml(dp.day)}» ${escHtml(dp.month)} ${escHtml(dp.year)} г.
Индивидуальный предприниматель, именуемый в дальнейшем «Исполнитель», с одной стороны
и ${escHtml(client_name || "—")} , именуемый(ая) в дальнейшем «Заказчик»
с другой стороны, составили настоящий акт сдачи-приёмки работ о нижеследующем:
Работы по установке мебели на объекте Заказчика по адресу: ${escHtml(address || "—")}
по договору №${escHtml(contract_num)} от ${escHtml(dp.day)} ${escHtml(dp.month)} ${escHtml(dp.year)} г.,
на общую сумму ${escHtml(totalFmt)} ,
выполнены Исполнителем в полном объёме надлежащего качества.
Стоимость услуг по сборке и установке:
${escHtml(asmFmt)}
Стоимость выезда сборщика по СПб:
${escHtml(spbFmt)}
Стоимость выезда сборщика за пределы условной границы СПб:
${escHtml(outsideFmt)}
Стороны не имеют претензий друг к другу по исполнению Договора, в том числе
по срокам выполнения работ, качеству и объёму работ.
Настоящий акт составлен в двух экземплярах.
${techBlock}
ВНИМАНИЕ! Перед подписанием акта тщательно осмотрите мебель на предмет возможных
недостатков. После подписания акта приёмки претензии по качеству не принимаются.
При наличии вопросов обращайтесь в отдел сервиса: +7-952-379-63-25
ЗАКАЗЧИК _________________ / ${escHtml(client_name || "—")}
ИСПОЛНИТЕЛЬ _______________ / Васильев Р.Г.
📄 Предпросмотр акта
`;
}
/* ── Главный экран ─────────────────────────────────────────── */
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.querySelector(".podbor-back").addEventListener("click", () => {
haptic && haptic("impact");
history.back();
});
const screen = el(`
`);
container.appendChild(header);
container.appendChild(screen);
/* Loader */
screen.innerHTML = ``;
/* Загружаем данные */
let data;
try {
data = await _api("contract_preview", { assembly_id: asmId });
} catch (e) {
screen.innerHTML = `
Ошибка загрузки: ${escHtml(e.message)}
`;
return;
}
if (!data.ok) {
screen.innerHTML = `
${escHtml(data.error || "Не удалось загрузить данные")}
`;
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(`
`);
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(`
✅
Акт подписан
${escHtml(asm.signed_by_name)}
${asm.signed_at ? " · " + escHtml(asm.signed_at) : ""}
`);
screen.appendChild(signedBadge);
}
/* === Блок: Дополнительные данные === */
const extrasSection = el(`
`);
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 };
})();