- Updated CSS for `.day` and `.access-denied` to improve layout and visual consistency. - Introduced a new function `dutyOverlapsLocalRange` in `dateUtils.js` to check duty overlaps within a specified date range. - Refactored `dutyItemHtml` in `dutyList.js` to utilize `formatTimeLocal` for time formatting, enhancing readability. - Added utility functions in `hints.js` for parsing duty marker data and building time prefixes, streamlining hint rendering logic. - Improved the `showAccessDenied` function in `ui.js` to display detailed server messages when access is denied.
358 lines
11 KiB
JavaScript
358 lines
11 KiB
JavaScript
/**
|
||
* Tooltips for calendar info buttons and duty markers.
|
||
*/
|
||
|
||
import { calendarEl } from "./dom.js";
|
||
import { EVENT_TYPE_LABELS } from "./constants.js";
|
||
import { escapeHtml } from "./utils.js";
|
||
import { localDateString, formatHHMM } from "./dateUtils.js";
|
||
|
||
/**
|
||
* Position hint element near button; flip below if needed, clamp to viewport.
|
||
* @param {HTMLElement} hintEl
|
||
* @param {DOMRect} btnRect
|
||
*/
|
||
export function positionHint(hintEl, btnRect) {
|
||
const vw = document.documentElement.clientWidth;
|
||
const margin = 12;
|
||
hintEl.classList.remove("below");
|
||
hintEl.style.left = btnRect.left + "px";
|
||
hintEl.style.top = btnRect.top - 4 + "px";
|
||
hintEl.hidden = false;
|
||
requestAnimationFrame(() => {
|
||
const hintRect = hintEl.getBoundingClientRect();
|
||
let left = parseFloat(hintEl.style.left);
|
||
let top = parseFloat(hintEl.style.top);
|
||
if (hintRect.top < margin) {
|
||
hintEl.classList.add("below");
|
||
top = btnRect.bottom + 4;
|
||
}
|
||
if (hintRect.right > vw - margin) {
|
||
left = vw - hintRect.width - margin;
|
||
}
|
||
if (left < margin) left = margin;
|
||
left = Math.min(left, vw - hintRect.width - margin);
|
||
hintEl.style.left = left + "px";
|
||
hintEl.style.top = top + "px";
|
||
if (hintEl.classList.contains("below")) {
|
||
requestAnimationFrame(() => {
|
||
const hintRect2 = hintEl.getBoundingClientRect();
|
||
let left2 = parseFloat(hintEl.style.left);
|
||
if (hintRect2.right > vw - margin) {
|
||
left2 = vw - hintRect2.width - margin;
|
||
}
|
||
if (left2 < margin) left2 = margin;
|
||
hintEl.style.left = left2 + "px";
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Parse data-duty-items and data-date from marker; detect if any item has times.
|
||
* @param {HTMLElement} marker
|
||
* @returns {{ dutyItems: object[], hasTimes: boolean, hintDay: string }}
|
||
*/
|
||
function parseDutyMarkerData(marker) {
|
||
const dutyItemsRaw =
|
||
marker.getAttribute("data-duty-items") ||
|
||
(marker.dataset && marker.dataset.dutyItems) ||
|
||
"";
|
||
let dutyItems = [];
|
||
try {
|
||
if (dutyItemsRaw) dutyItems = JSON.parse(dutyItemsRaw);
|
||
} catch (e) {
|
||
/* ignore */
|
||
}
|
||
const hasTimes =
|
||
dutyItems.length > 0 &&
|
||
dutyItems.some((it) => {
|
||
const start = it.start_at != null ? it.start_at : it.startAt;
|
||
const end = it.end_at != null ? it.end_at : it.endAt;
|
||
return start || end;
|
||
});
|
||
const hintDay = marker.getAttribute("data-date") || "";
|
||
return { dutyItems, hasTimes, hintDay };
|
||
}
|
||
|
||
/**
|
||
* Get fullName from duty item (supports snake_case and camelCase).
|
||
* @param {object} item
|
||
* @returns {string}
|
||
*/
|
||
function getItemFullName(item) {
|
||
return item.full_name != null ? item.full_name : item.fullName;
|
||
}
|
||
|
||
/**
|
||
* Build timePrefix for one duty item in the list (shared logic for text and HTML).
|
||
* @param {object} item - duty item with start_at/startAt, end_at/endAt
|
||
* @param {number} idx - index in dutyItems
|
||
* @param {number} total - dutyItems.length
|
||
* @param {string} hintDay - YYYY-MM-DD
|
||
* @param {string} sep - separator after "с"/"до" (e.g. " - " for text, "\u00a0" for HTML)
|
||
* @returns {string}
|
||
*/
|
||
function buildDutyItemTimePrefix(item, idx, total, hintDay, sep) {
|
||
const startAt = item.start_at != null ? item.start_at : item.startAt;
|
||
const endAt = item.end_at != null ? item.end_at : item.endAt;
|
||
const endHHMM = endAt ? formatHHMM(endAt) : "";
|
||
const startHHMM = startAt ? formatHHMM(startAt) : "";
|
||
const startSameDay =
|
||
hintDay && startAt && localDateString(new Date(startAt)) === hintDay;
|
||
const endSameDay =
|
||
hintDay && endAt && localDateString(new Date(endAt)) === hintDay;
|
||
let timePrefix = "";
|
||
if (idx === 0) {
|
||
if (total === 1 && startSameDay && startHHMM) {
|
||
timePrefix = "с" + sep + startHHMM;
|
||
if (endSameDay && endHHMM && endHHMM !== startHHMM) {
|
||
timePrefix += " до" + sep + endHHMM;
|
||
}
|
||
} else if (endHHMM) {
|
||
timePrefix = "до" + sep + endHHMM;
|
||
}
|
||
} else if (idx > 0) {
|
||
if (startHHMM) timePrefix = "с" + sep + startHHMM;
|
||
if (endHHMM && endSameDay && endHHMM !== startHHMM) {
|
||
timePrefix += (timePrefix ? " " : "") + "до" + sep + endHHMM;
|
||
}
|
||
}
|
||
return timePrefix;
|
||
}
|
||
|
||
/**
|
||
* Get array of { timePrefix, fullName } for duty items (single source of time rules).
|
||
* @param {object[]} dutyItems
|
||
* @param {string} hintDay
|
||
* @param {string} timeSep - e.g. " - " for text, "\u00a0" for HTML
|
||
* @returns {{ timePrefix: string, fullName: string }[]}
|
||
*/
|
||
function getDutyMarkerRows(dutyItems, hintDay, timeSep) {
|
||
return dutyItems.map((item, idx) => {
|
||
const timePrefix = buildDutyItemTimePrefix(
|
||
item,
|
||
idx,
|
||
dutyItems.length,
|
||
hintDay,
|
||
timeSep
|
||
);
|
||
const fullName = getItemFullName(item);
|
||
return { timePrefix, fullName };
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Get plain text content for duty marker tooltip.
|
||
* @param {HTMLElement} marker - .duty-marker, .unavailable-marker or .vacation-marker
|
||
* @returns {string}
|
||
*/
|
||
export function getDutyMarkerHintContent(marker) {
|
||
const type = marker.getAttribute("data-event-type") || "duty";
|
||
const label = EVENT_TYPE_LABELS[type] || type;
|
||
const names = marker.getAttribute("data-names") || "";
|
||
let body;
|
||
if (type === "duty") {
|
||
const { dutyItems, hasTimes, hintDay } = parseDutyMarkerData(marker);
|
||
if (dutyItems.length >= 1 && hasTimes) {
|
||
const rows = getDutyMarkerRows(dutyItems, hintDay, " - ");
|
||
body = rows
|
||
.map((r) =>
|
||
r.timePrefix ? r.timePrefix + " — " + r.fullName : r.fullName
|
||
)
|
||
.join("\n");
|
||
} else {
|
||
body = names;
|
||
}
|
||
} else {
|
||
body = names;
|
||
}
|
||
return body ? label + ":\n" + body : label;
|
||
}
|
||
|
||
/**
|
||
* Returns HTML for duty hint with aligned times, or null to use textContent.
|
||
* @param {HTMLElement} marker
|
||
* @returns {string|null}
|
||
*/
|
||
export function getDutyMarkerHintHtml(marker) {
|
||
const type = marker.getAttribute("data-event-type") || "duty";
|
||
if (type !== "duty") return null;
|
||
const names = marker.getAttribute("data-names") || "";
|
||
const { dutyItems, hasTimes, hintDay } = parseDutyMarkerData(marker);
|
||
const nbsp = "\u00a0";
|
||
let rowHtmls;
|
||
if (dutyItems.length >= 1 && hasTimes) {
|
||
const rows = getDutyMarkerRows(dutyItems, hintDay, nbsp);
|
||
rowHtmls = rows.map((r) => {
|
||
const timeHtml = r.timePrefix ? escapeHtml(r.timePrefix) : "";
|
||
const namePart = r.timePrefix
|
||
? " — " + escapeHtml(r.fullName)
|
||
: escapeHtml(r.fullName);
|
||
return (
|
||
'<span class="calendar-event-hint-time">' +
|
||
timeHtml +
|
||
'</span><span class="calendar-event-hint-name">' +
|
||
namePart +
|
||
"</span>"
|
||
);
|
||
});
|
||
} else {
|
||
rowHtmls = (names ? names.split("\n") : []).map((fullName) => {
|
||
const nameHtml = escapeHtml(fullName.trim());
|
||
return (
|
||
'<span class="calendar-event-hint-time"></span><span class="calendar-event-hint-name">' +
|
||
nameHtml +
|
||
"</span>"
|
||
);
|
||
});
|
||
}
|
||
if (rowHtmls.length === 0) return null;
|
||
return (
|
||
'<div class="calendar-event-hint-title">Дежурство:</div><div class="calendar-event-hint-rows">' +
|
||
rowHtmls
|
||
.map((r) => '<div class="calendar-event-hint-row">' + r + "</div>")
|
||
.join("") +
|
||
"</div>"
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Remove active class from all duty/unavailable/vacation markers.
|
||
*/
|
||
export function clearActiveDutyMarker() {
|
||
if (!calendarEl) return;
|
||
calendarEl
|
||
.querySelectorAll(
|
||
".duty-marker.calendar-marker-active, .unavailable-marker.calendar-marker-active, .vacation-marker.calendar-marker-active"
|
||
)
|
||
.forEach((m) => m.classList.remove("calendar-marker-active"));
|
||
}
|
||
|
||
/**
|
||
* Bind click tooltips for .info-btn (calendar event summaries).
|
||
*/
|
||
export function bindInfoButtonTooltips() {
|
||
let hintEl = document.getElementById("calendarEventHint");
|
||
if (!hintEl) {
|
||
hintEl = document.createElement("div");
|
||
hintEl.id = "calendarEventHint";
|
||
hintEl.className = "calendar-event-hint";
|
||
hintEl.setAttribute("role", "tooltip");
|
||
hintEl.hidden = true;
|
||
document.body.appendChild(hintEl);
|
||
}
|
||
if (!calendarEl) return;
|
||
calendarEl.querySelectorAll(".info-btn").forEach((btn) => {
|
||
btn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
const summary = btn.getAttribute("data-summary") || "";
|
||
const content = "События:\n" + summary;
|
||
if (hintEl.hidden || hintEl.textContent !== content) {
|
||
hintEl.textContent = content;
|
||
const rect = btn.getBoundingClientRect();
|
||
positionHint(hintEl, rect);
|
||
hintEl.dataset.active = "1";
|
||
} else {
|
||
hintEl.hidden = true;
|
||
hintEl.removeAttribute("data-active");
|
||
}
|
||
});
|
||
});
|
||
if (!document._calendarHintBound) {
|
||
document._calendarHintBound = true;
|
||
document.addEventListener("click", () => {
|
||
if (hintEl.dataset.active) {
|
||
hintEl.hidden = true;
|
||
hintEl.removeAttribute("data-active");
|
||
}
|
||
});
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape" && hintEl.dataset.active) {
|
||
hintEl.hidden = true;
|
||
hintEl.removeAttribute("data-active");
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Bind hover/click tooltips for duty/unavailable/vacation markers.
|
||
*/
|
||
export function bindDutyMarkerTooltips() {
|
||
let hintEl = document.getElementById("dutyMarkerHint");
|
||
if (!hintEl) {
|
||
hintEl = document.createElement("div");
|
||
hintEl.id = "dutyMarkerHint";
|
||
hintEl.className = "calendar-event-hint";
|
||
hintEl.setAttribute("role", "tooltip");
|
||
hintEl.hidden = true;
|
||
document.body.appendChild(hintEl);
|
||
}
|
||
if (!calendarEl) return;
|
||
let hideTimeout = null;
|
||
const selector = ".duty-marker, .unavailable-marker, .vacation-marker";
|
||
calendarEl.querySelectorAll(selector).forEach((marker) => {
|
||
marker.addEventListener("mouseenter", () => {
|
||
if (hideTimeout) {
|
||
clearTimeout(hideTimeout);
|
||
hideTimeout = null;
|
||
}
|
||
const html = getDutyMarkerHintHtml(marker);
|
||
if (html) {
|
||
hintEl.innerHTML = html;
|
||
} else {
|
||
hintEl.textContent = getDutyMarkerHintContent(marker);
|
||
}
|
||
const rect = marker.getBoundingClientRect();
|
||
positionHint(hintEl, rect);
|
||
hintEl.hidden = false;
|
||
});
|
||
marker.addEventListener("mouseleave", () => {
|
||
if (hintEl.dataset.active) return;
|
||
hideTimeout = setTimeout(() => {
|
||
hintEl.hidden = true;
|
||
hideTimeout = null;
|
||
}, 150);
|
||
});
|
||
marker.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
if (marker.classList.contains("calendar-marker-active")) {
|
||
hintEl.hidden = true;
|
||
hintEl.removeAttribute("data-active");
|
||
marker.classList.remove("calendar-marker-active");
|
||
return;
|
||
}
|
||
clearActiveDutyMarker();
|
||
const html = getDutyMarkerHintHtml(marker);
|
||
if (html) {
|
||
hintEl.innerHTML = html;
|
||
} else {
|
||
hintEl.textContent = getDutyMarkerHintContent(marker);
|
||
}
|
||
const rect = marker.getBoundingClientRect();
|
||
positionHint(hintEl, rect);
|
||
hintEl.hidden = false;
|
||
hintEl.dataset.active = "1";
|
||
marker.classList.add("calendar-marker-active");
|
||
});
|
||
});
|
||
if (!document._dutyMarkerHintBound) {
|
||
document._dutyMarkerHintBound = true;
|
||
document.addEventListener("click", () => {
|
||
if (hintEl.dataset.active) {
|
||
hintEl.hidden = true;
|
||
hintEl.removeAttribute("data-active");
|
||
clearActiveDutyMarker();
|
||
}
|
||
});
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape" && hintEl.dataset.active) {
|
||
hintEl.hidden = true;
|
||
hintEl.removeAttribute("data-active");
|
||
clearActiveDutyMarker();
|
||
}
|
||
});
|
||
}
|
||
}
|