Praxissofttware/public/js/calendar.js

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/* ── 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);
});
})();