Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
@@ -0,0 +1,800 @@
/**
* 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 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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 =
'<span class="wesp-zt-notify-fab__glyph" aria-hidden="true">К</span>' +
'<span class="wesp-zt-notify-fab__badge" hidden aria-hidden="true"></span>';
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) =>
`<button type="button" class="zt-tab${f.id === categoryFilter ? " active" : ""}" ` +
`data-notify-category="${escapeHtml(f.id)}">${escapeHtml(f.label)}</button>`
).join("");
}
function modalTemplate() {
return (
`<div class="modal wesp-shell-modal" id="${MODAL_ID}" tabindex="-1" aria-hidden="true">` +
'<div class="modal-dialog modal-dialog--shell modal-dialog--shell-wide">' +
'<div class="modal-content wesp-shell-bsmodal border-0 shadow-none zt-k-hub-modal">' +
'<div class="zt-k-hub-modal__header">' +
'<div class="zt-k-hub-modal__header-left">' +
'<button type="button" class="zt-k-hub-modal__back" data-action="k-hub-back" hidden>&larr; Назад</button>' +
'<h2 class="wesp-shell-bsmodal-title zt-k-hub-modal__title" data-k-hub-title>Комптон - хаб</h2>' +
"</div>" +
'<div class="zt-k-hub-modal__header-right">' +
'<button type="button" class="btn btn-sm btn-outline-primary zt-k-hub-plan-norms-btn" data-action="k-hub-open-component-norms" hidden title="Изменить СВ% компонентов только в плане на день — рецепты в справочнике не меняются">Коррекция СВ%</button>' +
'<button type="button" class="zt-notify-center__read-all" data-action="notify-read-all" hidden>Прочитать все</button>' +
'<button type="button" class="wesp-shell-bsmodal-close" data-bs-dismiss="modal" aria-label="Закрыть">×</button>' +
"</div></div>" +
'<div class="zt-k-hub-modal__body">' +
'<div class="zt-k-hub" data-k-hub-panel></div>' +
'<div class="zt-notify-center__notify" data-k-notify-panel hidden>' +
'<div class="zt-notify-toolbar" data-notify-toolbar>' +
'<div class="zt-notify-toolbar__filters zt-tabs" role="tablist" data-notify-categories>' +
categoryFilterButtonsHtml() +
"</div>" +
'<div class="zt-notify-toolbar__row">' +
'<label class="zt-notify-toolbar__unread">' +
'<input type="checkbox" data-notify-unread-only> Только непрочит.' +
"</label>" +
'<label class="zt-notify-toolbar__sort">' +
'<span class="visually-hidden">Сортировка</span>' +
'<select data-notify-sort aria-label="Сортировка">' +
'<option value="newest">Сначала новые</option>' +
'<option value="oldest">Сначала старые</option>' +
'<option value="unread_first">Непрочит. сверху</option>' +
'<option value="severity">По важности</option>' +
"</select></label></div></div>" +
'<div class="zt-notify-list" data-notify-list></div>' +
'<div class="zt-notify-load-more" data-notify-load-more hidden></div>' +
'<div class="zt-notify-detail" data-notify-detail hidden></div>' +
"</div>" +
'<div class="zt-k-hub-plan-mount" data-k-plan-panel hidden></div>' +
"</div></div></div></div>"
);
}
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]");
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();
});
});
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 (
`<button type="button" class="zt-notify-item${unread}" data-notify-id="${escapeHtml(item.id)}">` +
`<span class="zt-notify-item__dot zt-notify-item__dot--${escapeHtml(item.kind || "info")}"></span>` +
`<span class="zt-notify-item__main">` +
`<span class="zt-notify-item__title">${escapeHtml(item.title)}</span>` +
(showPreview
? `<span class="zt-notify-item__preview">${escapeHtml(preview)}</span>`
: "") +
`<span class="zt-notify-item__meta">` +
`<span class="zt-notify-item__category">${escapeHtml(cat)}</span>` +
`<span class="zt-notify-item__time">${escapeHtml(relativeTime(item.createdAt))}</span>` +
"</span></span></button>"
);
}
function renderLoadMoreState() {
const el = modalEl?.querySelector("[data-notify-load-more]");
if (!el) return;
if (loadingMore) {
el.hidden = false;
el.innerHTML =
'<div class="zt-notify-load-more__spinner"><div class="zt-spinner" role="status"></div>' +
'<span>Загрузка…</span></div>';
return;
}
if (prevDate) {
el.hidden = false;
el.innerHTML =
`<p class="zt-notify-load-more__hint">Прокрутите вниз — загрузятся уведомления за ${escapeHtml(formatDayLabel(prevDate))}</p>`;
return;
}
el.hidden = true;
el.innerHTML = "";
}
function renderHubCards() {
if (!hubEl) return;
hubEl.innerHTML =
'<div class="zt-k-hub__grid">' +
'<button type="button" class="zt-k-hub-card zt-card zt-card--interactive" data-action="hub-open-notifications">' +
'<span class="zt-k-hub-card__icon" aria-hidden="true"><i class="fas fa-bell"></i></span>' +
'<span class="zt-k-hub-card__text">' +
'<span class="zt-k-hub-card__title">Уведомления</span>' +
'<span class="zt-k-hub-card__desc">План, рейсы, синхронизация и отклонения</span>' +
"</span></button>" +
'<button type="button" class="zt-k-hub-card zt-card zt-card--interactive" data-action="hub-open-daily-plan">' +
'<span class="zt-k-hub-card__icon" aria-hidden="true"><i class="fas fa-calendar-day"></i></span>' +
'<span class="zt-k-hub-card__text">' +
'<span class="zt-k-hub-card__title">План на день</span>' +
'<span class="zt-k-hub-card__desc">Рейсы, ингредиенты и выгрузка по периодам</span>' +
"</span></button></div>";
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();
});
});
}
function applyModalLayout(view) {
if (!modalEl) return;
const dialog = modalEl.querySelector(".modal-dialog");
const body = modalEl.querySelector(".zt-k-hub-modal__body");
modalEl.classList.remove("zt-k-hub--plan", "zt-k-hub--notify");
body?.classList.remove("zt-k-hub-modal__body--plan");
dialog?.classList.remove("modal-dialog--shell-plan", "modal-dialog--shell-notify");
if (view === "daily-plan") {
modalEl.classList.add("zt-k-hub--plan");
body?.classList.add("zt-k-hub-modal__body--plan");
dialog?.classList.add("modal-dialog--shell-plan");
} else if (view === "notifications") {
modalEl.classList.add("zt-k-hub--notify");
dialog?.classList.add("modal-dialog--shell-notify");
}
}
function updateHeader(view) {
currentView = view;
applyModalLayout(view);
if (!titleEl || !backBtn || !readAllBtn) return;
if (normsBtn) normsBtn.hidden = view !== "daily-plan";
if (view === "hub") {
titleEl.textContent = "Комптон - хаб";
backBtn.hidden = true;
readAllBtn.hidden = true;
} else if (view === "notifications") {
titleEl.textContent = "Уведомления";
backBtn.hidden = false;
readAllBtn.hidden = false;
} else if (view === "daily-plan") {
titleEl.textContent = "План на день";
backBtn.hidden = false;
readAllBtn.hidden = true;
}
}
function dismissPlanOverlays() {
modalEl?.querySelectorAll(".zt-k-hub-plan-overlay").forEach((el) => el.remove());
}
function destroyDailyPlanPanel() {
dismissPlanOverlays();
global.closeDailyPlanComponentNormsModal?.();
dailyPlanPanel?.destroy?.();
dailyPlanPanel = null;
global.WespDailyPlanPanel?._setActivePanel?.(null);
}
function wireModalLifecycle() {
ensureModal();
if (modalEl.dataset.kHubLifecycleWired === "1") return;
modalEl.dataset.kHubLifecycleWired = "1";
modalEl.addEventListener("hide.bs.modal", dismissPlanOverlays);
modalEl.addEventListener("hidden.bs.modal", destroyDailyPlanPanel);
}
function showHub() {
ensureModal();
updateHeader("hub");
hubEl.hidden = false;
modalEl.querySelector("[data-k-notify-panel]").hidden = true;
planMountEl.hidden = true;
destroyDailyPlanPanel();
listEl.hidden = false;
detailEl.hidden = true;
}
function setBadge(unreadCount) {
ensureFab();
if (!badgeEl) return;
const count = Number(unreadCount) || 0;
if (count <= 0) {
badgeEl.hidden = true;
badgeEl.textContent = "";
fabEl.classList.remove("has-unread");
return;
}
badgeEl.hidden = false;
badgeEl.textContent = count > 9 ? "9+" : String(count);
fabEl.classList.add("has-unread");
}
async function refreshBadge() {
try {
const resp = await fetch("/api/notifications?summary=1");
if (!resp.ok) return;
const data = await resp.json();
setBadge(data.unreadCount);
} catch {
/* ignore */
}
}
function mergeItems(existing, incoming) {
const map = new Map(existing.map((x) => [x.id, x]));
incoming.forEach((item) => map.set(item.id, item));
return Array.from(map.values());
}
function matchesCategoryFilter(item) {
if (!categoryFilter || categoryFilter === "all") return true;
return inferCategory(item) === categoryFilter;
}
function filterVisibleItems(items) {
let list = items.filter(matchesCategoryFilter);
if (unreadOnly) list = list.filter((x) => !x.read);
return list;
}
async function fetchDay(date) {
const resp = await fetch(buildListUrl(date));
if (!resp.ok) throw new Error("Не удалось загрузить уведомления");
return resp.json();
}
async function loadInitialNotifications() {
let date = null;
let found = [];
for (let step = 0; step < 31; step += 1) {
const data = await fetchDay(date);
setBadge(data.unreadCount);
if (data.items?.length) {
found = mergeItems(found, data.items);
prevDate = data.hasMore ? data.prevDate : null;
return found;
}
if (!data.hasMore || !data.prevDate) {
prevDate = null;
return found;
}
date = data.prevDate;
}
prevDate = null;
return found;
}
async function reloadNotifications() {
listLoading = true;
itemsCache = [];
prevDate = null;
detailEl.hidden = true;
listEl.hidden = false;
listEl.innerHTML =
'<div class="zt-notify-empty zt-notify-empty--loading">' +
'<div class="zt-spinner" role="status"></div><p>Загрузка…</p></div>';
renderLoadMoreState();
try {
itemsCache = await loadInitialNotifications();
renderList();
} catch {
itemsCache = [];
prevDate = null;
renderList();
} finally {
listLoading = false;
renderLoadMoreState();
}
}
async function loadMoreNotifications() {
if (!prevDate || loadingMore || listLoading) return;
loadingMore = true;
renderLoadMoreState();
const loadDate = prevDate;
try {
const data = await fetchDay(loadDate);
itemsCache = mergeItems(itemsCache, data.items || []);
prevDate = data.hasMore ? data.prevDate : null;
setBadge(data.unreadCount);
renderList();
} catch {
/* keep current list */
} finally {
loadingMore = false;
renderLoadMoreState();
}
}
function wireListClicks() {
listEl.querySelectorAll("[data-notify-id]").forEach((btn) => {
btn.addEventListener("click", () => {
const id = btn.getAttribute("data-notify-id");
if (id) showDetail(id);
});
});
}
function renderList() {
ensureModal();
wireNotifyToolbar();
syncToolbarUi();
detailEl.hidden = true;
listEl.hidden = false;
const sorted = sortItems(filterVisibleItems(itemsCache));
if (!sorted.length) {
const hint =
unreadOnly || categoryFilter !== "all"
? prevDate
? "За сегодня ничего нет — прокрутите вниз для более ранних"
: "Нет уведомлений по выбранному фильтру"
: "Нет уведомлений";
listEl.innerHTML =
`<div class="zt-notify-empty"><i class="fas fa-bell-slash"></i><p>${escapeHtml(hint)}</p></div>`;
renderLoadMoreState();
return;
}
let html = "";
let lastGroup = null;
sorted.forEach((item) => {
const group = dayKeyFromItem(item);
if (group !== lastGroup) {
html += `<div class="zt-notify-group">${escapeHtml(formatDayLabel(group))}</div>`;
lastGroup = group;
}
html += renderNotifyItem(item);
});
listEl.innerHTML = html;
wireListClicks();
renderLoadMoreState();
}
async function showDetail(id) {
const item = itemsCache.find((x) => x.id === id);
if (!item) return;
ensureModal();
listEl.hidden = true;
modalEl.querySelector("[data-notify-load-more]")?.setAttribute("hidden", "");
detailEl.hidden = false;
detailEl.innerHTML =
`<button type="button" class="zt-notify-detail__back" data-action="notify-back">&larr; Назад</button>` +
`<p class="zt-notify-detail__kind zt-notify-detail__kind--${escapeHtml(item.kind)}">${escapeHtml(item.title)}</p>` +
`<p class="zt-notify-detail__meta">${escapeHtml(categoryLabel(item))}</p>` +
`<p class="zt-notify-detail__text">${escapeHtml(item.detail)}</p>` +
`<p class="zt-notify-detail__when">${escapeHtml(relativeTime(item.createdAt))}</p>` +
(resolveNotificationHref(item) || isDailyPlanItem(item)
? `<button type="button" class="btn btn-primary btn-sm mt-3" data-action="notify-open-target">Открыть план</button>`
: "");
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 =
'<div class="zt-k-hub-plan__empty"><p>Модуль плана на день не загружен.</p></div>';
}
showModal();
}
function init() {
if (!document.body.classList.contains("zootech-app-body")) return;
loadPrefs();
ensureFab();
ensureModal();
refreshBadge();
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(refreshBadge, POLL_MS);
}
global.WespZootechNotificationCenter = {
init,
openCenter,
openNotifications,
openDailyPlan,
refreshBadge,
setBadge,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})(typeof window !== "undefined" ? window : globalThis);