/* ============================================================ Детальная карточка сборки — #/c/assembly/:id Доступна клиенту, менеджеру, мастеру. v20260519o ============================================================ */ const AssemblyDetailScreen = (function () { function escHtml(s) { return String(s == null ? "" : s) .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function fmtDate(iso) { if (!iso) return null; try { return new Date(iso).toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric", hour: "2-digit", minute: "2-digit" }); } catch { return iso.slice(0, 16).replace("T", " "); } } async function _api(path, body = {}) { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), 15000); try { const res = await fetch(`${BACKEND_URL}/api/${path}`, { method: "POST", signal: ctrl.signal, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null, ...body }), }); if (!res.ok) throw new Error(`Ошибка сервера (${res.status})`); return await res.json(); } catch (e) { if (e.name === "AbortError") throw new Error("Сервер не отвечает"); throw e; } finally { clearTimeout(t); } } const STATUS = { created: { icon: "🆕", text: "Создана", color: "#8e8e8e" }, scheduled: { icon: "📅", text: "Запланирована", color: "#2980B9" }, in_progress: { icon: "🔨", text: "В процессе", color: "#F39C12" }, done: { icon: "✅", text: "Завершена", color: "#27AE60" }, cancelled: { icon: "❌", text: "Отменена", color: "#C0392B" }, }; function row(label, value, opts = {}) { if (!value) return ""; return `
${escHtml(label)}
${opts.html ? value : escHtml(value)}
`; } async function mount(container, assemblyId) { container.innerHTML = ""; document.body.classList.remove("has-bottom-nav"); const oldNav = document.getElementById("bottom-nav"); if (oldNav) oldNav.remove(); // Header const h = document.createElement("header"); h.className = "podbor-header"; h.innerHTML = `
Сборка кухни
`; h.querySelector(".podbor-back").addEventListener("click", () => { haptic && haptic("impact"); history.back(); }); container.appendChild(h); const screen = document.createElement("div"); screen.className = "podbor-screen"; screen.innerHTML = `
`; container.appendChild(screen); try { const data = await _api("assembly_detail", { assembly_id: assemblyId }); if (data.error) { screen.innerHTML = `
${escHtml(data.error)}
`; return; } const sl = STATUS[data.status] || { icon: "🔧", text: data.status, color: "#8e8e8e" }; // Статус-баннер const statusBanner = `
${sl.icon}
${escHtml(sl.text)}
ID: ${escHtml(data.id)}
`; // Финансовый блок — ставки из backend (настраиваются в админке) const _kp = data.kitchen_price ? Number(data.kitchen_price) : 0; const _cr = data.client_rate_pct || 10; const _ar = data.assembler_rate_pct || 9; const _cp = data.assembly_price_for_client != null ? Number(data.assembly_price_for_client) : (_kp ? Math.round(_kp * _cr / 100) : 0); const _ap = data.assembler_payout != null ? Number(data.assembler_payout) : null; const _priceRows = _kp ? ` ${row("Стоимость кухни", _kp.toLocaleString("ru-RU") + " ₽")} ${row("Стоимость сборки (" + _cr + "%)", _cp.toLocaleString("ru-RU") + " ₽")} ${_ap != null ? row("Ваш заработок (" + _ar + "%)", Math.round(_ap).toLocaleString("ru-RU") + " ₽", {color: "var(--accent)"}) : ""} ` : ""; // Основные данные const mainBlock = `
${row("Адрес", data.address)} ${_priceRows} ${row("Объём работ", data.scope_of_work)} ${row("Дата сборки", fmtDate(data.scheduled_at))} ${row("Начало", fmtDate(data.started_at))} ${row("Завершение", fmtDate(data.completed_at))}
`; // Контакт мастера (виден клиенту) const masterBlock = data.assigned_to_name ? `
Ваш мастер
${escHtml(data.assigned_to_name)}
${data.assigned_to_username ? ` ✉️ Написать ` : ""}
${data.assigned_to_username ? `
@${escHtml(data.assigned_to_username)}
` : ""}
` : ""; // === Согласование даты сборки === // Для менеджера: кнопка «Предложить дату» или статус ожидания function _fmtDateShort(iso) { if (!iso) return ""; try { return new Date(iso).toLocaleString("ru-RU", { day: "numeric", month: "long", hour: "2-digit", minute: "2-digit", }); } catch { return iso.slice(0, 16).replace("T", " "); } } const dateStatus = data.client_date_status || ""; const proposedDate = data.proposed_date || ""; const clientPrefDate = data.client_preferred_date || ""; // Блок для менеджера const dateNegMgrBlock = data.viewer_is_manager && !["done", "cancelled"].includes(data.status) ? (() => { if (dateStatus === "pending") { return `
⏳ Ожидаем ответа клиента
Предложено: ${escHtml(_fmtDateShort(proposedDate))}
`; } if (dateStatus === "declined") { return `
❌ Клиент не может
${clientPrefDate ? `
Предлагает: ${escHtml(_fmtDateShort(clientPrefDate))}
` : `
Альтернативная дата не указана
`}
${clientPrefDate ? `` : ""}
`; } // Нет активного предложения — умный подборщик return `
`; })() : ""; // Блок для клиента: подтверждение предложенной даты const dateNegClientBlock = !data.viewer_is_manager && !data.viewer_is_assembler && proposedDate && dateStatus === "pending" ? `
📅 Менеджер предлагает дату сборки
${escHtml(_fmtDateShort(proposedDate))}
` : ""; // Испытательный срок — виден менеджеру когда есть назначенный сборщик const probationBlock = (data.viewer_is_manager && data.assigned_to_tg_id) ? `
Испытательный срок
Сборщик обязан прикладывать фото
` : ""; // Act №4 summary const act4SummaryBlock = (data.act4_total > 0 || data.act4_signed) ? (() => { const dmgColor = data.act4_damaged > 0 ? "#E67E22" : "#27AE60"; return `
Акт №4 · Приёмка товара
📦 ${data.act4_total} поз. ${data.act4_damaged > 0 ? `⚠️ ${data.act4_damaged} поврежд.` : `✅ Без повреждений`} ${data.act4_signed ? `Принял: ${escHtml(data.act4_signed_by)}` : `⏳ Не подписан`}
`; })() : ""; // Заметка менеджера const noteBlock = data.manager_note ? `
Заметка
${escHtml(data.manager_note)}
` : ""; // Фото результата function _photoUrl(fn) { return `${BACKEND_URL}/api/photo/${encodeURIComponent(data.id)}/${encodeURIComponent(fn)}`; } const photosAfter = (data.photos_after || []).filter(Boolean); const photosBefore = (data.photos_before || []).filter(Boolean); const allPhotos = [...photosBefore, ...photosAfter]; const photosBlock = allPhotos.length ? `
Фото сборки
${allPhotos.map(fn => { const u = _photoUrl(fn); return ` фото `; }).join("")}
` : ""; // Подпись const VIA_LABELS = { canvas: "✍️ Подпись пальцем", code: "📱 Код подтверждения", proxy: "👤 Представитель", absent: "🚫 Без подписи", }; const signBlock = data.signed_by_name ? `
${escHtml(VIA_LABELS[data.signed_via] || "Принято")}
${escHtml(data.signed_by_name)}
${escHtml(fmtDate(data.signed_at) || "")}
${data.signed_by_phone ? `
${escHtml(data.signed_by_phone)}
` : ""}
` : `
`; // Кнопка Google Calendar const calBtn = data.gcal_event_url ? `
📅 Посмотреть в Google Календаре
` : ""; // Заметки сборщика (показ) — перед кнопками, данные уже в data const assemblerNotesBlock = data.assembler_notes ? `
Заметки сборщика
${escHtml(data.assembler_notes)}
` : ""; screen.innerHTML = statusBanner + mainBlock + dateNegMgrBlock + dateNegClientBlock + act4SummaryBlock + masterBlock + probationBlock + noteBlock + assemblerNotesBlock + photosBlock + signBlock + calBtn + `
`; // Обработчик toggle испытательного срока const probToggleBtn = screen.querySelector("#probation-toggle-btn"); if (probToggleBtn) { probToggleBtn.addEventListener("click", async () => { haptic && haptic("impact"); const newVal = !data.assigned_on_probation; probToggleBtn.disabled = true; probToggleBtn.textContent = "…"; try { const res = await _api("assembler_set_probation", { assembler_tg_id: data.assigned_to_tg_id, on_probation: newVal, }); if (res.ok) mount(container, assemblyId); else { probToggleBtn.disabled = false; alert(res.msg || res.error); } } catch (e) { probToggleBtn.disabled = false; probToggleBtn.textContent = "Ошибка"; } }); } // === Обработчики согласования даты (менеджер) === const proposeOpenBtn = screen.querySelector("#date-propose-open-btn"); const proposeAgainBtn = screen.querySelector("#date-propose-again-btn"); const dateAcceptClientBtn = screen.querySelector("#date-accept-client-btn"); function _showProposeForm() { const form = screen.querySelector("#date-propose-form"); const openBtn = screen.querySelector("#date-propose-open-btn"); if (form) { form.style.display = "block"; if (openBtn) openBtn.style.display = "none"; } } if (proposeOpenBtn) { proposeOpenBtn.addEventListener("click", () => { haptic && haptic("impact"); _showProposeForm(); }); } if (proposeAgainBtn) { proposeAgainBtn.addEventListener("click", () => { haptic && haptic("impact"); const block = screen.querySelector("#date-neg-block"); if (block) block.innerHTML = `
Предложить дату:
`; _bindProposeForm(block); }); } if (dateAcceptClientBtn) { dateAcceptClientBtn.addEventListener("click", async () => { haptic && haptic("impact"); dateAcceptClientBtn.disabled = true; dateAcceptClientBtn.textContent = "…"; try { const res = await _api("assembly_propose_date", { assembly_id: data.id, proposed_date: data.client_preferred_date, }); if (res.ok) mount(container, assemblyId); else dateAcceptClientBtn.textContent = res.error || "Ошибка"; } catch (e) { dateAcceptClientBtn.textContent = "Ошибка"; } }); } function _bindProposeForm(ctx) { const cancelBtn = (ctx || screen).querySelector("#date-propose-cancel-btn"); const sendBtn = (ctx || screen).querySelector("#date-propose-send-btn"); const statusEl = (ctx || screen).querySelector("#date-propose-status"); if (cancelBtn) cancelBtn.addEventListener("click", () => mount(container, assemblyId)); if (sendBtn) sendBtn.addEventListener("click", async () => { haptic && haptic("impact"); const inputEl = (ctx || screen).querySelector("#date-propose-input"); const val = inputEl ? inputEl.value : ""; if (!val) { if (statusEl) statusEl.textContent = "Выберите дату"; return; } sendBtn.disabled = true; sendBtn.textContent = "Отправляем…"; try { const res = await _api("assembly_propose_date", { assembly_id: data.id, proposed_date: val }); if (res.ok) { haptic && haptic("success"); mount(container, assemblyId); } else { if (statusEl) statusEl.textContent = res.msg || res.error || "Ошибка"; sendBtn.disabled = false; sendBtn.textContent = "Отправить клиенту"; } } catch (e) { if (statusEl) statusEl.textContent = e.message; sendBtn.disabled = false; sendBtn.textContent = "Отправить клиенту"; } }); } _bindProposeForm(null); // Кнопка "Указать вручную" const manualToggle = screen.querySelector("#date-propose-manual-toggle button"); if (manualToggle) { manualToggle.addEventListener("click", () => { haptic && haptic("impact"); const form = screen.querySelector("#date-propose-form"); const panel = screen.querySelector("#date-suggest-panel"); const suggestBtn = screen.querySelector("#date-suggest-btn"); if (form) { form.style.display = form.style.display === "none" ? "block" : "none"; } if (panel) panel.style.display = "none"; if (suggestBtn) suggestBtn.style.display = "block"; }); } // === Умный подборщик: загрузка и рендер слотов === const suggestBtn = screen.querySelector("#date-suggest-btn"); if (suggestBtn) { let _slotsLoaded = false; suggestBtn.addEventListener("click", async () => { haptic && haptic("impact"); const panel = screen.querySelector("#date-suggest-panel"); const manualWrap = screen.querySelector("#date-propose-manual-toggle"); if (!panel) return; if (panel.style.display === "block") { panel.style.display = "none"; suggestBtn.textContent = "🔍 Подобрать мастера и дату"; return; } panel.style.display = "block"; suggestBtn.textContent = "⏳ Загружаем…"; suggestBtn.disabled = true; if (manualWrap) manualWrap.style.display = "none"; if (!_slotsLoaded) { panel.innerHTML = `
`; try { const slots = await _api("assembly_suggest_slots", { assembly_id: data.id }); if (slots.error) { panel.innerHTML = `
${escHtml(slots.error)}
`; } else { _renderSlots(panel, slots.assemblers || []); _slotsLoaded = true; } } catch (e) { panel.innerHTML = `
Ошибка: ${escHtml(e.message)}
`; } } suggestBtn.disabled = false; suggestBtn.textContent = "✕ Скрыть"; }); } function _fmtSlot(iso) { try { const d = new Date(iso); const days = ["Вс","Пн","Вт","Ср","Чт","Пт","Сб"]; const mons = ["","янв","фев","мар","апр","май","июн","июл","авг","сен","окт","ноя","дек"]; return `${days[d.getDay()]} ${d.getDate()} ${mons[d.getMonth()+1]}, ${String(d.getHours()).padStart(2,"0")}:00`; } catch { return iso.slice(0, 16).replace("T"," "); } } let _selectedAssemblerTgId = null; let _selectedSlot = null; function _renderSlots(panel, assemblers) { panel.innerHTML = ""; if (!assemblers.length) { panel.innerHTML = `
Нет доступных сборщиков
`; return; } // Итоговый блок отправки const sendWrap = document.createElement("div"); sendWrap.id = "slots-send-wrap"; sendWrap.style.cssText = "display:none;margin-top:8px;padding:10px;background:var(--surface);" + "border:1px solid var(--accent);border-radius:10px;"; for (const asm of assemblers) { const card = document.createElement("div"); card.style.cssText = "margin-bottom:10px;padding:10px;background:var(--surface);" + "border:1px solid var(--border);border-radius:10px;"; // Заголовок: имя + рейтинг-бейджи const probBadge = asm.on_probation ? `испытательный` : ""; const loadColor = asm.active_count >= 3 ? "#E67E22" : asm.active_count >= 1 ? "#F39C12" : "#27AE60"; card.innerHTML = `
${escHtml(asm.name)}
✅ ${asm.completed_count} сборок 🔨 ${asm.active_count} активных ${probBadge}
⭐ ${asm.score > 0 ? "+" : ""}${asm.score}
`; const chipsWrap = card.querySelector(".slots-chips"); if (!asm.free_slots || !asm.free_slots.length) { chipsWrap.innerHTML = `Нет свободных дат на 2 недели`; } else { asm.free_slots.forEach(slot => { const chip = document.createElement("button"); chip.className = "slot-chip"; chip.dataset.slot = slot; chip.dataset.asmId = asm.tg_id; chip.dataset.asmName = asm.name; chip.style.cssText = `font-size:11px;padding:4px 8px;border-radius:8px; border:1px solid var(--border);background:var(--surface); color:var(--ink);cursor:pointer;white-space:nowrap;`; chip.textContent = _fmtSlot(slot); chip.addEventListener("click", () => { haptic && haptic("impact"); // Снять выделение с всех чипов panel.querySelectorAll(".slot-chip").forEach(c => { c.style.background = "var(--surface)"; c.style.color = "var(--ink)"; c.style.borderColor = "var(--border)"; }); // Подсветить выбранный chip.style.background = "var(--accent)"; chip.style.color = "#fff"; chip.style.borderColor = "var(--accent)"; _selectedAssemblerTgId = asm.tg_id; _selectedSlot = slot; // Показываем кнопку отправки sendWrap.style.display = "block"; sendWrap.innerHTML = `
${escHtml(asm.name)}
${escHtml(_fmtSlot(slot))}
`; const sendSlotBtn = sendWrap.querySelector("#slots-send-btn"); sendSlotBtn.addEventListener("click", async () => { haptic && haptic("impact"); sendSlotBtn.disabled = true; sendSlotBtn.textContent = "Отправляем…"; const statusEl = sendWrap.querySelector("#slots-send-status"); try { const res = await _api("assembly_propose_date", { assembly_id: data.id, proposed_date: _selectedSlot, assign_assembler_tg_id: _selectedAssemblerTgId, }); if (res.ok) { haptic && haptic("success"); mount(container, assemblyId); } else { if (statusEl) statusEl.textContent = res.msg || res.error || "Ошибка"; sendSlotBtn.disabled = false; sendSlotBtn.textContent = "📅 Предложить клиенту"; } } catch (e) { if (statusEl) statusEl.textContent = e.message; sendSlotBtn.disabled = false; sendSlotBtn.textContent = "📅 Предложить клиенту"; } }); }); chipsWrap.appendChild(chip); }); } panel.appendChild(card); } panel.appendChild(sendWrap); } // === Обработчики согласования даты (клиент) === const clientConfirmBtn = screen.querySelector("#date-client-confirm-btn"); const clientDeclineBtn = screen.querySelector("#date-client-decline-btn"); const clientSendAltBtn = screen.querySelector("#date-client-send-alt-btn"); const clientStatus = screen.querySelector("#date-client-status"); if (clientConfirmBtn) { clientConfirmBtn.addEventListener("click", async () => { haptic && haptic("impact"); clientConfirmBtn.disabled = true; clientConfirmBtn.textContent = "…"; try { const res = await _api("assembly_date_confirm", { assembly_id: data.id }); if (res.ok) { haptic && haptic("success"); mount(container, assemblyId); } else { clientConfirmBtn.disabled = false; clientConfirmBtn.textContent = "✅ Подтверждаю"; if (clientStatus) clientStatus.textContent = res.error || "Ошибка"; } } catch (e) { clientConfirmBtn.disabled = false; clientConfirmBtn.textContent = "✅ Подтверждаю"; } }); } if (clientDeclineBtn) { clientDeclineBtn.addEventListener("click", () => { haptic && haptic("impact"); const altForm = screen.querySelector("#date-client-alt-form"); if (altForm) altForm.style.display = "block"; clientDeclineBtn.style.display = "none"; }); } if (clientSendAltBtn) { clientSendAltBtn.addEventListener("click", async () => { haptic && haptic("impact"); const altInput = screen.querySelector("#date-client-alt-input"); const altVal = altInput ? altInput.value : ""; clientSendAltBtn.disabled = true; clientSendAltBtn.textContent = "Отправляем…"; try { const res = await _api("assembly_date_decline", { assembly_id: data.id, preferred_date: altVal || null, }); if (res.ok) { haptic && haptic("success"); const block = screen.querySelector("#date-client-block"); if (block) block.innerHTML = `
✅ Ваш ответ отправлен менеджеру
`; } else { clientSendAltBtn.disabled = false; clientSendAltBtn.textContent = "Отправить менеджеру"; if (clientStatus) clientStatus.textContent = res.error || "Ошибка"; } } catch (e) { clientSendAltBtn.disabled = false; clientSendAltBtn.textContent = "Отправить менеджеру"; } }); } // === Кнопки смены статуса (сборщик + менеджер) === const isAssembler = data.viewer_is_assembler; const isMgr = data.viewer_is_manager; // === Кнопка «Мой заказ» — только для клиента === if (!isMgr && !isAssembler) { const tlWrap = document.createElement("div"); tlWrap.style.cssText = "margin:8px 16px 0;"; const tlBtn = document.createElement("button"); tlBtn.className = "btn-primary"; tlBtn.style.cssText = "width:100%;font-size:14px;padding:12px;"; tlBtn.textContent = "📋 Мой заказ — этапы"; tlBtn.addEventListener("click", () => { haptic && haptic("impact"); location.hash = `#/c/assembly/${encodeURIComponent(data.id)}/timeline`; }); tlWrap.appendChild(tlBtn); screen.appendChild(tlWrap); } // === Оценка сборки (клиент, после завершения, ещё не оценивал) === if (!isMgr && !isAssembler && data.status === "done" && !data.client_feedback_at && typeof FeedbackModule !== "undefined") { const fbWrap = document.createElement("div"); fbWrap.style.cssText = "margin:16px 16px 0;"; screen.appendChild(fbWrap); FeedbackModule.mountAssemblyFeedback(fbWrap, { assemblerName: data.assigned_to_name || "", assemblerTgId: data.assigned_to_tg_id || "", managerName: data.manager_name || "", managerTgId: data.manager_tg_id || "", assemblyId: data.id, onSubmit: () => mount(container, assemblyId), }); } const isAssigned = String(data.assigned_to_tg_id) === String(data.viewer_tg_id); const canChangeStatus = isMgr || (isAssembler && isAssigned); const STATUS_BTNS = { created: [{ label: "🔨 Начать сборку", next: "in_progress", cls: "btn-primary" }], scheduled: [{ label: "🔨 Начать сборку", next: "in_progress", cls: "btn-primary" }], in_progress: [{ label: "✅ Завершить сборку", next: "done", cls: "btn-primary" }], }; if (canChangeStatus && STATUS_BTNS[data.status]) { STATUS_BTNS[data.status].forEach(({ label, next, cls }) => { const w = document.createElement("div"); w.style.cssText = "margin:8px 16px 0;"; const b = document.createElement("button"); b.className = cls; b.style.cssText = "width:100%;font-size:15px;padding:13px;"; b.textContent = label; b.addEventListener("click", async () => { haptic && haptic("impact"); b.disabled = true; b.textContent = "Обновляем…"; try { const res = await _api("assembly_set_status", { assembly_id: data.id, status: next }); if (res.error) { b.disabled = false; b.textContent = label; alert(res.msg || res.error); return; } mount(container, assemblyId); } catch (e) { b.disabled = false; b.textContent = label; } }); w.appendChild(b); screen.appendChild(w); }); } // === Назначить экспедитора (менеджер) === if (isMgr && data.status !== "done" && data.status !== "cancelled") { const expWrap = document.createElement("div"); expWrap.style.cssText = "margin:8px 16px 0;"; expWrap.innerHTML = `
`; screen.appendChild(expWrap); // Загружаем список экспедиторов _loadExpeditorList(expWrap.querySelector("#exp-select"), data.expeditor_tg_id); expWrap.querySelector("#exp-assign-btn").addEventListener("click", async () => { haptic && haptic("impact"); const selId = expWrap.querySelector("#exp-select").value; const res = await _api("assembly_set_expeditor", { assembly_id: data.id, expeditor_tg_id: selId }); if (res.ok) mount(container, assemblyId); }); } // === Фото-отчёт (только испытательный срок) === if (isAssembler && isAssigned && data.viewer_on_probation) { const photoUploadWrap = document.createElement("div"); photoUploadWrap.style.cssText = "margin:8px 16px 0;"; photoUploadWrap.innerHTML = `
📸 Фото-отчёт сборки
`; screen.appendChild(photoUploadWrap); const fileInput = photoUploadWrap.querySelector("#photo-file-input"); const statusEl = photoUploadWrap.querySelector("#photo-upload-status"); let _activeKind = "after"; photoUploadWrap.querySelectorAll("button[data-kind]").forEach(btn => { btn.addEventListener("click", () => { _activeKind = btn.dataset.kind; fileInput.click(); }); }); fileInput.addEventListener("change", async () => { const file = fileInput.files[0]; if (!file) return; statusEl.textContent = "Загружаем…"; try { const dataUrl = await new Promise((res, rej) => { const reader = new FileReader(); reader.onload = e => res(e.target.result); reader.onerror = rej; reader.readAsDataURL(file); }); const result = await _api("assembly_photo_upload", { assembly_id: data.id, photo_b64: dataUrl, kind: _activeKind, }); if (result.error) { statusEl.textContent = `Ошибка: ${result.msg || result.error}`; } else { statusEl.textContent = `✅ Фото добавлено`; haptic && haptic("success"); setTimeout(() => mount(container, assemblyId), 600); } } catch (e) { statusEl.textContent = `Ошибка: ${e.message}`; } finally { fileInput.value = ""; } }); } // === Заметки сборщика — ввод (in_progress, назначенный сборщик или менеджер) === if ((isAssembler && isAssigned) || isMgr) { const notesWrap = document.createElement("div"); notesWrap.style.cssText = "margin:8px 16px 0;"; notesWrap.innerHTML = `
📝 Заметки сборщика
${(!isMgr || isAssigned) ? `
` : ""} `; screen.appendChild(notesWrap); const saveNotesBtn = notesWrap.querySelector("#asm-notes-save-btn"); if (saveNotesBtn) { saveNotesBtn.addEventListener("click", async () => { haptic && haptic("impact"); const notes = notesWrap.querySelector("#asm-notes-input").value.trim(); saveNotesBtn.disabled = true; saveNotesBtn.textContent = "Сохраняем…"; const statusEl = notesWrap.querySelector("#asm-notes-status"); try { const res = await _api("assembly_notes_save", { assembly_id: data.id, notes }); if (res.ok) { statusEl.textContent = "✅ Сохранено"; haptic && haptic("success"); setTimeout(() => statusEl.textContent = "", 2000); } else statusEl.textContent = res.msg || res.error; } catch (e) { statusEl.textContent = e.message; } finally { saveNotesBtn.disabled = false; saveNotesBtn.textContent = "Сохранить заметку"; } }); } } // === Доп работы (сборщик + менеджер) === if ((isAssembler && isAssigned) || isMgr) { const extrasWrap = document.createElement("div"); extrasWrap.style.cssText = "margin:8px 16px 0;"; extrasWrap.innerHTML = ` `; screen.appendChild(extrasWrap); const extrasPanel = extrasWrap.querySelector("#extras-panel"); const extrasBadge = extrasWrap.querySelector("#extras-badge"); extrasWrap.querySelector("#extras-toggle-btn").addEventListener("click", async () => { haptic && haptic("impact"); const btn = extrasWrap.querySelector("#extras-toggle-btn"); const isOpen = btn.dataset.open === "1"; if (isOpen) { extrasPanel.style.display = "none"; btn.dataset.open = "0"; } else { extrasPanel.style.display = "block"; btn.dataset.open = "1"; if (!extrasPanel.dataset.loaded) { extrasPanel.dataset.loaded = "1"; await _loadExtras(data.id, extrasPanel, extrasBadge, isMgr || (isAssembler && isAssigned)); } } }); // Подгружаем счётчик сразу _api("assembly_extras_list", { assembly_id: data.id }).then(r => { const items = r.extras || []; if (items.length) { const total = items.reduce((s, x) => s + Number(x.amount || 0), 0); extrasBadge.textContent = `· ${items.length} поз. ${total > 0 ? "/ " + Math.round(total).toLocaleString("ru-RU") + " ₽" : ""}`; } }).catch(() => {}); } // Кнопка «Акт №4 — приёмка товара» const act4Wrap = document.createElement("div"); act4Wrap.style.cssText = "margin:8px 16px 0;"; const act4Btn = document.createElement("button"); act4Btn.className = "btn-secondary"; act4Btn.style.cssText = "width:100%;font-size:14px;padding:11px;"; act4Btn.textContent = "📦 Акт №4 · Приёмка товара"; act4Btn.addEventListener("click", () => { haptic && haptic("impact"); location.hash = `#/assembly/${data.id}/act4`; }); act4Wrap.appendChild(act4Btn); screen.appendChild(act4Wrap); // Кнопка «Акт сдачи-приёмки» — для менеджера всегда доступна 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 = "📄 Акт №3 · Сдача-приёмка сборки"; 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"); if (btnWrap) { const signBtn = document.createElement("button"); signBtn.className = "btn-primary"; signBtn.style.cssText = "width:100%;font-size:15px;padding:13px;"; signBtn.textContent = "✍️ Подписать акт приёмки"; signBtn.addEventListener("click", () => { haptic && haptic("impact"); if (typeof SignRequest !== "undefined") { SignRequest.open(data.id, { clientName: data.client_name || "", clientTgId: data.client_tg_id || "", onSuccess: () => { mount(container, assemblyId); }, }); } }); btnWrap.appendChild(signBtn); } } } catch (e) { screen.innerHTML = `
Ошибка: ${escHtml(e.message)}
`; } } async function _loadExtras(assemblyId, panel, badge, canEdit) { panel.innerHTML = `
Загружаем…
`; try { const r = await _api("assembly_extras_list", { assembly_id: assemblyId }); const items = r.extras || []; function renderList() { const total = items.reduce((s, x) => s + Number(x.amount || 0), 0); if (badge) badge.textContent = items.length ? `· ${items.length} поз. ${total > 0 ? "/ " + Math.round(total).toLocaleString("ru-RU") + " ₽" : ""}` : ""; panel.innerHTML = ""; // Список const STATUS_LABELS = { pending: { icon: "⏳", text: "На согласовании", color: "#E67E22" }, approved: { icon: "✅", text: "Согласовано", color: "#27AE60" }, rejected: { icon: "❌", text: "Отклонено", color: "#C0392B" }, }; if (items.length) { const listEl = document.createElement("div"); listEl.style.cssText = "margin-bottom:10px;"; for (const item of items) { const itemEl = document.createElement("div"); itemEl.style.cssText = "padding:8px 0;border-bottom:1px solid var(--border);"; const photoUrl = item.receipt_photo ? `${BACKEND_URL}/api/photo/${encodeURIComponent(assemblyId)}/${encodeURIComponent(item.receipt_photo)}` : null; const sl = STATUS_LABELS[item.status] || STATUS_LABELS.pending; itemEl.innerHTML = `
${photoUrl ? ` ` : `
`}
${escHtml(item.description || "—")}
${item.amount ? Number(item.amount).toLocaleString("ru-RU") + " ₽" : "сумма не указана"}
${sl.icon} ${sl.text}
${escHtml(item.added_by_name || "")}
${canEdit ? `` : ""}
${(isMgr && item.status === "pending") ? `
` : ""} `; if (canEdit) { itemEl.querySelector(`[data-del]`)?.addEventListener("click", async (e) => { if (!confirm("Удалить запись?")) return; const res = await _api("assembly_extra_delete", { assembly_id: assemblyId, extra_id: e.target.dataset.del }); if (res.ok) { const idx = items.findIndex(x => x.id === e.target.dataset.del); if (idx >= 0) items.splice(idx, 1); renderList(); } }); } if (isMgr) { itemEl.querySelectorAll("[data-act]").forEach(btn => { btn.addEventListener("click", async () => { haptic && haptic("impact"); btn.disabled = true; const res = await _api("assembly_extra_approve", { assembly_id: assemblyId, extra_id: btn.dataset.eid, action: btn.dataset.act, }); if (res.ok) { const itm = items.find(x => x.id === btn.dataset.eid); if (itm) itm.status = res.status; renderList(); } else btn.disabled = false; }); }); } listEl.appendChild(itemEl); } // Итоги: согласовано + ожидает const approvedTotal = items.filter(x => x.status === "approved").reduce((s, x) => s + Number(x.amount || 0), 0); const pendingTotal = items.filter(x => x.status === "pending").reduce((s, x) => s + Number(x.amount || 0), 0); if (total > 0) { const totEl = document.createElement("div"); totEl.style.cssText = "padding:8px 0;font-size:13px;"; totEl.innerHTML = ` ${approvedTotal > 0 ? `
✅ Согласовано:${Math.round(approvedTotal).toLocaleString("ru-RU")} ₽
` : ""} ${pendingTotal > 0 ? `
⏳ Ожидает:${Math.round(pendingTotal).toLocaleString("ru-RU")} ₽
` : ""} `; listEl.appendChild(totEl); } panel.appendChild(listEl); } if (!canEdit) return; // Форма добавления const form = document.createElement("div"); form.innerHTML = `
+ Добавить позицию
`; panel.appendChild(form); let _receiptB64 = null; let _receiptFn = null; const receiptInput = form.querySelector("#extra-receipt-input"); const parseStatus = form.querySelector("#extra-parse-status"); const receiptPreview = form.querySelector("#extra-receipt-preview"); form.querySelector("#extra-receipt-btn").addEventListener("click", () => receiptInput.click()); receiptInput.addEventListener("change", async () => { const file = receiptInput.files[0]; if (!file) return; parseStatus.textContent = "Загружаем чек…"; _receiptFn = file.name; _receiptB64 = await new Promise((res, rej) => { const reader = new FileReader(); reader.onload = e => res(e.target.result); reader.onerror = rej; reader.readAsDataURL(file); }); receiptPreview.innerHTML = ``; // AI парсинг суммы parseStatus.textContent = "🔍 Распознаём сумму…"; try { const pr = await _api("assembly_receipt_parse", { photo_b64: _receiptB64 }); if (pr.amount && pr.amount > 0) { form.querySelector("#extra-amount").value = Math.round(pr.amount); parseStatus.textContent = `✅ Сумма распознана: ${Math.round(pr.amount).toLocaleString("ru-RU")} ₽`; } else { parseStatus.textContent = "Сумма не распознана — введите вручную"; } } catch (e) { parseStatus.textContent = "Сумма не распознана — введите вручную"; } receiptInput.value = ""; }); form.querySelector("#extra-add-btn").addEventListener("click", async () => { haptic && haptic("impact"); const desc = form.querySelector("#extra-desc").value.trim(); const amount = parseFloat(form.querySelector("#extra-amount").value) || 0; const addStatus = form.querySelector("#extra-add-status"); if (!desc) { form.querySelector("#extra-desc").style.borderColor = "var(--danger,red)"; return; } form.querySelector("#extra-desc").style.borderColor = ""; const addBtn = form.querySelector("#extra-add-btn"); addBtn.disabled = true; addBtn.textContent = "Сохраняем…"; try { const res = await _api("assembly_extra_add", { assembly_id: assemblyId, description: desc, amount, receipt_b64: _receiptB64 || null, }); if (res.ok) { haptic && haptic("success"); items.push(res.extra); form.querySelector("#extra-desc").value = ""; form.querySelector("#extra-amount").value = ""; receiptPreview.innerHTML = ""; parseStatus.textContent = ""; _receiptB64 = null; renderList(); } else { addStatus.textContent = res.msg || res.error; } } catch (e) { addStatus.textContent = e.message; } finally { addBtn.disabled = false; addBtn.textContent = "Добавить"; } }); } renderList(); } catch (e) { panel.innerHTML = `
Ошибка: ${escHtml(e.message)}
`; } } async function _loadExpeditorList(select, currentExpTgId) { try { const res = await _api("staff_list", { role: "expeditor" }); const list = res.staff || []; select.innerHTML = `` + list.map(u => ``).join(""); } catch (e) { select.innerHTML = ``; } } return { mount }; })();