mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 15:04:50 +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):
|
||||
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()
|
||||
|
||||
@ -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 ПЭП)
|
||||
# =================================================================
|
||||
|
||||
@ -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;
|
||||
|
||||
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 +
|
||||
`<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
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/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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user