836 lines
30 KiB
JavaScript
836 lines
30 KiB
JavaScript
/**
|
||
* 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 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, ">")
|
||
.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 =
|
||
'<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>← Назад</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 class="zt-k-hub-multiserver-mount" data-k-multiserver-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]");
|
||
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 (
|
||
`<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>" +
|
||
'<button type="button" class="zt-k-hub-card zt-card zt-card--interactive" data-action="hub-open-multiserver">' +
|
||
'<span class="zt-k-hub-card__icon" aria-hidden="true"><i class="fas fa-server"></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();
|
||
if (action === "hub-open-multiserver") openMultiserver();
|
||
});
|
||
});
|
||
}
|
||
|
||
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;
|
||
} else if (view === "multiserver") {
|
||
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;
|
||
if (multiserverMountEl) multiserverMountEl.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">← Назад</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 openMultiserver() {
|
||
ensureModal();
|
||
updateHeader("multiserver");
|
||
hubEl.hidden = true;
|
||
modalEl.querySelector("[data-k-notify-panel]").hidden = true;
|
||
planMountEl.hidden = true;
|
||
multiserverMountEl.hidden = false;
|
||
destroyDailyPlanPanel();
|
||
const enterpriseId = global.__WESP_ORCH__?.enterpriseId || "";
|
||
if (global.WespMultiserverPanel?.mount) {
|
||
global.WespMultiserverPanel.mount(multiserverMountEl, enterpriseId);
|
||
} else if (multiserverMountEl) {
|
||
multiserverMountEl.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,
|
||
openMultiserver,
|
||
refreshBadge,
|
||
setBadge,
|
||
};
|
||
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", init);
|
||
} else {
|
||
init();
|
||
}
|
||
})(typeof window !== "undefined" ? window : globalThis);
|