/** * Hub «К»: уведомления и план на день. FAB «К», модалка, badge. */ (function (global) { const MODAL_ID = "zootechNotificationCenterModal"; const FAB_CLASS = "wesp-zt-notify-fab"; const POLL_MS = 60000; const SCROLL_LOAD_THRESHOLD = 80; let fabEl = null; let badgeEl = null; let modalEl = null; let listEl = null; let detailEl = null; let hubEl = null; let planMountEl = null; let multiserverMountEl = null; let orchContextLoaded = false; async function ensureOrchContext() { if (orchContextLoaded || global.__WESP_ORCH__?.enterpriseId) { orchContextLoaded = true; return; } try { const resp = await fetch("/api/v1/orchestrator/context"); if (resp.ok) { const data = await resp.json(); global.__WESP_ORCH__ = Object.assign(global.__WESP_ORCH__ || {}, { enterpriseId: data.enterpriseId || "", configured: !!data.configured, reachable: !!data.reachable, upstream: data.upstream || "", hubSiteId: data.hubSiteId || "", }); } } catch (_err) { /* hub may run without orchestrator */ } finally { orchContextLoaded = true; } } let titleEl = null; let readAllBtn = null; let backBtn = null; let normsBtn = null; let pollTimer = null; let itemsCache = []; let currentView = "hub"; let dailyPlanPanel = null; let toolbarEl = null; let scrollRootEl = null; let categoryFilter = "all"; let sortMode = "newest"; let unreadOnly = false; let prevDate = null; let loadingMore = false; let listLoading = false; let scrollWired = false; const STORAGE_KEY = "wesp-notify-prefs"; const CATEGORY_LABELS = { daily_plan: "План на день", recipe: "Рейсы", sync: "Синхронизация", feed_quality: "Отклонения", component: "Компоненты", sklad: "Склад", equipment: "Оборудование", report: "Отчёты", general: "Общее", }; const CATEGORY_FILTERS = [ { id: "all", label: "Все" }, { id: "daily_plan", label: "План" }, { id: "recipe", label: "Рейсы" }, { id: "sync", label: "Синхр." }, { id: "feed_quality", label: "Отклон." }, { id: "component", label: "Компон." }, { id: "sklad", label: "Склад" }, { id: "equipment", label: "Оборуд." }, ]; const KIND_ORDER = { error: 0, warning: 1, info: 2, success: 3 }; function escapeHtml(value) { return String(value) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function resolveNotificationHref(item) { if (!item || !item.linkKind) return null; if (item.linkKind === "component") return "/components"; if (item.linkKind === "sklad") return "/feed_consumption"; if (item.linkKind === "equipment") return "/feed_dispensers"; if (!item.linkId) return null; const id = encodeURIComponent(item.linkId); if (item.linkKind === "recipe") return `/recipes?recipe=${id}`; if (item.linkKind === "report_loading") return `/reports?report=${id}`; if (item.linkKind === "daily_plan") return `/recipes?dailyPlan=${id}`; return null; } async function markReadQuiet(id) { const item = itemsCache.find((x) => x.id === id); if (!item || item.read) return; try { await fetch(`/api/notifications/${encodeURIComponent(id)}/read`, { method: "PATCH" }); item.read = true; await refreshBadge(); } catch { /* ignore */ } } async function openNotificationTarget(item) { if (isDailyPlanItem(item)) { await markReadQuiet(item.id); openDailyPlan(); return; } const href = resolveNotificationHref(item); if (!href) { await showDetail(item.id); return; } await markReadQuiet(item.id); if (global.bootstrap?.Modal) { const inst = global.bootstrap.Modal.getInstance(modalEl); inst?.hide(); } global.location.href = href; } function relativeTime(iso) { if (!iso) return ""; const then = new Date(iso).getTime(); const diff = Date.now() - then; const min = Math.floor(diff / 60000); if (min < 1) return "только что"; if (min < 60) return `${min} мин назад`; const hrs = Math.floor(min / 60); if (hrs < 24) return `${hrs} ч назад`; const days = Math.floor(hrs / 24); return `${days} дн назад`; } function loadPrefs() { try { const raw = global.sessionStorage?.getItem(STORAGE_KEY); if (!raw) return; const prefs = JSON.parse(raw); if (prefs.categoryFilter) categoryFilter = prefs.categoryFilter; if (prefs.sortMode) sortMode = prefs.sortMode; if (typeof prefs.unreadOnly === "boolean") unreadOnly = prefs.unreadOnly; } catch { /* ignore */ } } function savePrefs() { try { global.sessionStorage?.setItem( STORAGE_KEY, JSON.stringify({ categoryFilter, sortMode, unreadOnly }) ); } catch { /* ignore */ } } function itemTimestamp(item) { return item?.createdAt ? new Date(item.createdAt).getTime() : 0; } function sortItems(items) { const list = [...items]; if (sortMode === "oldest") { return list.sort((a, b) => itemTimestamp(a) - itemTimestamp(b)); } if (sortMode === "unread_first") { return list.sort((a, b) => { const ra = a.read ? 1 : 0; const rb = b.read ? 1 : 0; if (ra !== rb) return ra - rb; return itemTimestamp(b) - itemTimestamp(a); }); } if (sortMode === "severity") { return list.sort((a, b) => { const ka = KIND_ORDER[a.kind] ?? 9; const kb = KIND_ORDER[b.kind] ?? 9; if (ka !== kb) return ka - kb; return itemTimestamp(b) - itemTimestamp(a); }); } return list.sort((a, b) => itemTimestamp(b) - itemTimestamp(a)); } function formatDayLabel(isoDate) { if (!isoDate) return "Ранее"; const parts = String(isoDate).slice(0, 10).split("-"); if (parts.length !== 3) return isoDate; const d = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2])); const now = new Date(); const startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const startThat = new Date(d.getFullYear(), d.getMonth(), d.getDate()); const diffDays = Math.round((startToday - startThat) / 86400000); if (diffDays === 0) return "Сегодня"; if (diffDays === 1) return "Вчера"; return d.toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" }); } function dayKeyFromItem(item) { if (!item?.createdAt) return ""; const d = new Date(item.createdAt); const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `${y}-${m}-${day}`; } function inferCategory(item) { const raw = item?.category || "general"; if (raw === "daily_plan") return "daily_plan"; if (item?.linkKind === "daily_plan" || item?.page === "daily_plan") return "daily_plan"; const text = `${item?.title || ""} ${item?.detail || ""}`.toLowerCase(); if ( text.includes("из плана") || text.includes("в плане") || text.includes("замен") || text.includes("исключ") || text.includes("снова в план") || text.includes("план на день") ) { return "daily_plan"; } return raw; } function isDailyPlanItem(item) { return inferCategory(item) === "daily_plan"; } function categoryLabel(item) { return CATEGORY_LABELS[inferCategory(item)] || CATEGORY_LABELS.general; } function buildListUrl(date) { const params = new URLSearchParams({ sort: sortMode }); if (date) params.set("date", date); if (categoryFilter && categoryFilter !== "all") params.set("category", categoryFilter); if (unreadOnly) params.set("unread", "1"); return `/api/notifications?${params.toString()}`; } function ensureFab() { if (fabEl && fabEl.isConnected) return fabEl; fabEl = document.querySelector(`.${FAB_CLASS}`); if (!fabEl) { fabEl = document.createElement("button"); fabEl.type = "button"; fabEl.className = FAB_CLASS; fabEl.setAttribute("data-action", "open-notification-center"); fabEl.setAttribute("aria-label", "Центр «К»"); fabEl.innerHTML = '' + ''; document.body.appendChild(fabEl); } badgeEl = fabEl.querySelector(".wesp-zt-notify-fab__badge"); fabEl.addEventListener("click", openCenter); return fabEl; } function categoryFilterButtonsHtml() { return CATEGORY_FILTERS.map( (f) => `` ).join(""); } function modalTemplate() { return ( `
" ); } function ensureModal() { if (modalEl && modalEl.isConnected) return modalEl; modalEl = document.getElementById(MODAL_ID); if (!modalEl) { document.body.insertAdjacentHTML("beforeend", modalTemplate()); modalEl = document.getElementById(MODAL_ID); } hubEl = modalEl.querySelector("[data-k-hub-panel]"); listEl = modalEl.querySelector("[data-notify-list]"); detailEl = modalEl.querySelector("[data-notify-detail]"); toolbarEl = modalEl.querySelector("[data-notify-toolbar]"); scrollRootEl = modalEl.querySelector(".zt-k-hub-modal__body"); planMountEl = modalEl.querySelector("[data-k-plan-panel]"); multiserverMountEl = modalEl.querySelector("[data-k-multiserver-panel]"); titleEl = modalEl.querySelector("[data-k-hub-title]"); readAllBtn = modalEl.querySelector("[data-action='notify-read-all']"); backBtn = modalEl.querySelector("[data-action='k-hub-back']"); normsBtn = modalEl.querySelector("[data-action='k-hub-open-component-norms']"); global.WespZootechModals?.wireAllShellModals?.(); readAllBtn?.addEventListener("click", readAll); backBtn?.addEventListener("click", () => showHub()); normsBtn?.addEventListener("click", () => { global.openDailyPlanComponentNormsModal?.(); }); wireNotifyToolbar(); wireScrollLoad(); hubEl?.querySelectorAll("[data-action]").forEach((btn) => { btn.addEventListener("click", () => { const action = btn.getAttribute("data-action"); if (action === "hub-open-notifications") openNotifications(); if (action === "hub-open-daily-plan") openDailyPlan(); if (action === "hub-open-multiserver") openMultiserver(); }); }); wireModalLifecycle(); renderHubCards(); return modalEl; } function syncToolbarUi() { if (!toolbarEl) return; toolbarEl.querySelectorAll("[data-notify-category]").forEach((btn) => { const cat = btn.getAttribute("data-notify-category") || "all"; btn.classList.toggle("active", cat === categoryFilter); }); const sortSelect = toolbarEl.querySelector("[data-notify-sort]"); if (sortSelect) sortSelect.value = sortMode; const unreadCb = toolbarEl.querySelector("[data-notify-unread-only]"); if (unreadCb) unreadCb.checked = unreadOnly; } function wireNotifyToolbar() { if (!toolbarEl || toolbarEl.dataset.wired === "1") return; toolbarEl.dataset.wired = "1"; loadPrefs(); syncToolbarUi(); toolbarEl.querySelectorAll("[data-notify-category]").forEach((btn) => { btn.addEventListener("click", () => { categoryFilter = btn.getAttribute("data-notify-category") || "all"; savePrefs(); syncToolbarUi(); reloadNotifications(); }); }); toolbarEl.querySelector("[data-notify-sort]")?.addEventListener("change", (ev) => { sortMode = ev.target.value || "newest"; savePrefs(); reloadNotifications(); }); toolbarEl.querySelector("[data-notify-unread-only]")?.addEventListener("change", (ev) => { unreadOnly = Boolean(ev.target.checked); savePrefs(); reloadNotifications(); }); } function wireScrollLoad() { if (scrollWired || !scrollRootEl) return; scrollWired = true; scrollRootEl.addEventListener("scroll", () => { if (currentView !== "notifications" || !prevDate || loadingMore || listLoading) return; const nearBottom = scrollRootEl.scrollTop + scrollRootEl.clientHeight >= scrollRootEl.scrollHeight - SCROLL_LOAD_THRESHOLD; if (nearBottom) loadMoreNotifications(); }); } function itemPreview(item) { const detail = String(item?.detail || "").trim(); if (detail) { const cut = detail.replace(/\.\s+\d{1,2}\s+\S+\s+\d{4}.*$/u, "").trim(); const text = cut || detail; return text.length > 140 ? `${text.slice(0, 137)}…` : text; } return item?.title || ""; } function renderNotifyItem(item) { const unread = !item.read ? " zt-notify-item--unread" : ""; const cat = categoryLabel(item); const preview = itemPreview(item); const showPreview = preview && preview !== item.title; return ( `" ); } function renderLoadMoreState() { const el = modalEl?.querySelector("[data-notify-load-more]"); if (!el) return; if (loadingMore) { el.hidden = false; el.innerHTML = 'Прокрутите вниз — загрузятся уведомления за ${escapeHtml(formatDayLabel(prevDate))}
`; return; } el.hidden = true; el.innerHTML = ""; } function renderHubCards() { if (!hubEl) return; hubEl.innerHTML = 'Загрузка…
${escapeHtml(hint)}
${escapeHtml(item.title)}
` + `` + `${escapeHtml(item.detail)}
` + `${escapeHtml(relativeTime(item.createdAt))}
` + (resolveNotificationHref(item) || isDailyPlanItem(item) ? `` : ""); detailEl.querySelector("[data-action='notify-open-target']")?.addEventListener("click", () => { openNotificationTarget(item); }); detailEl.querySelector("[data-action='notify-back']")?.addEventListener("click", () => { renderList(); }); if (!item.read) { try { await fetch(`/api/notifications/${encodeURIComponent(id)}/read`, { method: "PATCH" }); item.read = true; await refreshBadge(); } catch { /* ignore */ } } } async function readAll() { try { await fetch("/api/notifications/read-all", { method: "PATCH" }); await reloadNotifications(); } catch { /* ignore */ } } function showModal() { if (global.WespZootechModals?.show) { global.WespZootechModals.show(MODAL_ID); } else if (global.bootstrap?.Modal) { global.bootstrap.Modal.getOrCreateInstance(modalEl).show(); } } async function openCenter() { ensureModal(); showHub(); showModal(); } async function openNotifications() { ensureModal(); updateHeader("notifications"); hubEl.hidden = true; modalEl.querySelector("[data-k-notify-panel]").hidden = false; planMountEl.hidden = true; destroyDailyPlanPanel(); await reloadNotifications(); showModal(); } function openDailyPlan() { ensureModal(); updateHeader("daily-plan"); hubEl.hidden = true; modalEl.querySelector("[data-k-notify-panel]").hidden = true; planMountEl.hidden = false; destroyDailyPlanPanel(); dailyPlanPanel = global.WespDailyPlanPanel?.createDailyPlanPanel?.(); if (dailyPlanPanel && planMountEl) { dailyPlanPanel.mount(planMountEl); global.WespDailyPlanPanel?._setActivePanel?.(dailyPlanPanel); } else if (planMountEl) { planMountEl.innerHTML = 'Модуль плана на день не загружен.
Модуль мультисервера не загружен.