Compare commits
10 Commits
b5cfe5b336
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac5e80726f | ||
|
|
dca82fe381 | ||
|
|
6ecf3e4409 | ||
|
|
b9fb08299e | ||
|
|
bcdba20e09 | ||
|
|
d6b013132a | ||
|
|
d65758ed91 | ||
|
|
d61dcbe6b2 | ||
|
|
72a2af3a74 | ||
|
|
adf656c9f3 |
28
js/app.js
28
js/app.js
@@ -2,6 +2,8 @@ import { renderEmployees } from './employees.js';
|
|||||||
import { renderCalendar } from './calendar.js';
|
import { renderCalendar } from './calendar.js';
|
||||||
import { renderDataTab } from './history.js';
|
import { renderDataTab } from './history.js';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'morning-planner-state';
|
||||||
|
|
||||||
// Shared mutable state — imported by all other modules
|
// Shared mutable state — imported by all other modules
|
||||||
export const state = {
|
export const state = {
|
||||||
employees: [],
|
employees: [],
|
||||||
@@ -9,6 +11,32 @@ export const state = {
|
|||||||
history: []
|
history: []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Load persisted state from localStorage on startup
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (Array.isArray(parsed.employees)) state.employees = parsed.employees;
|
||||||
|
if (parsed.calendar) state.calendar = parsed.calendar;
|
||||||
|
if (Array.isArray(parsed.history)) state.history = parsed.history;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Corrupt storage — start fresh
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call after any state mutation to persist automatically
|
||||||
|
export function saveState() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||||
|
employees: state.employees,
|
||||||
|
calendar: state.calendar,
|
||||||
|
history: state.history
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// Storage full or unavailable — silently ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Tab switching
|
// Tab switching
|
||||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
|
|||||||
254
js/calendar.js
Normal file
254
js/calendar.js
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
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 = 2026;
|
||||||
|
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 yearOpt = document.createElement('option');
|
||||||
|
yearOpt.value = '2026';
|
||||||
|
yearOpt.textContent = '2026';
|
||||||
|
yearOpt.selected = true;
|
||||||
|
yearSel.appendChild(yearOpt);
|
||||||
|
|
||||||
|
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, genBtn, exportBtn);
|
||||||
|
container.appendChild(controls);
|
||||||
|
|
||||||
|
// Legend
|
||||||
|
const legend = document.createElement('div');
|
||||||
|
legend.className = 'row';
|
||||||
|
legend.style.cssText = 'font-size:12px;color:#6b7280';
|
||||||
|
legend.textContent = 'FT = Feiertag markieren | BU = Betriebsurlaub markieren';
|
||||||
|
container.appendChild(legend);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
const historyWithoutMonth = state.history.filter(h => !h.date.startsWith(prefix));
|
||||||
|
currentPlan = generatePlan(workdays, state.employees, historyWithoutMonth);
|
||||||
|
addHistoryEntries(state, currentPlan);
|
||||||
|
saveState();
|
||||||
|
renderGrid(wrapper, exportBtn);
|
||||||
|
});
|
||||||
|
|
||||||
|
exportBtn.addEventListener('click', () => {
|
||||||
|
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 nameById = Object.fromEntries(state.employees.map(e => [e.id, e.name]));
|
||||||
|
const planByDate = Object.fromEntries(currentPlan.map(a => [a.date, a.employeeId]));
|
||||||
|
|
||||||
|
// 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.style.cssText = 'text-align:center;font-weight:600;font-size:12px;color:#6b7280;padding:4px';
|
||||||
|
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
|
||||||
|
for (let d = 1; d <= daysInMonth; 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 assigneeId = planByDate[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');
|
||||||
|
|
||||||
|
// Day number + weekday label
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (!isWeekend) {
|
||||||
|
if (isHoliday || isClosure) {
|
||||||
|
// Show label + remove button
|
||||||
|
const tag = document.createElement('div');
|
||||||
|
tag.style.cssText = 'font-size:11px;margin-top:4px';
|
||||||
|
tag.textContent = isHoliday ? 'Feiertag' : 'Betriebsurlaub';
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.type = 'button';
|
||||||
|
removeBtn.textContent = '✕';
|
||||||
|
removeBtn.style.cssText = 'font-size:9px;padding:1px 4px;margin-left:4px';
|
||||||
|
removeBtn.addEventListener('click', () => {
|
||||||
|
if (isHoliday) toggleHoliday(state, iso);
|
||||||
|
else toggleClosure(state, iso);
|
||||||
|
saveState();
|
||||||
|
renderGrid(wrapper, exportBtn);
|
||||||
|
});
|
||||||
|
tag.appendChild(removeBtn);
|
||||||
|
cell.appendChild(tag);
|
||||||
|
} else {
|
||||||
|
// Assignee dropdown
|
||||||
|
const sel = document.createElement('select');
|
||||||
|
sel.style.cssText = 'width:100%;margin-top:4px;font-size:11px';
|
||||||
|
|
||||||
|
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
|
||||||
|
if (emp.id === assigneeId) opt.selected = true;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
}
|
||||||
|
|
||||||
|
sel.addEventListener('change', () => {
|
||||||
|
const empId = sel.value;
|
||||||
|
if (empId) {
|
||||||
|
const entry = { date: iso, employeeId: empId, manual: true };
|
||||||
|
addHistoryEntries(state, [entry]);
|
||||||
|
const idx = currentPlan.findIndex(a => a.date === iso);
|
||||||
|
if (idx >= 0) currentPlan[idx] = entry; else currentPlan.push(entry);
|
||||||
|
} else {
|
||||||
|
removeHistoryEntry(state, iso);
|
||||||
|
currentPlan = currentPlan.filter(a => a.date !== iso);
|
||||||
|
}
|
||||||
|
saveState();
|
||||||
|
exportBtn.style.display = currentPlan.length > 0 ? 'inline-block' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
cell.appendChild(sel);
|
||||||
|
|
||||||
|
// FT / BU mark buttons
|
||||||
|
const btnRow = document.createElement('div');
|
||||||
|
btnRow.style.cssText = 'margin-top:4px;display:flex;gap:2px';
|
||||||
|
|
||||||
|
const ftBtn = document.createElement('button');
|
||||||
|
ftBtn.type = 'button';
|
||||||
|
ftBtn.textContent = 'FT';
|
||||||
|
ftBtn.style.cssText = 'font-size:9px;padding:1px 4px';
|
||||||
|
ftBtn.addEventListener('click', () => { toggleHoliday(state, iso); saveState(); renderGrid(wrapper, exportBtn); });
|
||||||
|
|
||||||
|
const buBtn = document.createElement('button');
|
||||||
|
buBtn.type = 'button';
|
||||||
|
buBtn.textContent = 'BU';
|
||||||
|
buBtn.style.cssText = 'font-size:9px;padding:1px 4px';
|
||||||
|
buBtn.addEventListener('click', () => { toggleClosure(state, iso); saveState(); renderGrid(wrapper, exportBtn); });
|
||||||
|
|
||||||
|
btnRow.append(ftBtn, buBtn);
|
||||||
|
cell.appendChild(btnRow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyGrid.appendChild(cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapper.appendChild(bodyGrid);
|
||||||
|
exportBtn.style.display = currentPlan.length > 0 ? 'inline-block' : 'none';
|
||||||
|
}
|
||||||
271
js/employees.js
Normal file
271
js/employees.js
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import { state, saveState } from './app.js';
|
||||||
|
import { createEmployee, removeEmployee } from './data.js';
|
||||||
|
import { readEmployeeNames } from './excel.js';
|
||||||
|
|
||||||
|
const WD_LABELS = ['Mo','Di','Mi','Do','Fr'];
|
||||||
|
const WD_VALUES = [1,2,3,4,5];
|
||||||
|
|
||||||
|
export function renderEmployees() {
|
||||||
|
const container = document.getElementById('tab-employees');
|
||||||
|
|
||||||
|
// Build static shell
|
||||||
|
const shell = document.createElement('div');
|
||||||
|
const title = document.createElement('h2');
|
||||||
|
title.className = 'section-title';
|
||||||
|
title.textContent = 'Mitarbeiter';
|
||||||
|
shell.appendChild(title);
|
||||||
|
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'row';
|
||||||
|
|
||||||
|
const nameInput = document.createElement('input');
|
||||||
|
nameInput.id = 'emp-name-input';
|
||||||
|
nameInput.type = 'text';
|
||||||
|
nameInput.placeholder = 'Name eingeben...';
|
||||||
|
nameInput.style.width = '220px';
|
||||||
|
|
||||||
|
const addBtn = document.createElement('button');
|
||||||
|
addBtn.type = 'button';
|
||||||
|
addBtn.className = 'primary';
|
||||||
|
addBtn.textContent = 'Hinzufügen';
|
||||||
|
|
||||||
|
const excelBtn = document.createElement('button');
|
||||||
|
excelBtn.type = 'button';
|
||||||
|
excelBtn.textContent = 'Excel importieren';
|
||||||
|
|
||||||
|
const fileInput = document.createElement('input');
|
||||||
|
fileInput.id = 'emp-excel-input';
|
||||||
|
fileInput.type = 'file';
|
||||||
|
fileInput.accept = '.xlsx,.xls';
|
||||||
|
fileInput.style.display = 'none';
|
||||||
|
|
||||||
|
row.append(nameInput, addBtn, excelBtn, fileInput);
|
||||||
|
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.id = 'employee-list';
|
||||||
|
|
||||||
|
shell.append(row, list);
|
||||||
|
container.replaceChildren(shell);
|
||||||
|
|
||||||
|
refreshList();
|
||||||
|
|
||||||
|
addBtn.addEventListener('click', () => {
|
||||||
|
const name = nameInput.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
try {
|
||||||
|
state.employees.push(createEmployee(name));
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nameInput.value = '';
|
||||||
|
saveState();
|
||||||
|
refreshList();
|
||||||
|
});
|
||||||
|
|
||||||
|
nameInput.addEventListener('keydown', e => { if (e.key === 'Enter') addBtn.click(); });
|
||||||
|
|
||||||
|
excelBtn.addEventListener('click', () => fileInput.click());
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', async e => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const buf = await file.arrayBuffer();
|
||||||
|
const names = readEmployeeNames(buf);
|
||||||
|
for (const name of names) {
|
||||||
|
if (!state.employees.some(emp => emp.name === name)) {
|
||||||
|
try { state.employees.push(createEmployee(name)); } catch { /* skip invalid */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.target.value = '';
|
||||||
|
saveState();
|
||||||
|
refreshList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshList() {
|
||||||
|
const list = document.getElementById('employee-list');
|
||||||
|
list.replaceChildren();
|
||||||
|
|
||||||
|
if (state.employees.length === 0) {
|
||||||
|
const msg = document.createElement('p');
|
||||||
|
msg.style.cssText = 'color:#9ca3af;margin-top:16px';
|
||||||
|
msg.textContent = 'Noch keine Mitarbeiter hinzugefügt.';
|
||||||
|
list.appendChild(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const emp of state.employees) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'employee-item';
|
||||||
|
|
||||||
|
const nameSpan = document.createElement('span');
|
||||||
|
nameSpan.className = 'employee-name';
|
||||||
|
nameSpan.textContent = emp.name; // textContent: safe, no XSS
|
||||||
|
|
||||||
|
const editBtn = document.createElement('button');
|
||||||
|
editBtn.type = 'button';
|
||||||
|
editBtn.textContent = 'Einschränkungen';
|
||||||
|
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.type = 'button';
|
||||||
|
removeBtn.className = 'danger';
|
||||||
|
removeBtn.textContent = 'Entfernen';
|
||||||
|
|
||||||
|
item.append(nameSpan, editBtn, removeBtn);
|
||||||
|
list.appendChild(item);
|
||||||
|
|
||||||
|
const editorDiv = document.createElement('div');
|
||||||
|
editorDiv.id = 'editor-' + emp.id;
|
||||||
|
editorDiv.style.display = 'none';
|
||||||
|
list.appendChild(editorDiv);
|
||||||
|
|
||||||
|
removeBtn.addEventListener('click', () => {
|
||||||
|
removeEmployee(state, emp.id);
|
||||||
|
saveState();
|
||||||
|
refreshList();
|
||||||
|
});
|
||||||
|
|
||||||
|
editBtn.addEventListener('click', () => {
|
||||||
|
if (editorDiv.style.display === 'none') {
|
||||||
|
renderConstraintEditor(emp.id);
|
||||||
|
editorDiv.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
editorDiv.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConstraintEditor(empId) {
|
||||||
|
const emp = state.employees.find(e => e.id === empId);
|
||||||
|
if (!emp) return;
|
||||||
|
const c = emp.constraints;
|
||||||
|
const el = document.getElementById('editor-' + empId);
|
||||||
|
el.replaceChildren();
|
||||||
|
|
||||||
|
const form = document.createElement('div');
|
||||||
|
form.className = 'constraint-form';
|
||||||
|
|
||||||
|
// Helper: day toggle group
|
||||||
|
function makeDayGroup(label, constraintKey) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'constraint-row';
|
||||||
|
const lbl = document.createElement('label');
|
||||||
|
lbl.textContent = label;
|
||||||
|
const group = document.createElement('div');
|
||||||
|
group.className = 'day-toggle-group';
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const day = WD_VALUES[i];
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'day-toggle' + (c[constraintKey].includes(day) ? ' active' : '');
|
||||||
|
btn.textContent = WD_LABELS[i];
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const arr = emp.constraints[constraintKey];
|
||||||
|
const idx = arr.indexOf(day);
|
||||||
|
if (idx >= 0) arr.splice(idx, 1); else arr.push(day);
|
||||||
|
btn.classList.toggle('active');
|
||||||
|
saveState();
|
||||||
|
});
|
||||||
|
group.appendChild(btn);
|
||||||
|
}
|
||||||
|
row.append(lbl, group);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: number input
|
||||||
|
function makeNumberInput(label, constraintKey, placeholder) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'constraint-row';
|
||||||
|
const lbl = document.createElement('label');
|
||||||
|
lbl.textContent = label;
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'number';
|
||||||
|
input.min = '0';
|
||||||
|
input.style.width = '120px';
|
||||||
|
input.placeholder = placeholder;
|
||||||
|
input.value = c[constraintKey] != null ? c[constraintKey] : '';
|
||||||
|
input.addEventListener('change', () => {
|
||||||
|
const val = input.value.trim();
|
||||||
|
emp.constraints[constraintKey] = val === '' ? null : parseInt(val, 10);
|
||||||
|
saveState();
|
||||||
|
});
|
||||||
|
row.append(lbl, input);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.appendChild(makeDayGroup('Nie verfügbar', 'neverDays'));
|
||||||
|
form.appendChild(makeDayGroup('Niedrige Priorität / Home-Office', 'lowPriorityDays'));
|
||||||
|
form.appendChild(makeDayGroup('Nur an diesen Tagen (leer = alle)', 'onlyDays'));
|
||||||
|
form.appendChild(makeNumberInput('Max. Einsätze pro Monat', 'maxPerMonth', 'Kein Limit'));
|
||||||
|
form.appendChild(makeNumberInput('Mindestabstand (Tage)', 'minGapDays', '0'));
|
||||||
|
|
||||||
|
// Vacation section
|
||||||
|
const vacRow = document.createElement('div');
|
||||||
|
vacRow.className = 'constraint-row';
|
||||||
|
const vacLabel = document.createElement('label');
|
||||||
|
vacLabel.textContent = 'Urlaub';
|
||||||
|
const vacContainer = document.createElement('div');
|
||||||
|
vacContainer.id = 'vacations-' + empId;
|
||||||
|
const addVacBtn = document.createElement('button');
|
||||||
|
addVacBtn.type = 'button';
|
||||||
|
addVacBtn.textContent = '+ Urlaub hinzufügen';
|
||||||
|
addVacBtn.style.marginTop = '6px';
|
||||||
|
vacRow.append(vacLabel, vacContainer, addVacBtn);
|
||||||
|
form.appendChild(vacRow);
|
||||||
|
|
||||||
|
el.appendChild(form);
|
||||||
|
|
||||||
|
renderVacationList(empId);
|
||||||
|
|
||||||
|
addVacBtn.addEventListener('click', () => {
|
||||||
|
emp.constraints.vacations.push({ from: '', to: '' });
|
||||||
|
saveState();
|
||||||
|
renderVacationList(empId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVacationList(empId) {
|
||||||
|
const emp = state.employees.find(e => e.id === empId);
|
||||||
|
const container = document.getElementById('vacations-' + empId);
|
||||||
|
container.replaceChildren();
|
||||||
|
|
||||||
|
if (emp.constraints.vacations.length === 0) {
|
||||||
|
const msg = document.createElement('span');
|
||||||
|
msg.style.cssText = 'color:#9ca3af;font-size:12px';
|
||||||
|
msg.textContent = 'Kein Urlaub eingetragen';
|
||||||
|
container.appendChild(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emp.constraints.vacations.forEach((v, i) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'row';
|
||||||
|
row.style.marginBottom = '4px';
|
||||||
|
|
||||||
|
const fromInput = document.createElement('input');
|
||||||
|
fromInput.type = 'date';
|
||||||
|
fromInput.value = v.from;
|
||||||
|
fromInput.addEventListener('change', () => { emp.constraints.vacations[i].from = fromInput.value; saveState(); });
|
||||||
|
|
||||||
|
const sep = document.createElement('span');
|
||||||
|
sep.textContent = 'bis';
|
||||||
|
|
||||||
|
const toInput = document.createElement('input');
|
||||||
|
toInput.type = 'date';
|
||||||
|
toInput.value = v.to;
|
||||||
|
toInput.addEventListener('change', () => { emp.constraints.vacations[i].to = toInput.value; saveState(); });
|
||||||
|
|
||||||
|
const delBtn = document.createElement('button');
|
||||||
|
delBtn.type = 'button';
|
||||||
|
delBtn.textContent = '✕';
|
||||||
|
delBtn.addEventListener('click', () => {
|
||||||
|
emp.constraints.vacations.splice(i, 1);
|
||||||
|
saveState();
|
||||||
|
renderVacationList(empId);
|
||||||
|
});
|
||||||
|
|
||||||
|
row.append(fromInput, sep, toInput, delBtn);
|
||||||
|
container.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
35
js/excel.js
Normal file
35
js/excel.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// Reads employee names from first column of xlsx (skips header row 0)
|
||||||
|
export function readEmployeeNames(arrayBuffer) {
|
||||||
|
const wb = XLSX.read(arrayBuffer, { type: 'array' });
|
||||||
|
const sheet = wb.Sheets[wb.SheetNames[0]];
|
||||||
|
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 });
|
||||||
|
return rows.slice(1)
|
||||||
|
.map(row => String(row[0] ?? '').trim())
|
||||||
|
.filter(name => name.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exports schedule as xlsx blob
|
||||||
|
// assignments: [{ date: "2026-04-01", employeeId }]
|
||||||
|
// employees: [{ id, name }]
|
||||||
|
export function exportSchedule(assignments, employees) {
|
||||||
|
const nameById = Object.fromEntries(employees.map(e => [e.id, e.name]));
|
||||||
|
const names = assignments.map(a => nameById[a.employeeId] ?? '');
|
||||||
|
const dates = assignments.map(a => {
|
||||||
|
const [y, m, d] = a.date.split('-');
|
||||||
|
return d + '.' + m + '.' + y;
|
||||||
|
});
|
||||||
|
const ws = XLSX.utils.aoa_to_sheet([names, dates]);
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, 'Moderationsplan');
|
||||||
|
const buf = XLSX.write(wb, { type: 'array', bookType: 'xlsx' });
|
||||||
|
return new Blob([buf], { type: 'application/octet-stream' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadBlob(blob, filename) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
150
js/history.js
Normal file
150
js/history.js
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { state, saveState } from './app.js';
|
||||||
|
import { loadFromJSON, saveToJSON, removeHistoryEntry } from './data.js';
|
||||||
|
import { downloadBlob } from './excel.js';
|
||||||
|
|
||||||
|
export function renderDataTab() {
|
||||||
|
const container = document.getElementById('tab-data');
|
||||||
|
container.replaceChildren();
|
||||||
|
|
||||||
|
const title = document.createElement('h2');
|
||||||
|
title.className = 'section-title';
|
||||||
|
title.textContent = 'Daten';
|
||||||
|
container.appendChild(title);
|
||||||
|
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.style.cssText = 'display:grid;grid-template-columns:1fr 1fr;gap:32px';
|
||||||
|
|
||||||
|
// Left: save/load
|
||||||
|
const saveLoadCol = document.createElement('div');
|
||||||
|
|
||||||
|
const saveTitle = document.createElement('h3');
|
||||||
|
saveTitle.style.cssText = 'margin-bottom:12px;font-size:15px';
|
||||||
|
saveTitle.textContent = 'Speichern / Laden';
|
||||||
|
saveLoadCol.appendChild(saveTitle);
|
||||||
|
|
||||||
|
const saveRow = document.createElement('div');
|
||||||
|
saveRow.className = 'row';
|
||||||
|
const saveBtn = document.createElement('button');
|
||||||
|
saveBtn.type = 'button';
|
||||||
|
saveBtn.className = 'primary';
|
||||||
|
saveBtn.textContent = 'Daten speichern (.json)';
|
||||||
|
saveRow.appendChild(saveBtn);
|
||||||
|
saveLoadCol.appendChild(saveRow);
|
||||||
|
|
||||||
|
const loadRow = document.createElement('div');
|
||||||
|
loadRow.className = 'row';
|
||||||
|
const loadBtn = document.createElement('button');
|
||||||
|
loadBtn.type = 'button';
|
||||||
|
loadBtn.textContent = 'Daten laden (.json)';
|
||||||
|
const fileInput = document.createElement('input');
|
||||||
|
fileInput.type = 'file';
|
||||||
|
fileInput.accept = '.json';
|
||||||
|
fileInput.style.display = 'none';
|
||||||
|
loadRow.append(loadBtn, fileInput);
|
||||||
|
saveLoadCol.appendChild(loadRow);
|
||||||
|
|
||||||
|
const hint = document.createElement('p');
|
||||||
|
hint.style.cssText = 'color:#6b7280;font-size:12px;margin-top:8px';
|
||||||
|
hint.textContent = 'Speichert Mitarbeiterliste, Einschränkungen und Verlauf. Beim nächsten Mal diese Datei laden.';
|
||||||
|
saveLoadCol.appendChild(hint);
|
||||||
|
|
||||||
|
// Right: history
|
||||||
|
const historyCol = document.createElement('div');
|
||||||
|
const historyTitle = document.createElement('h3');
|
||||||
|
historyTitle.style.cssText = 'margin-bottom:12px;font-size:15px';
|
||||||
|
historyTitle.textContent = 'Verlauf';
|
||||||
|
historyCol.appendChild(historyTitle);
|
||||||
|
|
||||||
|
const historyTable = document.createElement('div');
|
||||||
|
historyTable.id = 'history-table';
|
||||||
|
historyCol.appendChild(historyTable);
|
||||||
|
|
||||||
|
grid.append(saveLoadCol, historyCol);
|
||||||
|
container.appendChild(grid);
|
||||||
|
|
||||||
|
renderHistoryTable();
|
||||||
|
|
||||||
|
saveBtn.addEventListener('click', () => {
|
||||||
|
const blob = new Blob([saveToJSON(state)], { type: 'application/json' });
|
||||||
|
downloadBlob(blob, 'morning-planner-daten.json');
|
||||||
|
});
|
||||||
|
|
||||||
|
loadBtn.addEventListener('click', () => fileInput.click());
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', async e => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const loaded = loadFromJSON(await file.text());
|
||||||
|
state.employees = loaded.employees;
|
||||||
|
state.calendar = loaded.calendar;
|
||||||
|
state.history = loaded.history;
|
||||||
|
saveState();
|
||||||
|
alert('Daten erfolgreich geladen.');
|
||||||
|
renderDataTab();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Fehler beim Laden: ' + err.message);
|
||||||
|
}
|
||||||
|
e.target.value = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHistoryTable() {
|
||||||
|
const el = document.getElementById('history-table');
|
||||||
|
el.replaceChildren();
|
||||||
|
|
||||||
|
if (state.history.length === 0) {
|
||||||
|
const msg = document.createElement('p');
|
||||||
|
msg.style.cssText = 'color:#9ca3af;font-size:13px';
|
||||||
|
msg.textContent = 'Noch keine Einträge im Verlauf.';
|
||||||
|
el.appendChild(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameById = Object.fromEntries(state.employees.map(e => [e.id, e.name]));
|
||||||
|
const sorted = [...state.history].sort((a, b) => b.date.localeCompare(a.date));
|
||||||
|
|
||||||
|
const table = document.createElement('table');
|
||||||
|
const thead = document.createElement('thead');
|
||||||
|
const headRow = document.createElement('tr');
|
||||||
|
['Datum','Mitarbeiter','Typ',''].forEach(txt => {
|
||||||
|
const th = document.createElement('th');
|
||||||
|
th.textContent = txt;
|
||||||
|
headRow.appendChild(th);
|
||||||
|
});
|
||||||
|
thead.appendChild(headRow);
|
||||||
|
table.appendChild(thead);
|
||||||
|
|
||||||
|
const tbody = document.createElement('tbody');
|
||||||
|
for (const h of sorted) {
|
||||||
|
const [y, m, d] = h.date.split('-');
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
|
||||||
|
const dateTd = document.createElement('td');
|
||||||
|
dateTd.textContent = d + '.' + m + '.' + y;
|
||||||
|
|
||||||
|
const nameTd = document.createElement('td');
|
||||||
|
nameTd.textContent = nameById[h.employeeId] ?? '(unbekannt)'; // textContent: safe
|
||||||
|
|
||||||
|
const typeTd = document.createElement('td');
|
||||||
|
typeTd.style.color = '#6b7280';
|
||||||
|
typeTd.textContent = h.manual ? 'Manuell' : 'Auto';
|
||||||
|
|
||||||
|
const actionTd = document.createElement('td');
|
||||||
|
const delBtn = document.createElement('button');
|
||||||
|
delBtn.type = 'button';
|
||||||
|
delBtn.textContent = '✕';
|
||||||
|
delBtn.style.cssText = 'font-size:11px;padding:2px 6px';
|
||||||
|
delBtn.addEventListener('click', () => {
|
||||||
|
removeHistoryEntry(state, h.date);
|
||||||
|
saveState();
|
||||||
|
renderHistoryTable();
|
||||||
|
});
|
||||||
|
actionTd.appendChild(delBtn);
|
||||||
|
|
||||||
|
tr.append(dateTd, nameTd, typeTd, actionTd);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
table.appendChild(tbody);
|
||||||
|
el.appendChild(table);
|
||||||
|
}
|
||||||
@@ -99,4 +99,26 @@ function emp(id, constraints = {}) {
|
|||||||
assert.equal(result[0].employeeId, 'bob', 'bob (0 history) beats alice (5 history)');
|
assert.equal(result[0].employeeId, 'bob', 'bob (0 history) beats alice (5 history)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// companyClosures excluded from workdays
|
||||||
|
{
|
||||||
|
const days = getWorkdays(2026, 4, [], ['2026-04-01']);
|
||||||
|
assert.equal(days.length, 21, 'Closure reduces count by 1');
|
||||||
|
assert.ok(!days.includes('2026-04-01'), 'Closure date excluded');
|
||||||
|
}
|
||||||
|
|
||||||
|
// lowPriorityDays reduces score — bob without penalty wins over alice with penalty
|
||||||
|
{
|
||||||
|
// 2026-04-01 = Wednesday (3); alice has lowPriorityDays on Wednesday
|
||||||
|
// Both have same history (0) — alice's score gets -5 penalty, bob wins
|
||||||
|
const employees = [emp('alice', {lowPriorityDays:[3]}), emp('bob')];
|
||||||
|
const result = generatePlan(['2026-04-01'], employees, [], fixed);
|
||||||
|
assert.equal(result[0].employeeId, 'bob', 'alice low priority on Wednesday → bob wins');
|
||||||
|
}
|
||||||
|
|
||||||
|
// manual: false on auto-assigned entries
|
||||||
|
{
|
||||||
|
const result = generatePlan(['2026-04-01'], [emp('alice')], [], fixed);
|
||||||
|
assert.equal(result[0].manual, false, 'auto-assignment sets manual: false');
|
||||||
|
}
|
||||||
|
|
||||||
console.log('All algorithm tests passed.');
|
console.log('All algorithm tests passed.');
|
||||||
|
|||||||
Reference in New Issue
Block a user