Extracts the day-cell construction into a self-contained makeDayCell(d). Slot changes and holiday/closure toggles now replace just that one cell (cell.replaceWith) plus update export-button visibility, instead of rebuilding the entire month grid and re-attaching every listener on each interaction. Structural changes (month/year/slot-count) still do a full render. Faithful extraction; verified headless that the page loads without JS errors and tests pass. Interactive render coverage to follow in the test-layer issue. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
340 lines
12 KiB
JavaScript
340 lines
12 KiB
JavaScript
import { state, saveState } from './app.js';
|
|
import { getWorkdays, generatePlan } from './algorithm.js';
|
|
import { toggleHoliday, toggleClosure, addHistoryEntries, removeHistoryEntry } from './data.js';
|
|
import { exportSchedule, downloadBlob } from './excel.js';
|
|
|
|
const MONTHS = ['Januar','Februar','März','April','Mai','Juni',
|
|
'Juli','August','September','Oktober','November','Dezember'];
|
|
const WD_LABELS = ['Mo','Di','Mi','Do','Fr','Sa','So'];
|
|
|
|
let currentYear = new Date().getFullYear();
|
|
let currentMonth = Math.min(Math.max(new Date().getMonth() + 1, 1), 12);
|
|
let currentPlan = []; // [{ date, employeeId, manual }] for displayed month
|
|
|
|
export function renderCalendar() {
|
|
const container = document.getElementById('tab-planning');
|
|
container.replaceChildren();
|
|
|
|
// Title
|
|
const title = document.createElement('h2');
|
|
title.className = 'section-title';
|
|
title.textContent = 'Monatsplanung';
|
|
container.appendChild(title);
|
|
|
|
// Controls row
|
|
const controls = document.createElement('div');
|
|
controls.className = 'row';
|
|
|
|
const monthLabel = document.createElement('label');
|
|
monthLabel.textContent = 'Monat:';
|
|
|
|
const monthSel = document.createElement('select');
|
|
MONTHS.forEach((m, i) => {
|
|
const opt = document.createElement('option');
|
|
opt.value = String(i + 1);
|
|
opt.textContent = m;
|
|
if (i + 1 === currentMonth) opt.selected = true;
|
|
monthSel.appendChild(opt);
|
|
});
|
|
|
|
const yearLabel = document.createElement('label');
|
|
yearLabel.textContent = 'Jahr:';
|
|
|
|
const yearSel = document.createElement('select');
|
|
const thisYear = new Date().getFullYear();
|
|
for (let y = thisYear - 1; y <= thisYear + 2; y++) {
|
|
const opt = document.createElement('option');
|
|
opt.value = String(y);
|
|
opt.textContent = String(y);
|
|
if (y === currentYear) opt.selected = true;
|
|
yearSel.appendChild(opt);
|
|
}
|
|
|
|
const slotsLabel = document.createElement('label');
|
|
slotsLabel.textContent = 'Pro Tag:';
|
|
|
|
const slotsSel = document.createElement('select');
|
|
for (let n = 1; n <= 5; n++) {
|
|
const opt = document.createElement('option');
|
|
opt.value = String(n);
|
|
opt.textContent = String(n);
|
|
if (n === state.settings.moderatorsPerDay) opt.selected = true;
|
|
slotsSel.appendChild(opt);
|
|
}
|
|
|
|
const genBtn = document.createElement('button');
|
|
genBtn.type = 'button';
|
|
genBtn.className = 'primary';
|
|
genBtn.textContent = 'Plan generieren';
|
|
|
|
const exportBtn = document.createElement('button');
|
|
exportBtn.type = 'button';
|
|
exportBtn.textContent = 'Als Excel exportieren';
|
|
exportBtn.style.display = 'none';
|
|
|
|
controls.append(monthLabel, monthSel, yearLabel, yearSel, slotsLabel, slotsSel, genBtn, exportBtn);
|
|
container.appendChild(controls);
|
|
|
|
// Legend
|
|
const legend = document.createElement('div');
|
|
legend.className = 'cal-legend';
|
|
legend.innerHTML =
|
|
'<span><span class="cal-legend-dot" style="background:#fde68a"></span>Feiertag</span>' +
|
|
'<span><span class="cal-legend-dot" style="background:#fecaca"></span>Betriebsurlaub</span>';
|
|
container.appendChild(legend);
|
|
|
|
const warnBox = document.createElement('div');
|
|
warnBox.id = 'skipped-warning';
|
|
warnBox.style.display = 'none';
|
|
container.appendChild(warnBox);
|
|
|
|
// Calendar grid wrapper
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'calendar-outer';
|
|
wrapper.id = 'calendar-wrapper';
|
|
container.appendChild(wrapper);
|
|
|
|
loadPlanFromHistory();
|
|
renderGrid(wrapper, exportBtn);
|
|
|
|
monthSel.addEventListener('change', () => {
|
|
currentMonth = parseInt(monthSel.value);
|
|
loadPlanFromHistory();
|
|
renderGrid(wrapper, exportBtn);
|
|
});
|
|
|
|
yearSel.addEventListener('change', () => {
|
|
currentYear = parseInt(yearSel.value);
|
|
loadPlanFromHistory();
|
|
renderGrid(wrapper, exportBtn);
|
|
});
|
|
|
|
slotsSel.addEventListener('change', () => {
|
|
state.settings.moderatorsPerDay = parseInt(slotsSel.value, 10);
|
|
saveState();
|
|
renderGrid(wrapper, exportBtn);
|
|
});
|
|
|
|
genBtn.addEventListener('click', () => {
|
|
if (state.employees.length === 0) {
|
|
alert('Bitte zuerst Mitarbeiter hinzufügen.');
|
|
return;
|
|
}
|
|
const workdays = getWorkdays(currentYear, currentMonth,
|
|
state.calendar.holidays, state.calendar.companyClosures);
|
|
const prefix = currentYear + '-' + String(currentMonth).padStart(2, '0');
|
|
// Preserve manual picks for this month; only auto-entries get regenerated.
|
|
const manualEntries = state.history.filter(h => h.date.startsWith(prefix) && h.manual);
|
|
const historyWithoutMonth = state.history.filter(h => !h.date.startsWith(prefix));
|
|
// Drop stale auto-entries for the month, keep history + manual picks
|
|
state.history = historyWithoutMonth.concat(manualEntries);
|
|
const result = generatePlan(workdays, state.employees, historyWithoutMonth,
|
|
Math.random, state.settings.moderatorsPerDay, manualEntries);
|
|
addHistoryEntries(state, result.assignments);
|
|
currentPlan = manualEntries.concat(result.assignments);
|
|
saveState();
|
|
renderSkippedWarning(warnBox, result.skippedDates);
|
|
renderGrid(wrapper, exportBtn);
|
|
});
|
|
|
|
exportBtn.addEventListener('click', () => {
|
|
if (typeof XLSX === 'undefined') {
|
|
alert('Excel-Export nicht verfügbar (SheetJS konnte nicht geladen werden).');
|
|
return;
|
|
}
|
|
const blob = exportSchedule(currentPlan, state.employees);
|
|
downloadBlob(blob, 'Moderationsplan-' + MONTHS[currentMonth-1] + '-' + currentYear + '.xlsx');
|
|
});
|
|
}
|
|
|
|
function loadPlanFromHistory() {
|
|
const prefix = currentYear + '-' + String(currentMonth).padStart(2,'0');
|
|
currentPlan = state.history
|
|
.filter(h => h.date.startsWith(prefix))
|
|
.map(h => ({ ...h }));
|
|
}
|
|
|
|
function renderGrid(wrapper, exportBtn) {
|
|
wrapper.replaceChildren();
|
|
|
|
const daysInMonth = new Date(currentYear, currentMonth, 0).getDate();
|
|
const empNameById = Object.fromEntries(state.employees.map(e => [e.id, e.name]));
|
|
const updateExportVisibility = () => {
|
|
exportBtn.style.display = currentPlan.length > 0 ? 'inline-block' : 'none';
|
|
};
|
|
|
|
// Build one day cell. Self-contained so a change to a single day re-renders
|
|
// just that cell (cell.replaceWith) instead of rebuilding the whole grid.
|
|
function makeDayCell(d) {
|
|
const iso = currentYear + '-' + String(currentMonth).padStart(2,'0') + '-' + String(d).padStart(2,'0');
|
|
const wdNum = (() => { const x = new Date(iso + 'T12:00:00Z').getUTCDay(); return x === 0 ? 7 : x; })();
|
|
const isWeekend = wdNum >= 6;
|
|
const isHoliday = state.calendar.holidays.includes(iso);
|
|
const isClosure = state.calendar.companyClosures.includes(iso);
|
|
|
|
const cell = document.createElement('div');
|
|
cell.className = 'cal-day';
|
|
if (isWeekend) cell.classList.add('weekend');
|
|
else if (isHoliday) cell.classList.add('holiday');
|
|
else if (isClosure) cell.classList.add('closure');
|
|
|
|
const dayNum = document.createElement('div');
|
|
dayNum.className = 'day-num';
|
|
dayNum.textContent = d + ' ';
|
|
const wdSpan = document.createElement('span');
|
|
wdSpan.className = 'day-tag';
|
|
wdSpan.textContent = WD_LABELS[wdNum - 1];
|
|
dayNum.appendChild(wdSpan);
|
|
cell.appendChild(dayNum);
|
|
|
|
// Re-render only this cell after a change affecting this day
|
|
const refresh = () => { cell.replaceWith(makeDayCell(d)); updateExportVisibility(); };
|
|
|
|
if (!isWeekend) {
|
|
if (isHoliday || isClosure) {
|
|
const tag = document.createElement('div');
|
|
tag.className = 'cal-day-tag-label';
|
|
tag.textContent = isHoliday ? 'Feiertag' : 'Betriebsurlaub';
|
|
const removeBtn = document.createElement('button');
|
|
removeBtn.type = 'button';
|
|
removeBtn.textContent = '✕';
|
|
removeBtn.addEventListener('click', () => {
|
|
if (isHoliday) toggleHoliday(state, iso);
|
|
else toggleClosure(state, iso);
|
|
saveState();
|
|
refresh();
|
|
});
|
|
tag.appendChild(removeBtn);
|
|
cell.appendChild(tag);
|
|
} else {
|
|
const slotCount = state.settings.moderatorsPerDay;
|
|
const dayEntries = currentPlan
|
|
.filter(a => a.date === iso)
|
|
.sort((x, y) =>
|
|
(empNameById[x.employeeId] ?? '').localeCompare(empNameById[y.employeeId] ?? '', 'de'));
|
|
const slotValues = []; // employeeId per slot index, '' if empty
|
|
for (let s = 0; s < slotCount; s++) slotValues.push(dayEntries[s]?.employeeId ?? '');
|
|
|
|
for (let slotIdx = 0; slotIdx < slotCount; slotIdx++) {
|
|
const sel = document.createElement('select');
|
|
sel.dataset.slot = String(slotIdx);
|
|
|
|
const emptyOpt = document.createElement('option');
|
|
emptyOpt.value = '';
|
|
emptyOpt.textContent = '— Leer —';
|
|
sel.appendChild(emptyOpt);
|
|
|
|
for (const emp of state.employees) {
|
|
const opt = document.createElement('option');
|
|
opt.value = emp.id;
|
|
opt.textContent = emp.name; // textContent: safe
|
|
// Disable if already picked in a different slot for this day
|
|
const pickedElsewhere = slotValues.some((id, i) => i !== slotIdx && id === emp.id);
|
|
if (pickedElsewhere) opt.disabled = true;
|
|
if (emp.id === slotValues[slotIdx]) opt.selected = true;
|
|
sel.appendChild(opt);
|
|
}
|
|
|
|
sel.addEventListener('change', () => {
|
|
const newId = sel.value;
|
|
const oldId = slotValues[slotIdx];
|
|
if (oldId) {
|
|
removeHistoryEntry(state, iso, oldId);
|
|
currentPlan = currentPlan.filter(a => !(a.date === iso && a.employeeId === oldId));
|
|
}
|
|
if (newId) {
|
|
const entry = { date: iso, employeeId: newId, manual: true };
|
|
addHistoryEntries(state, [entry]);
|
|
currentPlan.push(entry);
|
|
}
|
|
saveState();
|
|
refresh();
|
|
});
|
|
|
|
cell.appendChild(sel);
|
|
}
|
|
|
|
const btnRow = document.createElement('div');
|
|
btnRow.className = 'cal-btn-row';
|
|
|
|
const ftBtn = document.createElement('button');
|
|
ftBtn.type = 'button';
|
|
ftBtn.textContent = 'Feiertag';
|
|
ftBtn.addEventListener('click', () => { toggleHoliday(state, iso); saveState(); refresh(); });
|
|
|
|
const buBtn = document.createElement('button');
|
|
buBtn.type = 'button';
|
|
buBtn.textContent = 'Betriebsurl.';
|
|
buBtn.addEventListener('click', () => { toggleClosure(state, iso); saveState(); refresh(); });
|
|
|
|
btnRow.append(ftBtn, buBtn);
|
|
cell.appendChild(btnRow);
|
|
}
|
|
}
|
|
|
|
return cell;
|
|
}
|
|
|
|
// Day-of-week header row
|
|
const headerGrid = document.createElement('div');
|
|
headerGrid.className = 'calendar-header-grid';
|
|
WD_LABELS.forEach(lbl => {
|
|
const cell = document.createElement('div');
|
|
cell.className = 'cal-header-cell';
|
|
cell.textContent = lbl;
|
|
headerGrid.appendChild(cell);
|
|
});
|
|
wrapper.appendChild(headerGrid);
|
|
|
|
// Body grid
|
|
const bodyGrid = document.createElement('div');
|
|
bodyGrid.className = 'calendar-body-grid';
|
|
|
|
// Offset empty cells before first day
|
|
const firstISO = currentYear + '-' + String(currentMonth).padStart(2,'0') + '-01';
|
|
const firstWD = (() => { const d = new Date(firstISO + 'T12:00:00Z').getUTCDay(); return d === 0 ? 7 : d; })();
|
|
for (let i = 1; i < firstWD; i++) {
|
|
const empty = document.createElement('div');
|
|
empty.className = 'cal-day empty-cell';
|
|
bodyGrid.appendChild(empty);
|
|
}
|
|
|
|
// Day cells — each cell can re-render itself; no full-grid rebuild per click
|
|
for (let d = 1; d <= daysInMonth; d++) {
|
|
bodyGrid.appendChild(makeDayCell(d));
|
|
}
|
|
|
|
wrapper.appendChild(bodyGrid);
|
|
updateExportVisibility();
|
|
}
|
|
|
|
function renderSkippedWarning(box, skippedDates) {
|
|
box.replaceChildren();
|
|
if (!skippedDates || skippedDates.length === 0) {
|
|
box.style.display = 'none';
|
|
return;
|
|
}
|
|
box.style.display = 'flex';
|
|
box.className = 'skipped-warning';
|
|
|
|
const formatted = skippedDates.map(d => {
|
|
const [y, m, day] = d.split('-');
|
|
return day + '.' + m + '.';
|
|
}).join(', ');
|
|
|
|
const text = document.createElement('span');
|
|
text.textContent = skippedDates.length === 1
|
|
? '1 Tag konnte nicht vollständig besetzt werden: ' + formatted
|
|
: skippedDates.length + ' Tage konnten nicht vollständig besetzt werden: ' + formatted;
|
|
box.appendChild(text);
|
|
|
|
const closeBtn = document.createElement('button');
|
|
closeBtn.type = 'button';
|
|
closeBtn.textContent = '✕';
|
|
closeBtn.addEventListener('click', () => {
|
|
box.replaceChildren();
|
|
box.style.display = 'none';
|
|
});
|
|
box.appendChild(closeBtn);
|
|
}
|