Add event type handling for duties in the system

- Introduced a new `event_type` column in the `duties` table to categorize duties as 'duty', 'unavailable', or 'vacation'.
- Updated the duty schedule import functionality to parse and store event types from the JSON input.
- Enhanced the API response to include event types for each duty, improving the calendar display logic.
- Modified the web application to visually differentiate between duty types in the calendar and duty list.
- Updated tests to cover new event type functionality and ensure correct parsing and storage of duties.
- Revised README documentation to reflect changes in duty event types and their representation in the system.
This commit is contained in:
2026-02-17 23:01:07 +03:00
parent 78a1696a69
commit 7a963eccd1
12 changed files with 279 additions and 60 deletions

View File

@@ -198,18 +198,34 @@
const key = localDateString(d);
const isOther = d.getMonth() !== month;
const dayDuties = dutiesByDate[key] || [];
const dutyList = dayDuties.filter(function (x) { return x.event_type === "duty"; });
const unavailableList = dayDuties.filter(function (x) { return x.event_type === "unavailable"; });
const vacationList = dayDuties.filter(function (x) { return x.event_type === "vacation"; });
const hasAny = dayDuties.length > 0;
const isToday = key === today;
const eventSummaries = calendarEventsByDate[key] || [];
const hasEvent = eventSummaries.length > 0;
const cell = document.createElement("div");
cell.className = "day" + (isOther ? " other-month" : "") + (isToday ? " today" : "") + (dayDuties.length ? " has-duty" : "") + (hasEvent ? " holiday" : "");
const dutyNamesAttr = dayDuties.length ? escapeHtml(dayDuties.map(function (x) { return x.full_name; }).join("\n")) : "";
const dutyTitleAttr = dayDuties.length ? escapeHtml(dayDuties.map(function (x) { return x.full_name; }).join(", ")) : "";
cell.innerHTML =
"<span class=\"num\">" + d.getDate() + "</span>" +
(dayDuties.length ? "<span class=\"duty-marker\" data-duty-names=\"" + dutyNamesAttr + "\" title=\"" + dutyTitleAttr + "\" aria-label=\"Дежурные\">Д</span>" : "") +
(hasEvent ? "<button type=\"button\" class=\"info-btn\" aria-label=\"Информация о дне\" data-summary=\"" + escapeHtml(eventSummaries.join("\n")) + "\">i</button>" : "");
cell.className = "day" + (isOther ? " other-month" : "") + (isToday ? " today" : "") + (hasAny ? " has-duty" : "") + (hasEvent ? " holiday" : "");
function namesAttr(list) { return list.length ? escapeHtml(list.map(function (x) { return x.full_name; }).join("\n")) : ""; }
function titleAttr(list) { return list.length ? escapeHtml(list.map(function (x) { return x.full_name; }).join(", ")) : ""; }
let markers = "<span class=\"num\">" + d.getDate() + "</span>";
if (dutyList.length) {
markers += "<span class=\"duty-marker\" data-names=\"" + namesAttr(dutyList) + "\" title=\"" + titleAttr(dutyList) + "\" aria-label=\"Дежурные\">Д</span>";
}
if (unavailableList.length) {
markers += "<span class=\"unavailable-marker\" data-names=\"" + namesAttr(unavailableList) + "\" title=\"" + titleAttr(unavailableList) + "\" aria-label=\"Недоступен\">Н</span>";
}
if (vacationList.length) {
markers += "<span class=\"vacation-marker\" data-names=\"" + namesAttr(vacationList) + "\" title=\"" + titleAttr(vacationList) + "\" aria-label=\"Отпуск\">О</span>";
}
if (hasEvent) {
markers += "<button type=\"button\" class=\"info-btn\" aria-label=\"Информация о дне\" data-summary=\"" + escapeHtml(eventSummaries.join("\n")) + "\">i</button>";
}
cell.innerHTML = markers;
calendarEl.appendChild(cell);
d.setDate(d.getDate() + 1);
}
@@ -312,13 +328,14 @@
document.body.appendChild(hintEl);
}
var hideTimeout = null;
calendarEl.querySelectorAll(".duty-marker").forEach(function (marker) {
var selector = ".duty-marker, .unavailable-marker, .vacation-marker";
calendarEl.querySelectorAll(selector).forEach(function (marker) {
marker.addEventListener("mouseenter", function () {
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
var names = marker.getAttribute("data-duty-names") || "";
var names = marker.getAttribute("data-names") || "";
hintEl.textContent = names;
var rect = marker.getBoundingClientRect();
positionHint(hintEl, rect);
@@ -333,9 +350,11 @@
});
}
var EVENT_TYPE_LABELS = { duty: "Дежурство", unavailable: "Недоступен", vacation: "Отпуск" };
function renderDutyList(duties) {
if (duties.length === 0) {
dutyListEl.innerHTML = "<p class=\"muted\">В этом месяце дежурств нет.</p>";
dutyListEl.innerHTML = "<p class=\"muted\">В этом месяце событий нет.</p>";
return;
}
const grouped = {};
@@ -354,7 +373,9 @@
const endDate = new Date(d.end_at);
const start = String(startDate.getHours()).padStart(2, "0") + ":" + String(startDate.getMinutes()).padStart(2, "0");
const end = String(endDate.getHours()).padStart(2, "0") + ":" + String(endDate.getMinutes()).padStart(2, "0");
html += "<div class=\"duty-item\"><span class=\"name\">" + escapeHtml(d.full_name) + "</span><div class=\"time\">" + start + " " + end + "</div></div>";
const typeLabel = EVENT_TYPE_LABELS[d.event_type] || d.event_type;
const itemClass = "duty-item duty-item--" + (d.event_type || "duty");
html += "<div class=\"" + itemClass + "\"><span class=\"duty-item-type\">" + escapeHtml(typeLabel) + "</span> <span class=\"name\">" + escapeHtml(d.full_name) + "</span><div class=\"time\">" + start + " " + end + "</div></div>";
});
});
dutyListEl.innerHTML = html;