mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 15:04:50 +00:00
feat(6.16): акт доп.работ — прайс каталог, создание, подпись canvas
- extra_acts.js: список актов, выбор позиций из PriceBook (компания + ИП), корзина, подпись canvas - assembly_detail.js: кнопка «Акт доп. работ» для сборщика и менеджера → #/assembly/:id/extra_acts - app.js: маршрут #/assembly/:id/extra_acts → ExtraActs.mount() - main.py: эндпоинты /api/pricebook_list, /api/extra_act_save, /api/extra_act_sign, /api/extra_acts_list - Sheets: PriceBook_Company (53 позиции), PriceBook_IP (21 позиция) — загружены Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7865b3f699
commit
47bb4ebabc
@ -1738,6 +1738,310 @@ def _handle_assembly_assign_dispatch(body):
|
|||||||
return {"ok": True, "status": "scheduled"}
|
return {"ok": True, "status": "scheduled"}
|
||||||
|
|
||||||
|
|
||||||
|
def _extra_act_columns():
|
||||||
|
return [
|
||||||
|
"id", "assembly_id", "created_by_tg_id",
|
||||||
|
"status", # draft | agreed | signed | cancelled
|
||||||
|
"items_json", # JSON: [{id, name, unit, price, qty, total, category, source}]
|
||||||
|
"total_amount",
|
||||||
|
"signed_by_name", "signed_via", "signed_at",
|
||||||
|
"signature_b64",
|
||||||
|
"client_agreed_at",
|
||||||
|
"notes",
|
||||||
|
"created_at", "updated_at",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_extra_acts_sheet():
|
||||||
|
try:
|
||||||
|
sheets.sheet("ExtraActs")
|
||||||
|
except Exception:
|
||||||
|
sheets.ensure_sheet("ExtraActs", _extra_act_columns())
|
||||||
|
return
|
||||||
|
ws = sheets.sheet("ExtraActs")
|
||||||
|
existing = ws.row_values(1)
|
||||||
|
want = _extra_act_columns()
|
||||||
|
missing = [c for c in want if c not in existing]
|
||||||
|
if missing:
|
||||||
|
ws.update("A1", [existing + missing])
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_pricebook_list(body):
|
||||||
|
"""Возвращает каталог для выбора позиций в акт."""
|
||||||
|
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 = str(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, "manager")):
|
||||||
|
return {"error": "forbidden"}
|
||||||
|
|
||||||
|
result = {"ok": True, "company": [], "personal": []}
|
||||||
|
|
||||||
|
try:
|
||||||
|
ws = sheets.sheet("PriceBook_Company")
|
||||||
|
rows = ws.get_all_values()
|
||||||
|
if rows and len(rows) > 1:
|
||||||
|
headers = rows[0]
|
||||||
|
for r in rows[1:]:
|
||||||
|
row = dict(zip(headers, r + [""] * max(0, len(headers) - len(r))))
|
||||||
|
if row.get("active", "yes") == "no":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
price = float(row.get("price", 0) or 0)
|
||||||
|
except Exception:
|
||||||
|
price = 0
|
||||||
|
result["company"].append({
|
||||||
|
"id": row.get("id", ""),
|
||||||
|
"category": row.get("category", ""),
|
||||||
|
"name": row.get("name", ""),
|
||||||
|
"unit": row.get("unit", "шт."),
|
||||||
|
"price": price,
|
||||||
|
"source": "company",
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("pricebook_company error: %s", e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ws = sheets.sheet("PriceBook_IP")
|
||||||
|
rows = ws.get_all_values()
|
||||||
|
if rows and len(rows) > 1:
|
||||||
|
headers = rows[0]
|
||||||
|
for r in rows[1:]:
|
||||||
|
row = dict(zip(headers, r + [""] * max(0, len(headers) - len(r))))
|
||||||
|
if row.get("active", "yes") == "no":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
price = float(row.get("price", 0) or 0)
|
||||||
|
except Exception:
|
||||||
|
price = 0
|
||||||
|
result["personal"].append({
|
||||||
|
"id": row.get("id", ""),
|
||||||
|
"category": row.get("category", ""),
|
||||||
|
"name": row.get("name", ""),
|
||||||
|
"unit": row.get("unit", "шт."),
|
||||||
|
"price": price,
|
||||||
|
"source": "ip",
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("pricebook_ip error: %s", e)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_extra_act_save(body):
|
||||||
|
"""Создать или обновить черновик акта доп.работ."""
|
||||||
|
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 = str(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, "manager")):
|
||||||
|
return {"error": "forbidden"}
|
||||||
|
|
||||||
|
assembly_id = (body.get("assembly_id") or "").strip()
|
||||||
|
items = body.get("items") or []
|
||||||
|
notes = (body.get("notes") or "").strip()
|
||||||
|
act_id = (body.get("act_id") or "").strip()
|
||||||
|
|
||||||
|
if not assembly_id:
|
||||||
|
return {"error": "missing_assembly_id"}
|
||||||
|
if not items:
|
||||||
|
return {"error": "missing_items"}
|
||||||
|
|
||||||
|
_ensure_extra_acts_sheet()
|
||||||
|
|
||||||
|
total = sum(
|
||||||
|
float(it.get("price", 0)) * max(1, int(it.get("qty", 1)))
|
||||||
|
for it in items
|
||||||
|
)
|
||||||
|
|
||||||
|
now = _now_iso()
|
||||||
|
if act_id:
|
||||||
|
existing = sheets.find_row("ExtraActs", "id", act_id)
|
||||||
|
if not existing or existing.get("created_by_tg_id") != tg_id:
|
||||||
|
return {"error": "not_found_or_forbidden"}
|
||||||
|
sheets.update_cell_by_key("ExtraActs", "id", act_id, "items_json", json.dumps(items, ensure_ascii=False))
|
||||||
|
sheets.update_cell_by_key("ExtraActs", "id", act_id, "total_amount", str(round(total, 2)))
|
||||||
|
sheets.update_cell_by_key("ExtraActs", "id", act_id, "notes", notes)
|
||||||
|
sheets.update_cell_by_key("ExtraActs", "id", act_id, "updated_at", now)
|
||||||
|
return {"ok": True, "act_id": act_id, "total": total}
|
||||||
|
else:
|
||||||
|
act_id = str(uuid.uuid4())[:8]
|
||||||
|
cols = _extra_act_columns()
|
||||||
|
base = {c: "" for c in cols}
|
||||||
|
base["id"] = act_id
|
||||||
|
base["assembly_id"] = assembly_id
|
||||||
|
base["created_by_tg_id"] = tg_id
|
||||||
|
base["status"] = "draft"
|
||||||
|
base["items_json"] = json.dumps(items, ensure_ascii=False)
|
||||||
|
base["total_amount"] = str(round(total, 2))
|
||||||
|
base["notes"] = notes
|
||||||
|
base["created_at"] = now
|
||||||
|
base["updated_at"] = now
|
||||||
|
sheets.append_row("ExtraActs", [str(base.get(c, "")) for c in cols])
|
||||||
|
|
||||||
|
# Уведомить менеджера
|
||||||
|
try:
|
||||||
|
asm = sheets.find_row("Assemblies", "id", assembly_id)
|
||||||
|
mgr_id = asm.get("manager_tg_id") if asm else None
|
||||||
|
if mgr_id and mgr_id != tg_id:
|
||||||
|
items_preview = "\n".join(
|
||||||
|
f"• {it.get('name','')} × {it.get('qty',1)} = {int(float(it.get('price',0))*int(it.get('qty',1)))} ₽"
|
||||||
|
for it in items[:5]
|
||||||
|
)
|
||||||
|
tg.send_message(int(mgr_id),
|
||||||
|
f"📋 <b>Акт доп.работ создан</b>\n"
|
||||||
|
f"Сборка: {assembly_id}\n"
|
||||||
|
f"Клиент: {asm.get('client_name','')}\n\n"
|
||||||
|
f"{items_preview}\n\n"
|
||||||
|
f"<b>Итого: {round(total):,} ₽</b>".replace(",", " "))
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("extra_act notify error: %s", e)
|
||||||
|
|
||||||
|
return {"ok": True, "act_id": act_id, "total": total}
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_extra_act_sign(body):
|
||||||
|
"""Сборщик подписывает акт (canvas или OTP)."""
|
||||||
|
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 = str(auth["user"]["id"])
|
||||||
|
user = sheets.find_user(tg_id)
|
||||||
|
if not user:
|
||||||
|
return {"error": "user_not_found"}
|
||||||
|
|
||||||
|
act_id = (body.get("act_id") or "").strip()
|
||||||
|
signed_via = (body.get("signed_via") or "canvas").strip() # canvas | telegram_otp
|
||||||
|
signature_b64 = (body.get("signature_b64") or "").strip()
|
||||||
|
signer_name = (body.get("signed_by_name") or "").strip()
|
||||||
|
|
||||||
|
if not act_id:
|
||||||
|
return {"error": "missing_act_id"}
|
||||||
|
|
||||||
|
_ensure_extra_acts_sheet()
|
||||||
|
act = sheets.find_row("ExtraActs", "id", act_id)
|
||||||
|
if not act:
|
||||||
|
return {"error": "act_not_found"}
|
||||||
|
if act.get("status") not in ("draft", "agreed"):
|
||||||
|
return {"error": "already_signed"}
|
||||||
|
|
||||||
|
name = signer_name or (user.get("name") or user.get("first_name") or tg_id)
|
||||||
|
now = _now_iso()
|
||||||
|
|
||||||
|
for col, val in [
|
||||||
|
("status", "signed"),
|
||||||
|
("signed_by_name", name),
|
||||||
|
("signed_via", signed_via),
|
||||||
|
("signed_at", now),
|
||||||
|
("signature_b64", signature_b64),
|
||||||
|
("updated_at", now),
|
||||||
|
]:
|
||||||
|
sheets.update_cell_by_key("ExtraActs", "id", act_id, col, val)
|
||||||
|
|
||||||
|
return {"ok": True, "signed": True, "signed_by_name": name}
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_extra_acts_list(body):
|
||||||
|
"""Список актов доп.работ по сборке."""
|
||||||
|
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 = str(auth["user"]["id"])
|
||||||
|
user = sheets.find_user(tg_id)
|
||||||
|
if not user:
|
||||||
|
return {"error": "user_not_found"}
|
||||||
|
|
||||||
|
assembly_id = (body.get("assembly_id") or "").strip()
|
||||||
|
|
||||||
|
_ensure_extra_acts_sheet()
|
||||||
|
try:
|
||||||
|
ws = sheets.sheet("ExtraActs")
|
||||||
|
rows = ws.get_all_values()
|
||||||
|
except Exception:
|
||||||
|
return {"ok": True, "acts": []}
|
||||||
|
|
||||||
|
if not rows or len(rows) < 2:
|
||||||
|
return {"ok": True, "acts": []}
|
||||||
|
|
||||||
|
headers = rows[0]
|
||||||
|
out = []
|
||||||
|
for r in rows[1:]:
|
||||||
|
row = dict(zip(headers, r + [""] * max(0, len(headers) - len(r))))
|
||||||
|
if assembly_id and row.get("assembly_id") != assembly_id:
|
||||||
|
continue
|
||||||
|
# Только свои или менеджер
|
||||||
|
if row.get("created_by_tg_id") != tg_id and not sheets.has_role(user, "manager"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
items = json.loads(row.get("items_json") or "[]")
|
||||||
|
except Exception:
|
||||||
|
items = []
|
||||||
|
out.append({
|
||||||
|
"id": row.get("id"),
|
||||||
|
"assembly_id": row.get("assembly_id"),
|
||||||
|
"status": row.get("status", "draft"),
|
||||||
|
"total_amount": row.get("total_amount", "0"),
|
||||||
|
"items_count": len(items),
|
||||||
|
"signed_by_name": row.get("signed_by_name"),
|
||||||
|
"signed_at": row.get("signed_at"),
|
||||||
|
"created_at": row.get("created_at"),
|
||||||
|
"notes": row.get("notes"),
|
||||||
|
})
|
||||||
|
|
||||||
|
out.sort(key=lambda x: x.get("created_at", ""), reverse=True)
|
||||||
|
return {"ok": True, "acts": out}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/pricebook_list")
|
||||||
|
async def api_pricebook_list(request: Request):
|
||||||
|
body = await _safe_json(request)
|
||||||
|
return JSONResponse(_handle_pricebook_list(body))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/extra_act_save")
|
||||||
|
async def api_extra_act_save(request: Request):
|
||||||
|
body = await _safe_json(request)
|
||||||
|
return JSONResponse(_handle_extra_act_save(body))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/extra_act_sign")
|
||||||
|
async def api_extra_act_sign(request: Request):
|
||||||
|
body = await _safe_json(request)
|
||||||
|
return JSONResponse(_handle_extra_act_sign(body))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/extra_acts_list")
|
||||||
|
async def api_extra_acts_list(request: Request):
|
||||||
|
body = await _safe_json(request)
|
||||||
|
return JSONResponse(_handle_extra_acts_list(body))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/dispatcher_inbox")
|
@app.post("/api/dispatcher_inbox")
|
||||||
async def api_dispatcher_inbox(request: Request):
|
async def api_dispatcher_inbox(request: Request):
|
||||||
body = await _safe_json(request)
|
body = await _safe_json(request)
|
||||||
|
|||||||
@ -1972,7 +1972,11 @@ function routeByHash() {
|
|||||||
const asmId = location.hash.replace("#/expeditor/act/", "").split("?")[0];
|
const asmId = location.hash.replace("#/expeditor/act/", "").split("?")[0];
|
||||||
if (typeof ExpeditorDashboard !== "undefined") ExpeditorDashboard.mountAct(app, asmId);
|
if (typeof ExpeditorDashboard !== "undefined") ExpeditorDashboard.mountAct(app, asmId);
|
||||||
else init();
|
else init();
|
||||||
} else if (location.hash.startsWith("#/assembly/") && location.hash.endsWith("/act4")) {
|
} else if (location.hash.startsWith("#/assembly/") && location.hash.endsWith("/extra_acts")) {
|
||||||
|
const asmId = location.hash.split("/")[2];
|
||||||
|
if (typeof ExtraActs !== "undefined") ExtraActs.mount(app, asmId);
|
||||||
|
else init();
|
||||||
|
} else if (location.hash.startsWith("#/assembly/") && location.hash.endsWith("/act4")) {
|
||||||
const asmId = location.hash.split("/")[2];
|
const asmId = location.hash.split("/")[2];
|
||||||
if (typeof Act4Screen !== "undefined") Act4Screen.mount(app, asmId);
|
if (typeof Act4Screen !== "undefined") Act4Screen.mount(app, asmId);
|
||||||
else init();
|
else init();
|
||||||
|
|||||||
@ -961,6 +961,22 @@ const AssemblyDetailScreen = (function () {
|
|||||||
act4Wrap.appendChild(act4Btn);
|
act4Wrap.appendChild(act4Btn);
|
||||||
screen.appendChild(act4Wrap);
|
screen.appendChild(act4Wrap);
|
||||||
|
|
||||||
|
// Кнопка «Акт доп.работ» — для сборщика и менеджера
|
||||||
|
if (data.viewer_is_assembler || data.viewer_is_manager) {
|
||||||
|
const extraWrap = document.createElement("div");
|
||||||
|
extraWrap.style.cssText = "margin:8px 16px 0;";
|
||||||
|
const extraBtn = document.createElement("button");
|
||||||
|
extraBtn.className = "btn-secondary";
|
||||||
|
extraBtn.style.cssText = "width:100%;font-size:14px;padding:11px;";
|
||||||
|
extraBtn.textContent = "📋 Акт доп. работ";
|
||||||
|
extraBtn.addEventListener("click", () => {
|
||||||
|
haptic && haptic("impact");
|
||||||
|
location.hash = `#/assembly/${data.id}/extra_acts`;
|
||||||
|
});
|
||||||
|
extraWrap.appendChild(extraBtn);
|
||||||
|
screen.appendChild(extraWrap);
|
||||||
|
}
|
||||||
|
|
||||||
// Кнопка «Акт сдачи-приёмки» — для менеджера всегда доступна
|
// Кнопка «Акт сдачи-приёмки» — для менеджера всегда доступна
|
||||||
const actWrap = document.createElement("div");
|
const actWrap = document.createElement("div");
|
||||||
actWrap.style.cssText = "margin:8px 16px 0;";
|
actWrap.style.cssText = "margin:8px 16px 0;";
|
||||||
|
|||||||
321
miniapp/assets/extra_acts.js
Normal file
321
miniapp/assets/extra_acts.js
Normal file
@ -0,0 +1,321 @@
|
|||||||
|
// extra_acts.js v=20260521a
|
||||||
|
const ExtraActs = (function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
function fmt(n) { return Math.round(n||0).toLocaleString("ru-RU") + " ₽"; }
|
||||||
|
function fmtDate(iso) { if(!iso) return "—"; const d=iso.slice(0,10).split("-"); return d[2]+"."+d[1]+"."+d[0]; }
|
||||||
|
function el(html) { const t=document.createElement("div"); t.innerHTML=html.trim(); return t.firstChild; }
|
||||||
|
function showErr(c,msg){ c.innerHTML=`<div style="padding:32px;text-align:center;color:var(--danger)">${msg}</div>`; }
|
||||||
|
|
||||||
|
const STATUS = { draft:"Черновик", agreed:"Согласован", signed:"Подписан", cancelled:"Отменён" };
|
||||||
|
const STATUS_BG = { draft:"#eee", agreed:"#CCE5FF", signed:"#D1E7DD", cancelled:"#f8d7da" };
|
||||||
|
const STATUS_FG = { draft:"#555", agreed:"#004085", signed:"#0F5132", cancelled:"#721c24" };
|
||||||
|
|
||||||
|
function badge(status) {
|
||||||
|
return `<span style="background:${STATUS_BG[status]||'#eee'};color:${STATUS_FG[status]||'#333'};padding:2px 8px;border-radius:6px;font-size:12px;font-weight:600">${STATUS[status]||status}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── LIST ─────────────────────────────────────────────────────────
|
||||||
|
async function mount(container, assemblyId) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;padding:16px;background:var(--surface);border-bottom:1px solid #eee;position:sticky;top:0;z-index:10">
|
||||||
|
<button onclick="history.back()" style="background:none;border:none;font-size:22px;cursor:pointer;padding:0">←</button>
|
||||||
|
<span style="font-size:17px;font-weight:700;flex:1">📋 Доп. работы</span>
|
||||||
|
<button id="new-act-btn" style="background:var(--accent);color:#fff;border:none;border-radius:8px;padding:8px 14px;font-size:14px;font-weight:600;cursor:pointer">+ Новый акт</button>
|
||||||
|
</div>
|
||||||
|
<div id="list-body" style="padding:12px"></div>`;
|
||||||
|
|
||||||
|
container.querySelector("#new-act-btn").addEventListener("click", () => mountCreate(container, assemblyId));
|
||||||
|
|
||||||
|
const body = container.querySelector("#list-body");
|
||||||
|
body.innerHTML = `<div style="text-align:center;padding:40px;color:var(--muted)">Загрузка…</div>`;
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try { data = await _api("/api/extra_acts_list", { assembly_id: assemblyId }); }
|
||||||
|
catch(e) { showErr(body, "Ошибка: "+e.message); return; }
|
||||||
|
if (data.error) { showErr(body, data.error); return; }
|
||||||
|
|
||||||
|
const acts = data.acts || [];
|
||||||
|
if (!acts.length) {
|
||||||
|
body.innerHTML = `<div style="text-align:center;padding:40px;color:var(--muted)">Нет актов по этой сборке</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.innerHTML = "";
|
||||||
|
acts.forEach(a => {
|
||||||
|
const card = el(`<div style="background:var(--surface);border-radius:var(--radius);padding:14px 16px;margin-bottom:10px;box-shadow:0 1px 4px rgba(0,0,0,.07)">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
|
||||||
|
<span style="font-weight:600;font-size:15px">${fmt(parseFloat(a.total_amount||0))}</span>
|
||||||
|
${badge(a.status)}
|
||||||
|
</div>
|
||||||
|
<div style="font-size:13px;color:var(--muted)">${a.items_count} позиций · ${fmtDate(a.created_at)}</div>
|
||||||
|
${a.signed_by_name ? `<div style="font-size:12px;color:var(--muted);margin-top:2px">Подписал: ${a.signed_by_name} · ${fmtDate(a.signed_at)}</div>` : ""}
|
||||||
|
${a.notes ? `<div style="font-size:12px;color:var(--muted);margin-top:4px;font-style:italic">${a.notes}</div>` : ""}
|
||||||
|
</div>`);
|
||||||
|
body.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── CREATE ───────────────────────────────────────────────────────
|
||||||
|
async function mountCreate(container, assemblyId) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;padding:16px;background:var(--surface);border-bottom:1px solid #eee;position:sticky;top:0;z-index:10">
|
||||||
|
<button id="back-btn" style="background:none;border:none;font-size:22px;cursor:pointer;padding:0">←</button>
|
||||||
|
<span style="font-size:17px;font-weight:700">➕ Новый акт</span>
|
||||||
|
</div>
|
||||||
|
<div id="catalog-body" style="padding:12px;padding-bottom:180px"></div>
|
||||||
|
<div id="basket-panel" style="position:fixed;bottom:0;left:0;right:0;max-width:600px;margin:0 auto;background:var(--surface);border-top:2px solid var(--accent);padding:12px 16px;z-index:20;box-shadow:0 -2px 12px rgba(0,0,0,.1)"></div>`;
|
||||||
|
|
||||||
|
container.querySelector("#back-btn").addEventListener("click", () => mount(container, assemblyId));
|
||||||
|
|
||||||
|
const catalogBody = container.querySelector("#catalog-body");
|
||||||
|
const basketPanel = container.querySelector("#basket-panel");
|
||||||
|
|
||||||
|
// Basket state
|
||||||
|
const basket = {}; // id -> {item, qty}
|
||||||
|
const updateBasket = () => _renderBasket(basketPanel, basket, assemblyId, container);
|
||||||
|
updateBasket();
|
||||||
|
|
||||||
|
catalogBody.innerHTML = `<div style="text-align:center;padding:40px;color:var(--muted)">Загрузка каталога…</div>`;
|
||||||
|
|
||||||
|
let pb;
|
||||||
|
try { pb = await _api("/api/pricebook_list", {}); }
|
||||||
|
catch(e) { showErr(catalogBody, "Ошибка: "+e.message); return; }
|
||||||
|
if (pb.error) { showErr(catalogBody, pb.error); return; }
|
||||||
|
|
||||||
|
catalogBody.innerHTML = "";
|
||||||
|
_renderSection(catalogBody, "Прайс компании", pb.company||[], basket, updateBasket);
|
||||||
|
_renderSection(catalogBody, "Мой прайс (ИП)", pb.personal||[], basket, updateBasket);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderSection(parent, title, items, basket, updateBasket) {
|
||||||
|
if (!items.length) return;
|
||||||
|
|
||||||
|
const section = el(`<div style="margin-bottom:16px">
|
||||||
|
<div style="font-size:14px;font-weight:700;color:var(--accent);padding:8px 0;border-bottom:2px solid var(--accent);margin-bottom:8px">${title}</div>
|
||||||
|
</div>`);
|
||||||
|
|
||||||
|
// Group by category
|
||||||
|
const cats = {};
|
||||||
|
items.forEach(it => { (cats[it.category] = cats[it.category]||[]).push(it); });
|
||||||
|
|
||||||
|
Object.entries(cats).forEach(([cat, catItems]) => {
|
||||||
|
const catWrap = el(`<div style="margin-bottom:8px">`);
|
||||||
|
const catHead = el(`<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 10px;background:#f0f4ff;border-radius:8px;cursor:pointer;margin-bottom:4px">
|
||||||
|
<span style="font-size:13px;font-weight:600;color:var(--text)">${cat}</span>
|
||||||
|
<span class="cat-arrow" style="font-size:14px;color:var(--muted)">▼</span>
|
||||||
|
</div>`);
|
||||||
|
const catList = el(`<div class="cat-list" style="display:none">`);
|
||||||
|
|
||||||
|
catHead.addEventListener("click", () => {
|
||||||
|
const shown = catList.style.display !== "none";
|
||||||
|
catList.style.display = shown ? "none" : "block";
|
||||||
|
catHead.querySelector(".cat-arrow").textContent = shown ? "▼" : "▲";
|
||||||
|
});
|
||||||
|
|
||||||
|
catItems.forEach(item => {
|
||||||
|
const row = el(`<div style="display:flex;align-items:center;gap:8px;padding:8px 4px;border-bottom:1px solid #f0f0f0">
|
||||||
|
<div style="flex:1;min-width:0">
|
||||||
|
<div style="font-size:13px;color:var(--text);line-height:1.3">${item.name}</div>
|
||||||
|
<div style="font-size:11px;color:var(--muted)">${item.unit} · ${fmt(item.price)}</div>
|
||||||
|
</div>
|
||||||
|
<button data-id="${item.id}" style="background:var(--accent);color:#fff;border:none;border-radius:6px;width:30px;height:30px;font-size:18px;cursor:pointer;flex-shrink:0;display:flex;align-items:center;justify-content:center;line-height:1">+</button>
|
||||||
|
</div>`);
|
||||||
|
row.querySelector("button").addEventListener("click", () => {
|
||||||
|
if (basket[item.id]) {
|
||||||
|
basket[item.id].qty++;
|
||||||
|
} else {
|
||||||
|
basket[item.id] = { item, qty: 1 };
|
||||||
|
}
|
||||||
|
haptic && haptic("impact");
|
||||||
|
// Flash button
|
||||||
|
const btn = row.querySelector("button");
|
||||||
|
btn.style.background = "#4CAF50";
|
||||||
|
setTimeout(() => { btn.style.background = "var(--accent)"; }, 200);
|
||||||
|
updateBasket();
|
||||||
|
});
|
||||||
|
catList.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
catWrap.appendChild(catHead);
|
||||||
|
catWrap.appendChild(catList);
|
||||||
|
section.appendChild(catWrap);
|
||||||
|
});
|
||||||
|
parent.appendChild(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderBasket(panel, basket, assemblyId, container) {
|
||||||
|
const entries = Object.values(basket).filter(e => e.qty > 0);
|
||||||
|
const total = entries.reduce((s, e) => s + e.item.price * e.qty, 0);
|
||||||
|
|
||||||
|
if (!entries.length) {
|
||||||
|
panel.innerHTML = `<div style="color:var(--muted);font-size:13px;text-align:center;padding:4px 0">Добавьте позиции из каталога</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.innerHTML = "";
|
||||||
|
|
||||||
|
// Items compact list (max 3 visible)
|
||||||
|
const listWrap = el(`<div style="max-height:80px;overflow-y:auto;margin-bottom:8px">`);
|
||||||
|
entries.forEach(({ item, qty }) => {
|
||||||
|
const row = el(`<div style="display:flex;align-items:center;gap:6px;padding:3px 0;font-size:13px">
|
||||||
|
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${item.name}</span>
|
||||||
|
<button data-id="${item.id}" class="qty-minus" style="background:#eee;border:none;border-radius:4px;width:22px;height:22px;cursor:pointer;font-size:14px">−</button>
|
||||||
|
<span style="min-width:16px;text-align:center">${qty}</span>
|
||||||
|
<button data-id="${item.id}" class="qty-plus" style="background:#eee;border:none;border-radius:4px;width:22px;height:22px;cursor:pointer;font-size:14px">+</button>
|
||||||
|
<span style="min-width:60px;text-align:right;color:var(--accent);font-weight:600">${fmt(item.price*qty)}</span>
|
||||||
|
<button data-id="${item.id}" class="qty-del" style="background:none;border:none;cursor:pointer;font-size:16px;color:var(--muted)">×</button>
|
||||||
|
</div>`);
|
||||||
|
row.querySelector(".qty-minus").addEventListener("click", () => {
|
||||||
|
basket[item.id].qty = Math.max(0, basket[item.id].qty - 1);
|
||||||
|
if (basket[item.id].qty === 0) delete basket[item.id];
|
||||||
|
_renderBasket(panel, basket, assemblyId, container);
|
||||||
|
});
|
||||||
|
row.querySelector(".qty-plus").addEventListener("click", () => {
|
||||||
|
basket[item.id].qty++; _renderBasket(panel, basket, assemblyId, container);
|
||||||
|
});
|
||||||
|
row.querySelector(".qty-del").addEventListener("click", () => {
|
||||||
|
delete basket[item.id]; _renderBasket(panel, basket, assemblyId, container);
|
||||||
|
});
|
||||||
|
listWrap.appendChild(row);
|
||||||
|
});
|
||||||
|
panel.appendChild(listWrap);
|
||||||
|
|
||||||
|
// Textarea note
|
||||||
|
const noteArea = el(`<textarea id="act-notes" placeholder="Примечание (необязательно)" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:8px;font-size:13px;resize:none;height:40px;box-sizing:border-box;margin-bottom:8px;font-family:inherit"></textarea>`);
|
||||||
|
panel.appendChild(noteArea);
|
||||||
|
|
||||||
|
// Total + button
|
||||||
|
const foot = el(`<div style="display:flex;align-items:center;justify-content:space-between;gap:10px">
|
||||||
|
<div style="font-size:16px;font-weight:700;color:var(--accent)">${fmt(total)}</div>
|
||||||
|
<button id="act-submit" style="background:var(--accent);color:#fff;border:none;border-radius:8px;padding:10px 20px;font-size:15px;font-weight:600;cursor:pointer">Оформить акт →</button>
|
||||||
|
</div>`);
|
||||||
|
foot.querySelector("#act-submit").addEventListener("click", async () => {
|
||||||
|
const notes = panel.querySelector("#act-notes")?.value || "";
|
||||||
|
const items = Object.values(basket).map(({ item, qty }) => ({
|
||||||
|
id: item.id, name: item.name, unit: item.unit,
|
||||||
|
price: item.price, qty, total: item.price * qty,
|
||||||
|
category: item.category, source: item.source
|
||||||
|
}));
|
||||||
|
haptic && haptic("impact");
|
||||||
|
const btn = foot.querySelector("#act-submit");
|
||||||
|
btn.disabled = true; btn.textContent = "Сохраняем…";
|
||||||
|
let res;
|
||||||
|
try { res = await _api("/api/extra_act_save", { assembly_id: assemblyId, items, notes }); }
|
||||||
|
catch(e) { alert("Ошибка: "+e.message); btn.disabled=false; btn.textContent="Оформить акт →"; return; }
|
||||||
|
if (res.error) { alert(res.error); btn.disabled=false; btn.textContent="Оформить акт →"; return; }
|
||||||
|
_mountSign(container, res.act_id, assemblyId, res.total, items.length);
|
||||||
|
});
|
||||||
|
panel.appendChild(foot);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SIGN ─────────────────────────────────────────────────────────
|
||||||
|
function _mountSign(container, actId, assemblyId, total, itemCount) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;padding:16px;background:var(--surface);border-bottom:1px solid #eee;position:sticky;top:0;z-index:10">
|
||||||
|
<button onclick="history.back()" style="background:none;border:none;font-size:22px;cursor:pointer;padding:0">←</button>
|
||||||
|
<span style="font-size:17px;font-weight:700">Подписать акт</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:16px">
|
||||||
|
<div style="background:var(--surface);border-radius:var(--radius);padding:20px;text-align:center;margin-bottom:16px;box-shadow:0 1px 4px rgba(0,0,0,.07)">
|
||||||
|
<div style="font-size:13px;color:var(--muted);margin-bottom:4px">${itemCount} позиций</div>
|
||||||
|
<div style="font-size:28px;font-weight:700;color:var(--accent)">${fmt(total)}</div>
|
||||||
|
<div style="font-size:12px;color:var(--muted);margin-top:4px">Акт № ${actId}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:0;margin-bottom:16px;border-radius:var(--radius);overflow:hidden;border:1px solid #ddd">
|
||||||
|
<button id="tab-canvas" class="sign-tab" style="flex:1;padding:10px;background:var(--accent);color:#fff;border:none;font-size:14px;font-weight:600;cursor:pointer">✍️ Подпись</button>
|
||||||
|
<button id="tab-draft" class="sign-tab" style="flex:1;padding:10px;background:var(--surface);color:var(--text);border:none;font-size:14px;cursor:pointer">💾 Черновик</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="panel-canvas">
|
||||||
|
<canvas id="sign-canvas" style="width:100%;height:160px;border:2px solid #ddd;border-radius:var(--radius);touch-action:none;background:#fff;display:block"></canvas>
|
||||||
|
<div style="display:flex;gap:8px;margin-top:10px">
|
||||||
|
<button id="clear-btn" style="flex:1;padding:10px;background:#eee;border:none;border-radius:8px;font-size:14px;cursor:pointer">Очистить</button>
|
||||||
|
<button id="sign-btn" style="flex:2;padding:10px;background:var(--accent);color:#fff;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer">Подписать акт</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="panel-draft" style="display:none">
|
||||||
|
<div style="color:var(--muted);font-size:13px;text-align:center;padding:16px">Акт будет сохранён как черновик. Подпись можно добавить позже.</div>
|
||||||
|
<button id="draft-btn" style="width:100%;padding:12px;background:#eee;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer">Сохранить черновик</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="sign-result" style="display:none"></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// Tabs
|
||||||
|
container.querySelector("#tab-canvas").addEventListener("click", () => {
|
||||||
|
container.querySelector("#panel-canvas").style.display = "block";
|
||||||
|
container.querySelector("#panel-draft").style.display = "none";
|
||||||
|
container.querySelector("#tab-canvas").style.background = "var(--accent)";
|
||||||
|
container.querySelector("#tab-canvas").style.color = "#fff";
|
||||||
|
container.querySelector("#tab-draft").style.background = "var(--surface)";
|
||||||
|
container.querySelector("#tab-draft").style.color = "var(--text)";
|
||||||
|
});
|
||||||
|
container.querySelector("#tab-draft").addEventListener("click", () => {
|
||||||
|
container.querySelector("#panel-canvas").style.display = "none";
|
||||||
|
container.querySelector("#panel-draft").style.display = "block";
|
||||||
|
container.querySelector("#tab-draft").style.background = "var(--accent)";
|
||||||
|
container.querySelector("#tab-draft").style.color = "#fff";
|
||||||
|
container.querySelector("#tab-canvas").style.background = "var(--surface)";
|
||||||
|
container.querySelector("#tab-canvas").style.color = "var(--text)";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Canvas setup
|
||||||
|
const canvas = container.querySelector("#sign-canvas");
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
setTimeout(() => {
|
||||||
|
const w = canvas.offsetWidth; const h = canvas.offsetHeight;
|
||||||
|
canvas.width = w * dpr; canvas.height = h * dpr;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
ctx.strokeStyle = "#212121"; ctx.lineWidth = 2.5;
|
||||||
|
ctx.lineCap = "round"; ctx.lineJoin = "round";
|
||||||
|
let drawing = false;
|
||||||
|
|
||||||
|
const pos = (e) => {
|
||||||
|
const r = canvas.getBoundingClientRect();
|
||||||
|
const src = e.touches ? e.touches[0] : e;
|
||||||
|
return { x: (src.clientX - r.left), y: (src.clientY - r.top) };
|
||||||
|
};
|
||||||
|
canvas.addEventListener("pointerdown", e => { drawing=true; ctx.beginPath(); const p=pos(e); ctx.moveTo(p.x,p.y); e.preventDefault(); });
|
||||||
|
canvas.addEventListener("pointermove", e => { if(!drawing) return; const p=pos(e); ctx.lineTo(p.x,p.y); ctx.stroke(); e.preventDefault(); });
|
||||||
|
canvas.addEventListener("pointerup", () => { drawing=false; });
|
||||||
|
canvas.addEventListener("pointerleave", () => { drawing=false; });
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
container.querySelector("#clear-btn").addEventListener("click", () => {
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
});
|
||||||
|
|
||||||
|
const _doSign = async (via, b64) => {
|
||||||
|
haptic && haptic("impact");
|
||||||
|
let res;
|
||||||
|
try { res = await _api("/api/extra_act_sign", { act_id: actId, signed_via: via, signature_b64: b64 }); }
|
||||||
|
catch(e) { alert("Ошибка: "+e.message); return; }
|
||||||
|
if (res.error) { alert(res.error); return; }
|
||||||
|
_showSuccess(container, actId, assemblyId, total, via === "canvas");
|
||||||
|
};
|
||||||
|
|
||||||
|
container.querySelector("#sign-btn").addEventListener("click", () => {
|
||||||
|
_doSign("canvas", canvas.toDataURL("image/png"));
|
||||||
|
});
|
||||||
|
container.querySelector("#draft-btn").addEventListener("click", () => {
|
||||||
|
mount(container, assemblyId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showSuccess(container, actId, assemblyId, total, signed) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:60vh;padding:32px;text-align:center">
|
||||||
|
<div style="font-size:56px;margin-bottom:16px">${signed ? "✅" : "💾"}</div>
|
||||||
|
<div style="font-size:20px;font-weight:700;margin-bottom:8px">${signed ? "Акт подписан" : "Черновик сохранён"}</div>
|
||||||
|
<div style="font-size:24px;font-weight:700;color:var(--accent);margin-bottom:24px">${fmt(total)}</div>
|
||||||
|
<div style="font-size:13px;color:var(--muted);margin-bottom:32px">Акт № ${actId}</div>
|
||||||
|
<button onclick="history.back()" style="padding:14px 32px;background:var(--accent);color:#fff;border:none;border-radius:10px;font-size:16px;font-weight:600;cursor:pointer">← Назад</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mount, mountCreate };
|
||||||
|
})();
|
||||||
Loading…
Reference in New Issue
Block a user