@@ -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, "<")
|
||||
.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></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">← Назад</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);
|
||||
Reference in New Issue
Block a user