507 lines
20 KiB
JavaScript
507 lines
20 KiB
JavaScript
(function () {
|
|
'use strict';
|
|
|
|
/* ── Daten aus DOM (CSP-sicher via <script type="application/json">) ──── */
|
|
const ALL_DOCTORS = JSON.parse(
|
|
document.getElementById('calDoctorsData').textContent
|
|
);
|
|
const BASE = '/calendar/api';
|
|
|
|
/* ── State ──────────────────────────────────────────────────────────────── */
|
|
let currentDate = new Date();
|
|
let appointments = [];
|
|
let holidays = {};
|
|
let visibleDocs = new Set(ALL_DOCTORS.map(d => d.id));
|
|
let editingId = null;
|
|
|
|
/* ── Hilfsfunktionen ────────────────────────────────────────────────────── */
|
|
const pad = n => String(n).padStart(2, '0');
|
|
const toISO = d => `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
|
|
const addDays = (d, n) => { const r = new Date(d); r.setDate(r.getDate() + n); return r; };
|
|
|
|
const WDAYS = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'];
|
|
const MONTHS = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
|
|
|
|
const TIME_SLOTS = (() => {
|
|
const s = [];
|
|
for (let h = 0; h < 24; h++)
|
|
for (let m = 0; m < 60; m += 15)
|
|
s.push(`${pad(h)}:${pad(m)}`);
|
|
return s;
|
|
})();
|
|
|
|
async function apiFetch(path, opts = {}) {
|
|
const res = await fetch(BASE + path, {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
...opts,
|
|
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'API-Fehler');
|
|
return data;
|
|
}
|
|
|
|
function showToast(msg, isError = false) {
|
|
const el = document.getElementById('calToast');
|
|
const txt = document.getElementById('calToastMsg');
|
|
txt.textContent = msg;
|
|
el.className = `toast align-items-center border-0 ${isError ? 'text-bg-danger' : 'text-bg-dark'}`;
|
|
bootstrap.Toast.getOrCreateInstance(el, { delay: 2800 }).show();
|
|
}
|
|
|
|
/* ── Tages-Daten laden ──────────────────────────────────────────────────── */
|
|
async function loadDay() {
|
|
const iso = toISO(currentDate);
|
|
appointments = await apiFetch(`/appointments/${iso}`);
|
|
await ensureHolidays(currentDate.getFullYear());
|
|
renderToolbar();
|
|
renderHolidayBanner();
|
|
renderColumns();
|
|
renderMiniCal();
|
|
}
|
|
|
|
async function ensureHolidays(year) {
|
|
if (holidays[year] !== undefined) return;
|
|
try {
|
|
const data = await apiFetch(`/holidays/${year}`);
|
|
holidays[year] = {};
|
|
for (const h of data.holidays) {
|
|
if (!holidays[year][h.date]) holidays[year][h.date] = [];
|
|
holidays[year][h.date].push(h);
|
|
}
|
|
} catch { holidays[year] = {}; }
|
|
}
|
|
|
|
/* ── Toolbar ────────────────────────────────────────────────────────────── */
|
|
function renderToolbar() {
|
|
const wd = WDAYS[currentDate.getDay()];
|
|
const day = currentDate.getDate();
|
|
const mon = MONTHS[currentDate.getMonth()];
|
|
const yr = currentDate.getFullYear();
|
|
document.getElementById('btnDateDisplay').textContent =
|
|
`${wd}, ${day}. ${mon} ${yr}`;
|
|
}
|
|
|
|
/* ── Feiertagsbanner ────────────────────────────────────────────────────── */
|
|
function renderHolidayBanner() {
|
|
const iso = toISO(currentDate);
|
|
const list = holidays[currentDate.getFullYear()]?.[iso];
|
|
const el = document.getElementById('calHolidayBanner');
|
|
if (list?.length) {
|
|
document.getElementById('calHolidayText').textContent =
|
|
'Feiertag: ' + list.map(h => h.name).join(' · ');
|
|
el.style.display = 'flex';
|
|
} else {
|
|
el.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
/* ── Zeitachse ──────────────────────────────────────────────────────────── */
|
|
function buildTimeAxis() {
|
|
const ax = document.getElementById('calTimeAxis');
|
|
ax.innerHTML = TIME_SLOTS.map(t => {
|
|
const h = t.endsWith(':00');
|
|
return `<div class="cal-time-label ${h ? 'hour' : ''}">${h ? t : ''}</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
/* ── Spalten rendern ────────────────────────────────────────────────────── */
|
|
function renderColumns() {
|
|
const visible = ALL_DOCTORS.filter(d => visibleDocs.has(d.id));
|
|
const headers = document.getElementById('calColHeadersInner');
|
|
const cols = document.getElementById('calColumnsInner');
|
|
const iso = toISO(currentDate);
|
|
const isWEnd = [0, 6].includes(currentDate.getDay());
|
|
|
|
const countMap = {};
|
|
for (const a of appointments)
|
|
countMap[a.doctor_id] = (countMap[a.doctor_id] || 0) + 1;
|
|
|
|
if (!visible.length) {
|
|
headers.innerHTML = '';
|
|
cols.innerHTML = `
|
|
<div class="d-flex flex-column align-items-center justify-content-center w-100 text-muted py-5">
|
|
<i class="bi bi-person-x fs-1 mb-2"></i>
|
|
<div>Keine Ärzte ausgewählt</div>
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
headers.innerHTML = visible.map(d => `
|
|
<div class="col-header">
|
|
<span class="doc-dot" style="background:${d.color}"></span>
|
|
<div>
|
|
<div class="col-header-name">${esc(d.name)}</div>
|
|
</div>
|
|
<span class="col-header-count">${countMap[d.id] || 0}</span>
|
|
<input type="color" class="col-header-color ms-1" value="${d.color}"
|
|
title="Farbe ändern" data-doc="${d.id}">
|
|
</div>
|
|
`).join('');
|
|
|
|
cols.innerHTML = visible.map(d => `
|
|
<div class="doc-col" id="docCol-${d.id}" data-doc="${d.id}">
|
|
${TIME_SLOTS.map(t => `
|
|
<div class="slot-row ${t.endsWith(':00') ? 'hour-start' : ''} ${isWEnd ? 'weekend' : ''}"
|
|
data-time="${t}" data-doc="${d.id}"></div>
|
|
`).join('')}
|
|
</div>
|
|
`).join('');
|
|
|
|
/* Termin-Blöcke */
|
|
const byDoc = {};
|
|
for (const a of appointments) {
|
|
if (!byDoc[a.doctor_id]) byDoc[a.doctor_id] = [];
|
|
byDoc[a.doctor_id].push(a);
|
|
}
|
|
for (const d of visible) {
|
|
const col = document.getElementById(`docCol-${d.id}`);
|
|
if (col) (byDoc[d.id] || []).forEach(a => renderApptBlock(col, a, d.color));
|
|
}
|
|
|
|
updateNowLine();
|
|
|
|
/* Slot-Klick */
|
|
cols.querySelectorAll('.slot-row').forEach(slot =>
|
|
slot.addEventListener('click', () =>
|
|
openApptModal(null, slot.dataset.doc, iso, slot.dataset.time))
|
|
);
|
|
|
|
/* Farb-Picker */
|
|
headers.querySelectorAll('.col-header-color').forEach(inp => {
|
|
inp.addEventListener('change', async () => {
|
|
const docId = parseInt(inp.dataset.doc);
|
|
const color = inp.value;
|
|
await apiFetch(`/doctors/${docId}/color`, { method: 'PATCH', body: { color } });
|
|
const doc = ALL_DOCTORS.find(d => d.id === docId);
|
|
if (doc) doc.color = color;
|
|
renderDocList();
|
|
renderColumns();
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderApptBlock(col, a, color) {
|
|
const idx = TIME_SLOTS.indexOf(a.time);
|
|
if (idx < 0) return;
|
|
const slots = Math.max(1, Math.round(a.duration / 15));
|
|
const block = document.createElement('div');
|
|
block.className = `appt-block status-${a.status}`;
|
|
block.style.cssText =
|
|
`top:${idx * 40 + 2}px; height:${slots * 40 - 4}px; background:${color}28; border-color:${color};`;
|
|
block.innerHTML = `
|
|
<div class="appt-patient">${esc(a.patient_name)}</div>
|
|
${slots > 1 ? `<div class="appt-time">${a.time} · ${a.duration} min</div>` : ''}
|
|
`;
|
|
block.addEventListener('click', e => { e.stopPropagation(); openApptModal(a); });
|
|
col.appendChild(block);
|
|
}
|
|
|
|
function updateNowLine() {
|
|
document.querySelectorAll('.now-line').forEach(n => n.remove());
|
|
if (toISO(new Date()) !== toISO(currentDate)) return;
|
|
const mins = new Date().getHours() * 60 + new Date().getMinutes();
|
|
const top = (mins / 15) * 40;
|
|
document.querySelectorAll('.doc-col').forEach(col => {
|
|
const line = document.createElement('div');
|
|
line.className = 'now-line';
|
|
line.style.top = `${top}px`;
|
|
line.innerHTML = '<div class="now-dot"></div>';
|
|
col.appendChild(line);
|
|
});
|
|
}
|
|
setInterval(updateNowLine, 30000);
|
|
|
|
/* ── Arztliste (Sidebar) ────────────────────────────────────────────────── */
|
|
function renderDocList() {
|
|
const el = document.getElementById('docList');
|
|
el.innerHTML = ALL_DOCTORS.map(d => `
|
|
<div class="doc-item ${visibleDocs.has(d.id) ? 'active' : ''}" data-id="${d.id}">
|
|
<span class="doc-dot" style="background:${d.color}"></span>
|
|
<span style="font-size:13px; flex:1;">${esc(d.name)}</span>
|
|
<span class="doc-check">
|
|
${visibleDocs.has(d.id)
|
|
? '<i class="bi bi-check text-white" style="font-size:11px;"></i>'
|
|
: ''}
|
|
</span>
|
|
</div>
|
|
`).join('');
|
|
|
|
el.querySelectorAll('.doc-item').forEach(item => {
|
|
item.addEventListener('click', () => {
|
|
const id = parseInt(item.dataset.id);
|
|
visibleDocs.has(id) ? visibleDocs.delete(id) : visibleDocs.add(id);
|
|
renderDocList();
|
|
renderColumns();
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ── Mini-Kalender ──────────────────────────────────────────────────────── */
|
|
let miniYear = new Date().getFullYear();
|
|
let miniMonth = new Date().getMonth();
|
|
|
|
async function renderMiniCal(yr, mo) {
|
|
if (yr !== undefined) { miniYear = yr; miniMonth = mo; }
|
|
await ensureHolidays(miniYear);
|
|
|
|
const first = new Date(miniYear, miniMonth, 1);
|
|
const last = new Date(miniYear, miniMonth + 1, 0);
|
|
const startWd = (first.getDay() + 6) % 7;
|
|
|
|
let html = `
|
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
|
<button class="btn btn-sm btn-link p-0 text-muted" id="miniPrev">
|
|
<i class="bi bi-chevron-left"></i>
|
|
</button>
|
|
<small class="fw-semibold">${MONTHS[miniMonth].substring(0,3)} ${miniYear}</small>
|
|
<button class="btn btn-sm btn-link p-0 text-muted" id="miniNext">
|
|
<i class="bi bi-chevron-right"></i>
|
|
</button>
|
|
</div>
|
|
<div class="mini-cal-grid">
|
|
${['Mo','Di','Mi','Do','Fr','Sa','So'].map(w => `<div class="mini-wd">${w}</div>`).join('')}
|
|
`;
|
|
|
|
for (let i = 0; i < startWd; i++) html += '<div></div>';
|
|
|
|
for (let day = 1; day <= last.getDate(); day++) {
|
|
const d2 = new Date(miniYear, miniMonth, day);
|
|
const iso = toISO(d2);
|
|
const tod = toISO(new Date()) === iso;
|
|
const sel = toISO(currentDate) === iso;
|
|
const hol = !!(holidays[miniYear]?.[iso]);
|
|
html += `<div class="mini-day ${tod?'today':''} ${sel?'selected':''} ${hol?'holiday':''}"
|
|
data-iso="${iso}">${day}</div>`;
|
|
}
|
|
html += '</div>';
|
|
|
|
const mc = document.getElementById('miniCal');
|
|
mc.innerHTML = html;
|
|
|
|
mc.querySelector('#miniPrev').addEventListener('click', () => {
|
|
let m = miniMonth - 1, y = miniYear;
|
|
if (m < 0) { m = 11; y--; }
|
|
renderMiniCal(y, m);
|
|
});
|
|
mc.querySelector('#miniNext').addEventListener('click', () => {
|
|
let m = miniMonth + 1, y = miniYear;
|
|
if (m > 11) { m = 0; y++; }
|
|
renderMiniCal(y, m);
|
|
});
|
|
mc.querySelectorAll('.mini-day[data-iso]').forEach(el => {
|
|
el.addEventListener('click', () => {
|
|
const [y, m, d] = el.dataset.iso.split('-').map(Number);
|
|
currentDate = new Date(y, m - 1, d);
|
|
loadDay();
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ── Patienten-Autocomplete ─────────────────────────────────────────────── */
|
|
let acTimer = null;
|
|
|
|
function initPatientAutocomplete() {
|
|
const input = document.getElementById('fPatient');
|
|
const dropdown = document.getElementById('patientDropdown');
|
|
const hiddenId = document.getElementById('fPatientId');
|
|
|
|
function hideDropdown() {
|
|
dropdown.style.display = 'none';
|
|
dropdown.innerHTML = '';
|
|
}
|
|
|
|
function selectPatient(p) {
|
|
input.value = `${p.firstname} ${p.lastname}`;
|
|
hiddenId.value = p.id;
|
|
hideDropdown();
|
|
}
|
|
|
|
input.addEventListener('input', () => {
|
|
clearTimeout(acTimer);
|
|
hiddenId.value = ''; // Freitext → ID zurücksetzen
|
|
const q = input.value.trim();
|
|
|
|
if (q.length < 1) { hideDropdown(); return; }
|
|
|
|
acTimer = setTimeout(async () => {
|
|
try {
|
|
const results = await apiFetch(
|
|
`/patients/search?q=${encodeURIComponent(q)}`
|
|
);
|
|
|
|
if (!results.length) { hideDropdown(); return; }
|
|
|
|
dropdown.innerHTML = results.map(p => {
|
|
const bd = p.birthdate
|
|
? new Date(p.birthdate).toLocaleDateString('de-DE')
|
|
: '';
|
|
return `
|
|
<div class="ac-item d-flex align-items-center gap-2 px-3 py-2"
|
|
style="cursor:pointer; font-size:13px; border-bottom:1px solid #f0f0f0;"
|
|
data-id="${p.id}"
|
|
data-name="${esc(p.firstname)} ${esc(p.lastname)}">
|
|
<i class="bi bi-person text-muted"></i>
|
|
<div>
|
|
<div class="fw-semibold">${esc(p.firstname)} ${esc(p.lastname)}</div>
|
|
${bd ? `<div class="text-muted" style="font-size:11px;">*${bd}</div>` : ''}
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
dropdown.style.display = 'block';
|
|
|
|
dropdown.querySelectorAll('.ac-item').forEach(item => {
|
|
// Hover-Effekt
|
|
item.addEventListener('mouseenter', () =>
|
|
item.style.background = '#f0f5ff'
|
|
);
|
|
item.addEventListener('mouseleave', () =>
|
|
item.style.background = ''
|
|
);
|
|
// Auswahl
|
|
item.addEventListener('mousedown', e => {
|
|
e.preventDefault(); // verhindert blur vor click
|
|
selectPatient({
|
|
id: parseInt(item.dataset.id),
|
|
firstname: item.dataset.name.split(' ')[0],
|
|
lastname: item.dataset.name.split(' ').slice(1).join(' '),
|
|
});
|
|
});
|
|
});
|
|
} catch { hideDropdown(); }
|
|
}, 220);
|
|
});
|
|
|
|
// Dropdown schließen wenn Fokus woanders hin geht
|
|
input.addEventListener('blur', () => {
|
|
setTimeout(hideDropdown, 200);
|
|
});
|
|
|
|
// Modal schließt → Dropdown aufräumen
|
|
document.getElementById('apptModal').addEventListener('hidden.bs.modal', hideDropdown);
|
|
}
|
|
|
|
/* ── Termin-Modal ───────────────────────────────────────────────────────── */
|
|
function populateTimeSelect() {
|
|
const sel = document.getElementById('fTime');
|
|
sel.innerHTML = TIME_SLOTS.map(t =>
|
|
`<option value="${t}">${t}</option>`
|
|
).join('');
|
|
}
|
|
|
|
function populateDoctorSelect() {
|
|
const sel = document.getElementById('fDoctor');
|
|
sel.innerHTML = ALL_DOCTORS.map(d =>
|
|
`<option value="${d.id}">${esc(d.name)}</option>`
|
|
).join('');
|
|
}
|
|
|
|
function openApptModal(appt, docId, date, time) {
|
|
editingId = appt?.id ?? null;
|
|
document.getElementById('apptModalTitle').textContent =
|
|
appt ? 'Termin bearbeiten' : 'Neuer Termin';
|
|
document.getElementById('btnApptDelete').style.display = appt ? '' : 'none';
|
|
|
|
populateTimeSelect();
|
|
populateDoctorSelect();
|
|
|
|
document.getElementById('fDate').value = appt?.date ?? (date || toISO(currentDate));
|
|
document.getElementById('fTime').value = appt?.time ?? (time || '08:00');
|
|
document.getElementById('fDoctor').value = appt?.doctor_id ?? (docId || ALL_DOCTORS[0]?.id || '');
|
|
document.getElementById('fPatient').value = appt?.patient_name ?? '';
|
|
document.getElementById('fPatientId').value = ''; // ← immer zurücksetzen
|
|
document.getElementById('fDuration').value = appt?.duration ?? 15;
|
|
document.getElementById('fStatus').value = appt?.status ?? 'scheduled';
|
|
document.getElementById('fNotes').value = appt?.notes ?? '';
|
|
|
|
bootstrap.Modal.getOrCreateInstance(
|
|
document.getElementById('apptModal')
|
|
).show();
|
|
setTimeout(() => document.getElementById('fPatient').focus(), 300);
|
|
}
|
|
|
|
async function saveAppt() {
|
|
const payload = {
|
|
doctor_id: parseInt(document.getElementById('fDoctor').value),
|
|
date: document.getElementById('fDate').value,
|
|
time: document.getElementById('fTime').value,
|
|
duration: parseInt(document.getElementById('fDuration').value),
|
|
patient_name: document.getElementById('fPatient').value.trim(),
|
|
notes: document.getElementById('fNotes').value.trim(),
|
|
status: document.getElementById('fStatus').value,
|
|
};
|
|
if (!payload.patient_name) { showToast('Patientenname fehlt', true); return; }
|
|
|
|
try {
|
|
if (editingId) {
|
|
await apiFetch(`/appointments/${editingId}`, { method: 'PUT', body: payload });
|
|
showToast('Termin gespeichert');
|
|
} else {
|
|
await apiFetch('/appointments', { method: 'POST', body: payload });
|
|
showToast('Termin erstellt');
|
|
}
|
|
bootstrap.Modal.getInstance(document.getElementById('apptModal')).hide();
|
|
await loadDay();
|
|
} catch (e) { showToast(e.message, true); }
|
|
}
|
|
|
|
async function deleteAppt() {
|
|
if (!confirm('Termin wirklich löschen?')) return;
|
|
try {
|
|
await apiFetch(`/appointments/${editingId}`, { method: 'DELETE' });
|
|
showToast('Termin gelöscht');
|
|
bootstrap.Modal.getInstance(document.getElementById('apptModal')).hide();
|
|
await loadDay();
|
|
} catch (e) { showToast(e.message, true); }
|
|
}
|
|
|
|
/* ── Events ─────────────────────────────────────────────────────────────── */
|
|
function setupEvents() {
|
|
document.getElementById('btnPrev').addEventListener('click', () => {
|
|
currentDate = addDays(currentDate, -1); loadDay();
|
|
});
|
|
document.getElementById('btnNext').addEventListener('click', () => {
|
|
currentDate = addDays(currentDate, 1); loadDay();
|
|
});
|
|
document.getElementById('btnToday').addEventListener('click', () => {
|
|
currentDate = new Date(); loadDay();
|
|
});
|
|
document.getElementById('btnNewAppt').addEventListener('click', () =>
|
|
openApptModal(null)
|
|
);
|
|
document.getElementById('btnApptSave').addEventListener('click', saveAppt);
|
|
document.getElementById('btnApptDelete').addEventListener('click', deleteAppt);
|
|
|
|
document.addEventListener('keydown', e => {
|
|
if (document.querySelector('.modal.show')) return;
|
|
if (e.key === 'ArrowLeft') { currentDate = addDays(currentDate, -1); loadDay(); }
|
|
if (e.key === 'ArrowRight') { currentDate = addDays(currentDate, 1); loadDay(); }
|
|
if (e.key === 't') { currentDate = new Date(); loadDay(); }
|
|
});
|
|
}
|
|
|
|
function esc(s) {
|
|
return String(s || '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
}
|
|
|
|
/* ── Start ──────────────────────────────────────────────────────────────── */
|
|
buildTimeAxis();
|
|
renderDocList();
|
|
setupEvents();
|
|
initPatientAutocomplete();
|
|
loadDay()
|
|
.then(() => {
|
|
// Scroll zu 07:00 (Slot 28)
|
|
document.getElementById('calScroll').scrollTop = 28 * 40 - 60;
|
|
})
|
|
.catch(err => {
|
|
console.error(err);
|
|
showToast('Verbindung zum Server fehlgeschlagen', true);
|
|
});
|
|
|
|
})();
|