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 = 'Feiertag' + 'Betriebsurlaub'; 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); }