Compare commits

...

11 Commits

Author SHA1 Message Date
clemens fd320ba0c6 Praxis Webseite Update 2026-04-02 01:30:14 +02:00
clemens aae89a45a8 Abwesenheitskalender erweitert 2026-04-01 23:59:28 +02:00
clemens 6360af272a Impfverwaltung anpassen 2026-03-30 21:52:10 +02:00
clemens 091702c2a2 Menü und Hilfe 2026-03-30 21:24:55 +02:00
clemens c9b0026f52 Menüanpassung und Sonderzeichen 2026-03-30 21:04:39 +02:00
clemens bb422005d0 PDF Ausgabe erweitert 2026-03-30 20:54:41 +02:00
clemens 098c2d4275 Betriebsurlaub 2026-03-30 20:48:55 +02:00
clemens 7388b5b379 Schließung aller offnen Fehler 2026-03-30 20:46:08 +02:00
clemens 016753293c Merge branch 'main' of https://git.ctb-it.de/clemens/praxis-creutzburg-web 2026-03-30 20:37:17 +02:00
clemens 874e8a04c0 Zeiterfassung Mail anpassung 2026-03-30 20:35:13 +02:00
clemens 0084516414 zeiterfassung 2026-03-30 20:34:27 +02:00
35 changed files with 3162 additions and 721 deletions
+18
View File
@@ -0,0 +1,18 @@
{
"sqltools.connections": [
{
"mysqlOptions": {
"authProtocol": "default",
"enableSsl": "Disabled"
},
"ssh": "Disabled",
"previewLimit": 50,
"server": "mysql2fda.netcup.net",
"port": 3306,
"driver": "MySQL",
"name": "Praxis Creutzburg",
"database": "k25330_pracreutz",
"username": "k25330_pracreutz"
}
]
}
+24
View File
@@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS time_error_notification_state (
employee_id INT NOT NULL,
cycle_started_on DATE NOT NULL,
first_error_date DATE NOT NULL,
last_notification_stage VARCHAR(50) DEFAULT NULL,
last_notification_sent_at DATETIME DEFAULT NULL,
employee_day_1_sent_at DATETIME DEFAULT NULL,
employee_day_3_sent_at DATETIME DEFAULT NULL,
admin_day_7_sent_at DATETIME DEFAULT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (employee_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS time_error_notifications (
id INT NOT NULL AUTO_INCREMENT,
employee_id INT NOT NULL,
cycle_started_on DATE NOT NULL,
notification_stage VARCHAR(50) NOT NULL,
recipient_email VARCHAR(255) NOT NULL,
sent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uniq_time_error_notification (employee_id, cycle_started_on, notification_stage, recipient_email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+13 -2
View File
@@ -43,13 +43,24 @@ include("templates/footer.inc.php");
<?php
$aktion = $_POST["aktion"] ?? $_GET["aktion"] ?? '';
$artRequest = $_POST["art"] ?? $_GET["art"] ?? "1";
if ($aktion !== '') {
$_POST["aktion"] = $aktion;
}
if ($artRequest !== '') {
$_POST["art"] = $artRequest;
}
if(!check_worker()){
echo "<div class='container main-container'><h3>Erst anmelden: <a href=login.php>Login</a></h3><br>";
echo $_SESSION['userid'];
}else{
if (($_POST["aktion"] ?? '') == "1") {
if ($aktion == "1") {
echo "<header><h2>Anfragen bearbeiten</h2></header>";
@@ -95,7 +106,7 @@ if(!check_worker()){
$art = $_POST["art"] ?? "1";
$art = $artRequest;
// Default
$sql = "
+47 -27
View File
@@ -120,22 +120,12 @@ function workflowLoadWaitRowsForPlan(PDO $pdo, int $impfstoffId, int $planId): a
FROM warteliste w
WHERE w.checked = 1
AND (w.impfstoff = :iid OR w.impfstoff = 0)
AND (
EXISTS (
AND EXISTS (
SELECT 1
FROM warteliste_zeitraum wz
WHERE wz.warteid = w.warteid
AND wz.zeitraum_id = :zid
)
OR (
NOT EXISTS (
SELECT 1
FROM warteliste_zeitraum wz_none
WHERE wz_none.warteid = w.warteid
)
AND (w.zeitraum_id = :zid OR w.zeitraum_id IS NULL)
)
)
ORDER BY w.date_created ASC, w.warteid ASC");
$stW->execute([
'iid' => $impfstoffId,
@@ -145,6 +135,26 @@ function workflowLoadWaitRowsForPlan(PDO $pdo, int $impfstoffId, int $planId): a
return $stW->fetchAll(PDO::FETCH_ASSOC);
}
function workflowCountStrictWaitersForPlan(PDO $pdo, int $impfstoffId, int $planId): int
{
$stW = $pdo->prepare("SELECT COUNT(DISTINCT w.userid)
FROM warteliste w
WHERE w.checked = 1
AND (w.impfstoff = :iid OR w.impfstoff = 0)
AND EXISTS (
SELECT 1
FROM warteliste_zeitraum wz
WHERE wz.warteid = w.warteid
AND wz.zeitraum_id = :zid
)");
$stW->execute([
'iid' => $impfstoffId,
'zid' => $planId,
]);
return (int)$stW->fetchColumn();
}
function workflowAddWartelisteEntry(
PDO $pdo,
int $personId,
@@ -665,6 +675,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$rules = [];
$plans = [];
$configuredImpfstoffe = [];
$configuredImpfstoffNames = [];
$eligible = [];
$personResults = [];
$waitRows = [];
@@ -700,6 +711,7 @@ try {
foreach ($rules as $r) {
$iid = (int)$r['impfstoff_id'];
$configuredImpfstoffNames[$iid] = (string)$r['impfname'];
if (!isset($planExistsForImpfstoff[$iid])) {
continue;
}
@@ -721,7 +733,7 @@ try {
}
$planId = (int)$plan['zeitraum_id'];
$planWaitCounts[$iid][$planId] = workflowCountWaitersForPlan($pdo, $iid, $planId);
$planWaitCounts[$iid][$planId] = workflowCountStrictWaitersForPlan($pdo, $iid, $planId);
if ($planWaitCounts[$iid][$planId] >= $dosen) {
$hasEligiblePlan = true;
}
@@ -893,7 +905,7 @@ try {
<?php if (!empty($w['zeitraum_labels'])): ?>
<?php echo implode('<br>', array_map('esc', $w['zeitraum_labels'])); ?>
<?php else: ?>
<?php echo esc((string)$w['impfenzeitraum']); ?>
<?php echo esc(impfLimitLabelLength((string)$w['impfenzeitraum'], 50)); ?>
<?php endif; ?>
</td>
<td>
@@ -953,10 +965,11 @@ try {
<?php endif; ?>
<?php endif; ?>
<form method="post" class="form-inline">
<form method="post">
<input type="hidden" name="aktion" value="add_waitlist_existing">
<input type="hidden" name="person_search" value="<?php echo esc($personSearch); ?>">
<div class="form-group" style="display:block; margin-bottom:10px;">
<label>Patient</label>
<select class="form-control" name="wl_person_id" required <?php echo empty($personResults) ? 'disabled' : ''; ?>>
<option value="">Bitte wählen</option>
@@ -969,8 +982,10 @@ try {
<option value="<?php echo $pid; ?>" <?php echo $selected; ?>><?php echo esc($personLabel); ?></option>
<?php endforeach; ?>
</select>
</div>
<label style="margin-left:10px;">Impfstoff</label>
<div class="form-group" style="display:block; margin-bottom:10px;">
<label>Impfstoff</label>
<select class="form-control" name="wl_impfstoff_id" id="existing_impfstoff">
<option value="0" <?php echo ((int)($_POST['wl_impfstoff_id'] ?? 0) === 0) ? 'selected' : ''; ?>>Keine Vorgabe</option>
<?php foreach ($configuredImpfstoffe as $r): ?>
@@ -983,8 +998,10 @@ try {
</option>
<?php endforeach; ?>
</select>
</div>
<label style="margin-left:10px;">Zeitfenster</label>
<div class="form-group" style="display:block;">
<label>Zeitfenster</label>
<?php $selectedExistingPlanIds = impfNormalizeZeitraumIds($_POST['wl_plan_ids'] ?? ($_POST['wl_plan_id'] ?? [])); ?>
<select class="form-control" name="wl_plan_ids[]" id="existing_plan" multiple size="6">
<?php foreach ($plans as $p): ?>
@@ -992,13 +1009,14 @@ try {
$planId = (int)$p['zeitraum_id'];
$selected = in_array($planId, $selectedExistingPlanIds, true) ? 'selected' : '';
$impfstoffeCsv = implode(',', $p['impfstoff_id_list']);
$impfstoffeText = empty($p['impfstoff_name_list']) ? 'ohne Impfstoff' : implode(', ', $p['impfstoff_name_list']);
$optionLabel = workflowPlanLabel($p);
?>
<option value="<?php echo $planId; ?>" data-impfstoffe="<?php echo esc($impfstoffeCsv); ?>" <?php echo $selected; ?>>
<?php echo esc(workflowPlanLabel($p) . ' | Impfstoffe: ' . $impfstoffeText); ?>
<?php echo esc($optionLabel); ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="workflow-action-row">
<button class="btn btn-primary" type="submit" <?php echo empty($personResults) ? 'disabled' : ''; ?>>
Zur Warteliste hinzufügen
@@ -1083,10 +1101,10 @@ try {
$planId = (int)$p['zeitraum_id'];
$selected = in_array($planId, $selectedNewPlanIds, true) ? 'selected' : '';
$impfstoffeCsv = implode(',', $p['impfstoff_id_list']);
$impfstoffeText = empty($p['impfstoff_name_list']) ? 'ohne Impfstoff' : implode(', ', $p['impfstoff_name_list']);
$optionLabel = workflowPlanLabel($p);
?>
<option value="<?php echo $planId; ?>" data-impfstoffe="<?php echo esc($impfstoffeCsv); ?>" <?php echo $selected; ?>>
<?php echo esc(workflowPlanLabel($p) . ' | Impfstoffe: ' . $impfstoffeText); ?>
<?php echo esc($optionLabel); ?>
</option>
<?php endforeach; ?>
</select>
@@ -1114,7 +1132,7 @@ try {
<div class="panel panel-default">
<div class="panel-heading"><strong>Konkretes Impfevent erstellen</strong></div>
<div class="panel-body">
<p>Es werden nur Impfstoffe angeboten, bei denen die bestätigte Warteliste mindestens eine Flasche füllt.</p>
<p>Es werden nur Impfstoffe und Zeiträume angeboten, bei denen die bestätigte Warteliste mindestens eine Flasche füllt und die Wartenden diesen Zeitraum auch ausdrücklich gewählt haben.</p>
<form method="post" class="form-inline" style="margin-bottom:14px;">
<input type="hidden" name="aktion" value="create_event">
@@ -1130,14 +1148,18 @@ try {
<select class="form-control" name="plan_id" id="event_plan" required>
<option value="">Bitte wählen</option>
<?php foreach ($plans as $p): ?>
<?php foreach (($p['impfstoff_id_list'] ?? []) as $planImpfstoffId): ?>
<?php
$impfstoffeCsv = implode(',', $p['impfstoff_id_list']);
$impfstoffeText = empty($p['impfstoff_name_list']) ? 'ohne Impfstoff' : implode(', ', $p['impfstoff_name_list']);
$waiterCount = (int)($planWaitCounts[(int)$planImpfstoffId][(int)$p['zeitraum_id']] ?? 0);
if ($waiterCount <= 0) {
continue;
}
?>
<option value="<?php echo (int)$p['zeitraum_id']; ?>" data-impfstoffe="<?php echo esc($impfstoffeCsv); ?>">
<?php echo esc(workflowPlanLabel($p) . ' | Impfstoffe: ' . $impfstoffeText); ?>
<option value="<?php echo (int)$p['zeitraum_id']; ?>" data-impfstoffe="<?php echo esc((string)$planImpfstoffId); ?>">
<?php echo esc(workflowPlanLabel($p) . ' | Impfstoff: ' . ($configuredImpfstoffNames[(int)$planImpfstoffId] ?? 'Unbekannt') . ' | Wartende: ' . $waiterCount); ?>
</option>
<?php endforeach; ?>
<?php endforeach; ?>
</select>
<label style="margin-left:10px;">Konkretes Datum</label>
@@ -1287,5 +1309,3 @@ try {
</div>
<?php include __DIR__ . "/templates/footer.inc.php"; ?>
+6 -1
View File
@@ -289,6 +289,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($aktion === 'add_zeitraum') {
$bezeichnung = trim((string)($_POST['bezeichnung'] ?? ''));
if (function_exists('mb_substr')) {
$bezeichnung = mb_substr($bezeichnung, 0, 50, 'UTF-8');
} else {
$bezeichnung = substr($bezeichnung, 0, 50);
}
$wochentag = (int)($_POST['wochentag'] ?? 0);
$start = trim((string)($_POST['start'] ?? ''));
$ende = trim((string)($_POST['ende'] ?? ''));
@@ -608,7 +613,7 @@ $rules = $stRules->fetchAll(PDO::FETCH_ASSOC);
<form method="post" class="form-inline" style="margin-bottom:12px;">
<input type="hidden" name="aktion" value="add_zeitraum">
<label>Bezeichnung</label>
<input class="form-control" type="text" name="bezeichnung" placeholder="z. B. Mittwoch Vormittag">
<input class="form-control" type="text" name="bezeichnung" maxlength="50" placeholder="z. B. Mittwoch Vormittag">
<label style="margin-left:10px;">Wochentag</label>
<select class="form-control" name="wochentag" required>
<option value="1">Montag</option>
@@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS time_error_notification_state (
employee_id INT NOT NULL,
cycle_started_on DATE NOT NULL,
first_error_date DATE NOT NULL,
last_notification_stage VARCHAR(50) DEFAULT NULL,
last_notification_sent_at DATETIME DEFAULT NULL,
employee_day_1_sent_at DATETIME DEFAULT NULL,
employee_day_3_sent_at DATETIME DEFAULT NULL,
admin_day_7_sent_at DATETIME DEFAULT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (employee_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS time_error_notifications (
id INT NOT NULL AUTO_INCREMENT,
employee_id INT NOT NULL,
cycle_started_on DATE NOT NULL,
notification_stage VARCHAR(50) NOT NULL,
recipient_email VARCHAR(255) NOT NULL,
sent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uniq_time_error_notification (employee_id, cycle_started_on, notification_stage, recipient_email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+21 -6
View File
@@ -63,7 +63,21 @@
<?php else: ?>
<div id="navbar" class="navbar-collapse collapse">
<ul class="nav navbar-nav navbar-right">
<li><a href="anfragen.php">Anfragen</a></li>
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Anfragen <span class="caret"></span></a>
<ul class="dropdown-menu">
<li><a href="anfragen.php">Übersicht Anfragen</a></li>
<li><a href="anfragen.php?aktion=1">Anfragen bearbeiten</a></li>
<li><a href="anfragen.php?aktion=12">Formular-Auswertung</a></li>
<li><a href="anfragen.php?aktion=18">Mailvorlagen anlegen</a></li>
<li><a href="anfragen.php?aktion=16">Mailvorlagen anpassen</a></li>
<li class="divider"></li>
<li><a href="anfragen.php?aktion=4">Urlaub eintragen</a></li>
<li><a href="anfragen.php?aktion=6">Notfallsprechstunde eintragen</a></li>
<li class="divider"></li>
<li><a href="http://ts03.fritz.box:8080/" target="_blank">Anrufbeantworter</a></li>
</ul>
</li>
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Impfungen <span class="caret"></span></a>
<ul class="dropdown-menu">
@@ -74,17 +88,18 @@
<li><a href="impfworkflow_stammdaten.php">Stammdaten</a></li>
</ul>
</li>
<!--<li><a href="togoadmin.php">togo-Impfung</a></li>-->
<li><a href="http://ts03.fritz.box:8080/" target="_blank">Anrufbeantworter</a></li>
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Verwaltung <span class="caret"></span></a>
<ul class="dropdown-menu">
<li><a href="../zeiterfassung">Zeiterfassung</a></li>
<li><a href="webseitenadmin.php">Webseiteninhalt ändern</a></li>
<li><a href="settings.php">Einstellungen</a></li>
</ul>
</li>
<li><a href="zeiterfassung_hilfe.php">Hilfe</a></li>
<li><a href="logout.php">Logout</a></li>
</ul>
</div><!--/.navbar-collapse -->
<?php endif; ?>
</div>
</nav>
+16 -3
View File
@@ -58,17 +58,23 @@ if (!check_worker()) {
$inhaltid = (int)($_POST["inhaltid"] ?? 0);
$inhalt = $_POST["inhalt"] ?? "";
$webseitentitel = $_POST["webseitentitel"] ?? "";
$beschreibung = $_POST["beschreibung"] ?? "";
$url = $_POST["url"] ?? "";
try {
$stmt = $pdo->prepare("
UPDATE webseiteninhalt
SET inhalt = :inhalt,
webseitentitel = :webseitentitel
webseitentitel = :webseitentitel,
beschreibung = :beschreibung,
url = :url
WHERE inhaltid = :inhaltid
");
$stmt->execute([
':inhalt' => $inhalt,
':webseitentitel' => $webseitentitel,
':beschreibung' => $beschreibung,
':url' => $url,
':inhaltid' => $inhaltid,
]);
@@ -85,7 +91,7 @@ if (!check_worker()) {
try {
$stmt = $pdo->prepare("
SELECT webseitentitel, inhalt
SELECT webseitentitel, inhalt, beschreibung, url
FROM webseiteninhalt
WHERE inhaltid = ?
LIMIT 1
@@ -98,13 +104,20 @@ if (!check_worker()) {
} else {
$webseitentitel = $rowconfig["webseitentitel"] ?? "";
$inhalt = $rowconfig["inhalt"] ?? "";
$beschreibung = $rowconfig["beschreibung"] ?? "";
$url = $rowconfig["url"] ?? "";
echo "<h1>Webseiteninhalt bearbeiten</h1><br>";
echo "<h4>Vorlage: " . htmlspecialchars($webseitentitel, ENT_QUOTES, 'UTF-8') . "</h4>";
echo "<br><br>";
echo "<form action='" . htmlspecialchars($_SERVER['PHP_SELF'], ENT_QUOTES, 'UTF-8') . "' method='POST'>";
echo "<input name='webseitentitel' type='hidden' value='" . htmlspecialchars($webseitentitel, ENT_QUOTES, 'UTF-8') . "'>";
echo "<label>Titel</label><br>";
echo "<input name='webseitentitel' type='text' class='form-control' value='" . htmlspecialchars($webseitentitel, ENT_QUOTES, 'UTF-8') . "'><br><br>";
echo "<label>Beschreibung</label><br>";
echo "<input name='beschreibung' type='text' class='form-control' value='" . htmlspecialchars($beschreibung, ENT_QUOTES, 'UTF-8') . "'><br><br>";
echo "<label>URL / Hinweis</label><br>";
echo "<input name='url' type='text' class='form-control' value='" . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . "'><br><br>";
echo "<div id='my-editor'></div>";
// Inhalt ist HTML -> bewusst NICHT escapen, sonst zerstörst du HTML im Editor
echo "<textarea height='200' name='inhalt' id='trumbowyg-demo'>" . $inhalt . "</textarea>";
+168
View File
@@ -0,0 +1,168 @@
<?php
session_start();
require_once __DIR__ . "/../inc/config.inc.php";
require_once __DIR__ . "/../inc/functions.inc.php";
$user = check_admin_user();
include __DIR__ . "/templates/header.inc.php";
if (!$user) {
echo "<div class='container main-container'><h3>Erst anmelden: <a href='login.php'>Login</a></h3><br>";
include __DIR__ . "/templates/footer.inc.php";
exit;
}
?>
<div class="container main-container">
<div class="page-header">
<h2>FAQ und Anleitung: Admin + Zeiterfassung</h2>
<p>Diese Seite dient als Nachschlagewerk für die tägliche Arbeit in der Administration und in der Zeiterfassung.</p>
</div>
<div class="alert alert-info">
<strong>Kurzüberblick:</strong> Die Administration steuert Anfragen, Inhalte, Einstellungen und Sonderbereiche. Die Zeiterfassung steuert Stempelungen, Korrekturen, Abwesenheiten, Fehlbuchungen, PDF-Ausgaben und Benachrichtigungen.
</div>
<h3>1. Administration</h3>
<h4>Anfragen</h4>
<p>Im Bereich <strong>Anfragen</strong> werden eingehende Formularanfragen bearbeitet, gefiltert und beantwortet. Dort lassen sich offene, letzte oder alle Anfragen anzeigen und in den Bearbeitungsstatus überführen.</p>
<h4>Mailvorlagen</h4>
<p>Mailvorlagen werden genutzt, um wiederkehrende Antworten und Abläufe einheitlich zu versenden. Änderungen an Vorlagen wirken sich auf spätere Nachrichten aus, daher sollten Texte dort zentral gepflegt werden.</p>
<h4>Formular-Auswertung</h4>
<p>Die Formular-Auswertung dient dazu, Anfragen strukturiert auszuwerten. Das ist vor allem hilfreich, wenn Mengen, Anfragearten oder Bearbeitungsstände geprüft werden sollen.</p>
<h4>Impfverwaltung</h4>
<p>Unter <strong>Impfungen</strong> werden Wartelisten, Teilnehmer, Impfevents und Stammdaten verwaltet. Dieser Bereich ist unabhängig von der Zeiterfassung, gehört aber zur Admin-Oberfläche.</p>
<h4>Webseiteninhalte ändern</h4>
<p>Über <strong>Webseiteninhalt ändern</strong> werden Texte und Inhalte der öffentlichen Webseite gepflegt. Änderungen dort betreffen direkt die Darstellung der Praxis-Webseite.</p>
<h4>Einstellungen</h4>
<p>Im Bereich <strong>Einstellungen</strong> werden zentrale Konfigurationen gepflegt, zum Beispiel Benachrichtigungsadressen und technische Grundeinstellungen. Änderungen dort sollten bewusst vorgenommen werden.</p>
<h4>Zeiterfassung aus der Admin-Oberfläche</h4>
<p>Die Zeiterfassung ist aus dem Admin-Menü direkt erreichbar. Dort wechseln Administratoren in den operativen Bereich für Zeiten, Fehlbuchungen, Abwesenheiten und Mitarbeiterverwaltung.</p>
<hr>
<h3>2. Zeiterfassung: Funktionen für Mitarbeiter</h3>
<h4>Startseite / Stempeln</h4>
<p>Auf der Startseite der Zeiterfassung wird gestempelt. Das System setzt automatisch abwechselnd <strong>KOMMEN</strong> und <strong>GEHEN</strong>. Zusätzlich wird dort angezeigt, ob aktuell ein Buchungsproblem vorhanden ist.</p>
<h4>Zeitübersicht</h4>
<p>In der <strong>Zeitübersicht</strong> können die eigenen Buchungen für einen Monat kontrolliert werden. Dort lassen sich auch Tagesansichten aufrufen und bei Bedarf manuell anpassen.</p>
<h4>Fehlbuchungen</h4>
<p>Der Bereich <strong>Fehlbuchungen</strong> zeigt unvollständige oder fehlerhafte KOMMEN/GEHEN-Folgen an. Mitarbeiter sehen dort ihre eigenen problematischen Tage und können diese korrigieren.</p>
<h4>Abwesenheitsantrag</h4>
<p>Über <strong>Abwesenheitsantrag</strong> werden Urlaube und weitere Abwesenheitsgründe eingereicht. Der Antrag wird anschließend über die Genehmigungsfunktionen der Admins geprüft.</p>
<h4>Mein Abwesenheitskalender</h4>
<p>Im eigenen Kalender sind die persönlichen Abwesenheiten sichtbar. So kann jeder Mitarbeiter seine eigenen Anträge und genehmigten Zeiten prüfen.</p>
<h4>Team-Urlaubskalender</h4>
<p>Der Team-Kalender zeigt genehmigte Urlaubseinträge des Teams sowie Betriebsurlaub. Damit lassen sich Überschneidungen und Abwesenheiten leichter erkennen.</p>
<hr>
<h3>3. Zeiterfassung: Funktionen für Admins</h3>
<h4>Alle Zeitbuchungen</h4>
<p>Unter <strong>Alle Zeitbuchungen</strong> kann für jeden Mitarbeiter ein Monat ausgewählt und angezeigt werden. Zusätzlich lassen sich Einzel-PDFs und eine Sammel-PDF für alle Mitarbeiter eines Monats erzeugen.</p>
<h4>PDF-Ausgaben</h4>
<p>Die Einzel-PDF erstellt die Monatsübersicht eines einzelnen Mitarbeiters. Die Sammel-PDF enthält alle Mitarbeiter mit Buchungen im gewählten Monat. Mitarbeiter ohne Buchung im Monat werden dabei nicht ausgegeben.</p>
<h4>Alle Fehlbuchungen</h4>
<p>Unter <strong>Alle Fehlbuchungen</strong> werden fehlerhafte Tage aller Mitarbeiter angezeigt. Von dort aus können Admins einzelne Tage manuell bearbeiten oder automatische Schließungen für einen Mitarbeiter durchführen.</p>
<h4>Fehlbuchungen automatisch schließen</h4>
<p>Admins können für einen Mitarbeiter alle automatisch schließbaren Fehlbuchungen mit einer Stundenanzahl ergänzen. Beispiel: Fehlt das <strong>GEHEN</strong>, wird es um die angegebene Anzahl Stunden nach dem letzten <strong>KOMMEN</strong> eingetragen.</p>
<h4>Mitarbeiterverwaltung</h4>
<p>In der Mitarbeiterverwaltung werden Mitarbeiter angelegt und gepflegt. Dort werden unter anderem E-Mail, Rollen, Zeiterfassungsberechtigung, Admin-Status und Kartenzuordnungen verwaltet.</p>
<h4>Abwesenheitsübersicht</h4>
<p>Die Abwesenheitsübersicht dient zur Kontrolle aller Abwesenheitseinträge. Dort werden pro Mitarbeiter die Urlaubstage für den Anspruch sowie die übrigen Abwesenheitsgründe je Jahr zusammengefasst.</p>
<h4>Abwesenheiten genehmigen</h4>
<p>Im Bereich <strong>Abwesenheiten genehmigen</strong> prüfen Admins eingereichte Abwesenheiten und können diese annehmen oder ablehnen.</p>
<h4>Leitungskalender</h4>
<p>Der Leitungskalender zeigt alle Abwesenheitstermine über alle Personen hinweg. Damit lassen sich Urlaub, Krankheit, Weiterbildung und weitere Gründe zentral koordinieren.</p>
<h4>Betriebsurlaub</h4>
<p>Unter <strong>Betriebsurlaub</strong> werden zentrale Schließzeiten der Praxis gepflegt. Diese Einträge erscheinen im Urlaubskontext und können mit Vertreterinformationen hinterlegt werden.</p>
<h4>Benachrichtigungen bei Zeitfehlern</h4>
<p>Für offene Zeitfehler existiert eine gestaffelte Benachrichtigungslogik. Mitarbeiter werden erinnert, und bei länger offenen Fehlern erfolgt später eine Eskalation an Admins. Die E-Mails enthalten einen Direktlink zur Zeiterfassung.</p>
<hr>
<h3>4. Typische Fragen von Mitarbeitern</h3>
<h4>Ich habe vergessen zu stempeln. Was soll ich tun?</h4>
<p>Bitte in der Zeiterfassung den Bereich <strong>Fehlbuchungen</strong> oder die <strong>Zeitübersicht</strong> öffnen und den betroffenen Tag korrigieren. Falls das nicht möglich ist, bitte einen Admin informieren.</p>
<h4>Warum wird mir ein Buchungsfehler angezeigt?</h4>
<p>Ein Fehler entsteht, wenn die Reihenfolge der Buchungen nicht stimmt, zum Beispiel zwei <strong>KOMMEN</strong> hintereinander oder wenn ein <strong>GEHEN</strong> fehlt.</p>
<h4>Bekomme ich Erinnerungen bei offenen Fehlern?</h4>
<p>Ja. Offene Zeitfehler können Erinnerungen auslösen. Bleiben Fehler bestehen, werden sie nach dem vorgesehenen Ablauf weiter eskaliert.</p>
<h4>Kann ich meine Zeiten selbst ändern?</h4>
<p>Eigene fehlerhafte Tage können in der Zeiterfassung angepasst werden. Größere Korrekturen oder Sammelkorrekturen werden durch Admins vorgenommen.</p>
<h4>Wo sehe ich meinen Urlaub?</h4>
<p>Im Bereich <strong>Mein Abwesenheitskalender</strong>. Dort sind die eigenen Abwesenheitszeiträume sichtbar.</p>
<h4>Wo sehe ich, wann Kollegen im Urlaub sind?</h4>
<p>Im <strong>Team-Urlaubskalender</strong>. Dort werden freigegebene Urlaube und Betriebsurlaub angezeigt.</p>
<h4>Was bedeutet Betriebsurlaub?</h4>
<p>Betriebsurlaub sind zentrale Schließzeiten der Praxis. Diese werden administrativ gepflegt und im Abwesenheitskalender sichtbar gemacht.</p>
<h4>An wen wende ich mich bei falschen Zeiten, wenn ich sie nicht selbst korrigieren kann?</h4>
<p>Dann sollte ein Admin oder Vorgesetzter informiert werden. Admins können einzelne Tage bearbeiten oder automatisch fehlende Ausstempelungen ergänzen.</p>
<hr>
<h3>5. Typische Fragen von Admins</h3>
<h4>Wie finde ich schnell offene Zeitprobleme?</h4>
<p>Über <strong>Alle Fehlbuchungen</strong> in der Zeiterfassung. Dort sind alle problematischen Tage je Mitarbeiter sichtbar.</p>
<h4>Wie schließe ich mehrere ähnliche Fehlbuchungen auf einmal?</h4>
<p>In <strong>Alle Fehlbuchungen</strong> kann für einen Mitarbeiter eine Stundenanzahl eingetragen werden, um automatisch fehlende <strong>GEHEN</strong>-Buchungen zu ergänzen, wenn der letzte Eintrag des Tages ein <strong>KOMMEN</strong> ist.</p>
<h4>Wie erstelle ich Monatsnachweise für mehrere Mitarbeiter?</h4>
<p>Über <strong>Alle Zeitbuchungen</strong> und dann die Sammel-PDF für den gewünschten Monat. So werden alle Mitarbeiter mit Buchungen in einem Dokument zusammengefasst.</p>
<h4>Warum erscheint ein Mitarbeiter nicht in der Sammel-PDF?</h4>
<p>Mitarbeiter ohne Buchung im ausgewählten Monat werden in der Gesamt-PDF nicht aufgenommen.</p>
<h4>Was tun, wenn die PDF für einen Mitarbeiter nicht erzeugt wird?</h4>
<p>Dann liegen im gewählten Monat meist noch offene Zeitfehler vor. Diese müssen zuerst bereinigt werden.</p>
<h4>Wo pflege ich Vertreterdaten beim Betriebsurlaub?</h4>
<p>Im Bereich <strong>Betriebsurlaub</strong>. Dort werden Beschreibung, Vertretung, Telefonnummer, Adresse und URL gepflegt.</p>
<h4>Welche Abwesenheiten zählen auf den Urlaubsanspruch?</h4>
<p>Nur <strong>Urlaub</strong> zählt auf den Urlaubsanspruch. Krankheit, Berufsschule, Weiterbildung, persönliche Gründe und Sonstiges werden separat ausgewertet.</p>
<h4>Wo finde ich den schnellsten Rückweg zwischen Admin und Zeiterfassung?</h4>
<p>Es gibt direkte Menüeinträge zwischen beiden Bereichen. In der Zeiterfassung führt <strong>Zur Admin-Oberfläche</strong> zurück in die Verwaltung.</p>
<div class="alert alert-success" style="margin-top:30px;">
<strong>Hinweis:</strong> Diese Seite ist als lebendes Handbuch gedacht. Wenn neue Funktionen in Admin oder Zeiterfassung hinzukommen, sollte diese Hilfeseite mit aktualisiert werden.
</div>
</div>
<?php include __DIR__ . "/templates/footer.inc.php"; ?>
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg width="720" height="720" viewBox="0 0 720 720" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="720" height="720" rx="36" fill="#F3F4F6"/>
<circle cx="360" cy="235" r="112" fill="#9CA3AF"/>
<path d="M152 608C152 488.706 248.706 392 368 392H352C471.294 392 568 488.706 568 608V624H152V608Z" fill="#9CA3AF"/>
<path d="M228 608C228 530.68 290.68 468 368 468H352C429.32 468 492 530.68 492 608V624H228V608Z" fill="#6B7280"/>
</svg>

After

Width:  |  Height:  |  Size: 452 B

+19 -5
View File
@@ -3,18 +3,32 @@
if (!function_exists('vacationSyncTableExists')) {
function vacationSyncTableExists(PDO $pdo, string $table): bool
{
$stmt = $pdo->prepare("SHOW TABLES LIKE :table_name");
$stmt = $pdo->prepare(
"SELECT COUNT(*)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = :table_name"
);
$stmt->execute(['table_name' => $table]);
return (bool)$stmt->fetchColumn();
return (int)$stmt->fetchColumn() > 0;
}
}
if (!function_exists('vacationSyncTableHasColumn')) {
function vacationSyncTableHasColumn(PDO $pdo, string $table, string $column): bool
{
$stmt = $pdo->prepare("SHOW COLUMNS FROM `" . $table . "` LIKE :column_name");
$stmt->execute(['column_name' => $column]);
return (bool)$stmt->fetch(PDO::FETCH_ASSOC);
$stmt = $pdo->prepare(
"SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = :table_name
AND COLUMN_NAME = :column_name"
);
$stmt->execute([
'table_name' => $table,
'column_name' => $column,
]);
return (int)$stmt->fetchColumn() > 0;
}
}
+21 -6
View File
@@ -87,6 +87,26 @@ if (!function_exists('impfWeekdayName')) {
}
}
if (!function_exists('impfLimitLabelLength')) {
function impfLimitLabelLength(string $text, int $maxLength = 50): string
{
$text = trim($text);
if ($text === '') {
return $text;
}
if (function_exists('mb_strimwidth')) {
return rtrim(mb_strimwidth($text, 0, $maxLength, '...', 'UTF-8'));
}
if (strlen($text) <= $maxLength) {
return $text;
}
return rtrim(substr($text, 0, max(0, $maxLength - 3))) . '...';
}
}
if (!function_exists('impfWorkflowEnsureTables')) {
function impfWorkflowEnsureTables(PDO $pdo): void
{
@@ -336,12 +356,7 @@ if (!function_exists('impfZeitraumLabel')) {
$zeitText .= ' (' . $ortText . ')';
}
$bezeichnung = trim((string)($zeitraum['bezeichnung'] ?? ''));
if ($includeName && $bezeichnung !== '') {
return $bezeichnung . ': ' . $zeitText;
}
return $zeitText;
return impfLimitLabelLength($zeitText, 50);
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
if (!function_exists('websiteContentEnsureEntry')) {
function websiteContentEnsureEntry(PDO $pdo, array $entry): int
{
$title = trim((string)($entry['webseitentitel'] ?? ''));
if ($title === '') {
throw new InvalidArgumentException('webseitentitel is required');
}
$stmt = $pdo->prepare("
SELECT inhaltid
FROM webseiteninhalt
WHERE webseitentitel = :webseitentitel
ORDER BY inhaltid DESC
LIMIT 1
");
$stmt->execute(['webseitentitel' => $title]);
$existingId = (int)($stmt->fetchColumn() ?: 0);
if ($existingId > 0) {
return $existingId;
}
$insert = $pdo->prepare("
INSERT INTO webseiteninhalt (webseitentitel, inhalt, beschreibung, url)
VALUES (:webseitentitel, :inhalt, :beschreibung, :url)
");
$insert->execute([
'webseitentitel' => $title,
'inhalt' => (string)($entry['inhalt'] ?? ''),
'beschreibung' => (string)($entry['beschreibung'] ?? ''),
'url' => (string)($entry['url'] ?? ''),
]);
return (int)$pdo->lastInsertId();
}
}
if (!function_exists('websiteContentEnsureEntries')) {
function websiteContentEnsureEntries(PDO $pdo, array $entries): void
{
foreach ($entries as $entry) {
websiteContentEnsureEntry($pdo, $entry);
}
}
}
if (!function_exists('websiteContentGetByTitle')) {
function websiteContentGetByTitle(PDO $pdo, string $title, string $fallback = ''): array
{
$stmt = $pdo->prepare("
SELECT inhaltid, webseitentitel, inhalt, beschreibung, url
FROM webseiteninhalt
WHERE webseitentitel = :webseitentitel
ORDER BY inhaltid DESC
LIMIT 1
");
$stmt->execute(['webseitentitel' => $title]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row) {
return $row;
}
return [
'inhaltid' => 0,
'webseitentitel' => $title,
'inhalt' => $fallback,
'beschreibung' => '',
'url' => '',
];
}
}
+1
View File
@@ -9,6 +9,7 @@
<li><a href="praxis.php">Die Praxis</a></li>
<li><a href="praxis.php#philosophie">Philosophie</a></li>
<li><a href="praxis.php#team">Das Team</a></li>
<li><a href="pcm.php">PCM</a></li>
<li><a href="sprechzeiten.php#sprechzeiten">Sprechzeiten</a></li>
<li><a href="sprechzeiten.php#anfahrt">Anfahrt</a></li>
+96
View File
@@ -0,0 +1,96 @@
<!DOCTYPE HTML>
<html>
<head>
<?php include('header.php'); ?>
<title>Praxis Creutzburg - PCM</title>
</head>
<body>
<header id="header" class="../skel-layers-fixed">
<?php
include('menu.php');
include_once("inc/config.inc.php");
include_once("inc/functions.inc.php");
include_once('inc/functions.impfen.inc.php');
include_once('inc/website_content.inc.php');
$pcmContent = websiteContentGetByTitle($pdo, 'PCM - Seiteninhalt');
?>
</header>
<section id="main" class="container">
<?php echo showHeaderpraxis(); ?>
<section class="box special features">
<span class="image featured"><img src="images/praxis1.jpg" alt="Praxis Creutzburg"></span>
<style>
.pcm-intro {
max-width: 860px;
margin: 0 auto 1.8rem auto;
text-align: center;
}
.pcm-intro .pcm-kicker {
display: inline-flex;
padding: 0.4rem 0.9rem;
margin-bottom: 0.9rem;
border-radius: 999px;
background: linear-gradient(135deg, rgba(233, 241, 255, 0.95), rgba(245, 248, 252, 0.95));
color: #29415f;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.pcm-intro h2 {
margin-bottom: 0.6rem;
}
.pcm-intro p {
font-size: 1.08rem;
line-height: 1.8;
color: #54626f;
}
.pcm-content {
max-width: 880px;
margin: 0 auto;
text-align: left;
}
.pcm-content h3 {
margin-top: 1.8rem;
color: #30404f;
}
.pcm-content ul {
margin: 0.8rem 0 0;
padding-left: 1.15rem;
line-height: 1.8;
color: #4e5860;
}
.pcm-callout {
margin: 1.3rem 0;
padding: 1rem 1.15rem;
border-left: 4px solid #6b8bb4;
background: #f7f9fc;
border-radius: 16px;
color: #44505d;
}
.pcm-content p {
line-height: 1.8;
color: #4e5860;
}
</style>
<div class="pcm-intro">
<span class="pcm-kicker">Primary Care Management</span>
<h2>PCM - die Qualifikation im Überblick</h2>
<p>PCM stärkt Medizinische Fachangestellte in medizinischen, organisatorischen und kommunikativen Aufgaben rund um die hausärztliche Versorgung. Die ausführliche Einordnung dazu steht im folgenden Inhalt.</p>
</div>
<div class="pcm-content">
<?php if (trim((string)($pcmContent['inhalt'] ?? '')) !== ''): ?>
<?php echo $pcmContent['inhalt']; ?>
<?php else: ?>
<p>Die Inhalte zur Qualifikation PCM werden derzeit vorbereitet.</p>
<?php endif; ?>
</div>
<p><a href="praxis.php#team" class="button alt">Zurück zum Team</a></p>
</section>
</section>
<?php include_once('footer.php'); ?>
</body>
</html>
+220 -8
View File
@@ -79,15 +79,227 @@
</section>
<section class="box special features" id="team">
<style>
.team-intro {
max-width: 920px;
margin: 0 auto 2.2rem auto;
padding: 0;
text-align: center;
font-size: 1.05rem;
}
.team-list {
max-width: 1080px;
margin: 0 auto;
}
.team-profile {
padding: 2rem 0;
border-top: 1px solid rgba(0, 0, 0, 0.12);
}
.team-profile:first-child {
border-top: 0;
padding-top: 0;
}
.team-profile__role {
display: block;
margin-bottom: 0.55rem;
color: #6c5320;
font-size: 0.92rem;
font-weight: 800;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.team-profile__name {
margin: 0 0 0.2rem 0;
font-size: 2.35rem;
line-height: 1.06;
color: #263545;
}
.team-profile__subtitle {
margin: 0 0 1rem 0;
font-size: 1.18rem;
font-weight: 700;
color: #495666;
}
.team-profile__body {
display: grid;
grid-template-columns: 240px 1fr;
gap: 1.6rem;
align-items: center;
}
.team-profile__media img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: 20px;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08);
}
.team-profile__text p:first-child {
margin-top: 0;
}
.team-profile__text p:last-child {
margin-bottom: 0;
}
.team-profile__points {
margin: 1rem 0 0;
padding-left: 1.1rem;
}
.team-profile__points li {
margin-bottom: 0.35rem;
}
@media (max-width: 736px) {
.team-profile {
padding: 1.5rem 0;
}
.team-profile__body {
grid-template-columns: 1fr;
}
.team-profile__name {
font-size: 1.9rem;
}
}
</style>
<h2>Praxis-Team</h2>
<h3>Arzt</h3>
Heiner Creutzburg
<span class="image featured"><img src="images/arztbuero.jpg" alt="" title="">
<div class="team-intro">
<p>Unser Team arbeitet eng zusammen und begleitet Sie vom Empfang bis zur Behandlung mit Ruhe, Struktur und einem freundlichen Miteinander. Neben der ärztlichen Versorgung ist gerade die Organisation im Hintergrund ein wichtiger Teil guter Praxisarbeit.</p>
<p>Auf dieser Seite stellen wir Ihnen die Menschen vor, die den Praxisalltag prägen und für einen verlässlichen Ablauf sorgen.</p>
</div>
<div class="team-list">
<section class="team-profile">
<div class="team-profile__body">
<div class="team-profile__media">
<img src="images/heiner-creutzburg.jpg" alt="Heiner Creutzburg">
</div>
<div class="team-profile__text">
<span class="team-profile__role">Arzt</span>
<h3 class="team-profile__name">Heiner Creutzburg</h3>
<p class="team-profile__subtitle">Facharzt für Innere Medizin</p>
<p>Heiner Creutzburg steht für die hausärztliche und internistische Versorgung in unserer Praxis. Im Mittelpunkt stehen eine sorgfältige medizinische Einschätzung, eine klare Kommunikation und ein ganzheitlicher Blick auf Beschwerden, Verlauf und Therapie.</p>
<ul class="team-profile__points">
<li>Langjährige Erfahrung in der hausärztlichen Versorgung</li>
<li>Interne Medizin und umfassende Diagnostik</li>
<li>Verlässliche Begleitung in akuten und chronischen Fragen</li>
</ul>
</div>
</div>
</section>
<section class="team-profile">
<div class="team-profile__body">
<div class="team-profile__media">
<img src="images/person-silhouette.svg" alt="Dalia Alayan-Ibrahim">
</div>
<div class="team-profile__text">
<span class="team-profile__role">Medizinische Fachangestellte, Primary Care Management B.Sc.</span>
<h3 class="team-profile__name">Dalia Alayan-Ibrahim</h3>
<p class="team-profile__subtitle">Praxismanagerin, PCM B.Sc.</p>
<p>Dalia Alayan-Ibrahim übernimmt in unserer Praxis die Organisation der Abläufe und die Verantwortung für den Personalbereich. Sie sorgt mit dafür, dass Termine, Kommunikation und interne Abläufe gut ineinandergreifen und Patientinnen und Patienten sich jederzeit gut begleitet fühlen.</p>
<p>Mit ihrem abgeschlossenen Studium im Bereich Primary Care Management (Bachelor of Science) bringt sie zusätzlich vertiefte Kompetenzen in Praxisorganisation, koordinierter Versorgung und teamorientiertem Arbeiten mit. Darüber hinaus übernimmt sie alle delegierten Tätigkeiten, führt Patientengespräche und betreut eine eigene Sprechstunde im dafür vorgesehenen Rahmen. Das stärkt die Struktur im Alltag und schafft mehr Raum für eine verlässliche Betreuung.</p>
<ul class="team-profile__points">
<li>Praxisorganisation und Koordination der Abläufe</li>
<li>Personalverantwortung und strukturierte Teamarbeit</li>
<li>Delegierte Tätigkeiten, Patientengespräche und eigene Sprechstunde</li>
<li>Zusätzliche Stärke durch das PCM Studium</li>
</ul>
<p><a href="pcm.php" class="button alt small">Mehr über PCM</a></p>
</div>
</div>
</section>
<section class="team-profile">
<div class="team-profile__body">
<div class="team-profile__media">
<img src="images/person-silhouette.svg" alt="Medizinische Fachangestellte">
</div>
<div class="team-profile__text">
<span class="team-profile__role">Medizinische Fachangestellte</span>
<h3 class="team-profile__name">Finja Baruth</h3>
<p class="team-profile__subtitle">Anmeldung, Assistenz, Labortätigkeiten und Praxisorganisation</p>
<p>Finja Baruth unterstützt unser Team in der Patientenaufnahme, in der täglichen Assistenz sowie bei vielen organisatorischen Abläufen im Praxisalltag. So bleibt der Ablauf auch an lebhaften Tagen verlässlich, freundlich und gut strukturiert.</p>
<ul class="team-profile__points">
<li>Assistenz und Unterstützung im Behandlungsalltag</li>
<li>Organisation und Koordination an der Anmeldung</li>
<li>Labortätigkeiten und Vorbereitung diagnostischer Abläufe</li>
<li>Typische Standardaufgaben in einer modernen Arztpraxis</li>
<li>Verlässliche Begleitung für Patienten und Team</li>
</ul>
</div>
</div>
</section>
<section class="team-profile">
<div class="team-profile__body">
<div class="team-profile__media">
<img src="images/person-silhouette.svg" alt="Medizinische Fachangestellte">
</div>
<div class="team-profile__text">
<span class="team-profile__role">Medizinische Fachangestellte</span>
<h3 class="team-profile__name">Svenja Vespermann</h3>
<p class="team-profile__subtitle">Assistenz, Labortätigkeiten und verlässliche Praxisabläufe</p>
<p>Svenja Vespermann sorgt mit dafür, dass Vorbereitungen, organisatorische Aufgaben und die praktische Unterstützung im Alltag sauber ineinandergreifen. Das schafft Ruhe im Tagesgeschäft und hilft, Patientinnen und Patienten gut durch die Praxis zu begleiten.</p>
<ul class="team-profile__points">
<li>Unterstützung bei Praxisorganisation und Assistenz</li>
<li>Labortätigkeiten und Mitwirkung bei diagnostischen Standardabläufen</li>
<li>Sorgfältige Vor- und Nachbereitung im Alltag</li>
<li>Typische Aufgaben in Anmeldung, Organisation und Behandlungsablauf</li>
<li>Teamarbeit mit Blick auf einen freundlichen Ablauf</li>
</ul>
</div>
</div>
</section>
<section class="team-profile">
<div class="team-profile__body">
<div class="team-profile__media">
<img src="images/person-silhouette.svg" alt="Auszubildende zur MFA">
</div>
<div class="team-profile__text">
<span class="team-profile__role">Auszubildende zur Medizinische Fachangestellte</span>
<h3 class="team-profile__name">Promise Nnodim</h3>
<p class="team-profile__subtitle">Lernen, Mitwachsen und Praxisnähe</p>
<p>Unsere Auszubildende zur Medizinischen Fachangestellten lernt die Abläufe in der Praxis Schritt für Schritt kennen und wächst dabei in die vielfältigen Aufgaben einer modernen Hausarztpraxis hinein. Begleitung, Lernen und Praxisnähe stehen dabei im Vordergrund.</p>
<ul class="team-profile__points">
<li>Einblick in Anmeldung, Organisation und Assistenz</li>
<li>Begleitung durch erfahrene Kolleginnen und Kollegen</li>
<li>Frühe Verankerung im Team und in den Praxisabläufen</li>
</ul>
</div>
</div>
</section>
<section class="team-profile">
<div class="team-profile__body">
<div class="team-profile__media">
<img src="images/person-silhouette.svg" alt="Barbara Creutzburg">
</div>
<div class="team-profile__text">
<span class="team-profile__role">Praxisassistentin</span>
<h3 class="team-profile__name">Barbara Creutzburg</h3>
<p class="team-profile__subtitle">Unterstützung im Praxisalltag und organisatorische Begleitung</p>
<p>Barbara Creutzburg unterstützt die Praxis in organisatorischen und assistierenden Aufgaben und trägt dazu bei, dass Abläufe im Hintergrund zuverlässig vorbereitet und begleitet werden.</p>
<ul class="team-profile__points">
<li>Unterstützung bei organisatorischen Praxisaufgaben</li>
<li>Assistierende Tätigkeiten im täglichen Ablauf</li>
<li>Verlässliche Begleitung im Hintergrund</li>
</ul>
</div>
</div>
</section>
<section class="team-profile">
<div class="team-profile__body">
<div class="team-profile__media">
<img src="images/clemenscreutzburg.jpg" alt="Clemens Creutzburg">
</div>
<div class="team-profile__text">
<span class="team-profile__role">Webmaster und EDV-Verantwortlicher</span>
<h3 class="team-profile__name">Clemens Creutzburg</h3>
<p class="team-profile__subtitle">Technik, digitale Prozesse und Systembetreuung</p>
<p>Clemens Creutzburg verantwortet die technische Betreuung der digitalen Systeme und unterstützt die Praxis bei Webseite, EDV-Struktur und technischen Abläufen.</p>
<ul class="team-profile__points">
<li>Betreuung der Webseite und digitaler Inhalte</li>
<li>Verantwortung für EDV, Technik und Systemstruktur</li>
<li>Unterstützung bei stabilen digitalen Praxisprozessen</li>
</ul>
</div>
</div>
</section>
</div>
</section>
<?php
+233
View File
@@ -0,0 +1,233 @@
<?php
session_start();
require_once('inc/config.inc.php');
require_once('inc/functions.inc.php');
require_once __DIR__ . '/inc/vacation_absence.inc.php';
$user = check_user();
if (!is_admin_user()) {
die('Zugriff verweigert. Nur Chefs dürfen den Leitungskalender sehen.');
}
$schemaWarning = '';
try {
vacationAbsenceEnsureSchema($pdo);
} catch (Throwable $e) {
$schemaWarning = 'Das Abwesenheitsschema konnte nicht automatisch aktualisiert werden: ' . $e->getMessage();
}
function adminAbsenceStatusLabel(?string $status): string
{
$status = trim((string)$status);
if ($status === '' || $status === 'beantragt') {
return 'Beantragt';
}
if ($status === 'genehmigt') {
return 'Genehmigt';
}
if ($status === 'abgelehnt') {
return 'Abgelehnt';
}
return ucfirst($status);
}
function adminAbsenceColor(string $reason, string $status): string
{
$reason = vacationAbsenceNormalizeReason($reason);
$status = trim(strtolower($status));
$palette = [
'urlaub' => '#2f855a',
'krankheit_mit_atest' => '#c53030',
'krankheit_ohne_atest' => '#9b2c2c',
'berufsschule' => '#dd6b20',
'weiterbildung' => '#6b46c1',
'persoenliche_gruende' => '#4a5568',
'sonstiges' => '#718096',
];
$color = $palette[$reason] ?? '#2d3748';
if ($status === 'beantragt') {
return $color;
}
return $color;
}
function adminAbsenceEventTitle(array $row): string
{
$employee = trim(($row['vorname'] ?? '') . ' ' . ($row['nachname'] ?? ''));
$reasonLabel = vacationAbsenceReasonLabel($row['absence_reason'] ?? 'urlaub');
$statusLabel = adminAbsenceStatusLabel($row['status'] ?? '');
if ($employee === '') {
return $reasonLabel . ' (' . $statusLabel . ')';
}
if ($statusLabel !== 'Genehmigt') {
return $employee . ' · ' . $reasonLabel . ' (' . $statusLabel . ')';
}
return $employee . ' · ' . $reasonLabel;
}
$events = [];
$vacStmt = $pdo->prepare("
SELECT
v.id,
v.user_id,
v.start_date,
v.end_date,
v.days,
v.status,
v.comment_user,
v.absence_reason,
u.vorname,
u.nachname,
u.email
FROM vacations v
JOIN users u ON u.id = v.user_id
WHERE LOWER(TRIM(COALESCE(v.status, ''))) != 'abgelehnt'
ORDER BY v.start_date ASC, v.end_date ASC, u.nachname ASC, u.vorname ASC
");
$vacStmt->execute();
$vacations = $vacStmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($vacations as $row) {
$endInclusive = (new DateTime($row['end_date']))->modify('+1 day')->format('Y-m-d');
$reason = vacationAbsenceNormalizeReason($row['absence_reason'] ?? 'urlaub');
$status = trim((string)($row['status'] ?? ''));
$statusLabel = adminAbsenceStatusLabel($status);
$events[] = [
'id' => 'vac_' . $row['id'],
'title' => adminAbsenceEventTitle($row),
'start' => $row['start_date'],
'end' => $endInclusive,
'allDay' => true,
'backgroundColor' => adminAbsenceColor($reason, $status),
'borderColor' => adminAbsenceColor($reason, $status),
'extendedProps' => [
'type' => 'absence',
'user_id' => $row['user_id'],
'employee_name' => trim(($row['vorname'] ?? '') . ' ' . ($row['nachname'] ?? '')),
'email' => $row['email'] ?? '',
'status' => $status,
'status_label' => $statusLabel,
'reason' => $reason,
'reason_label' => vacationAbsenceReasonLabel($reason),
'days' => (int)($row['days'] ?? 0),
'comment' => $row['comment_user'] ?? '',
],
];
}
$companyStmt = $pdo->prepare("
SELECT id, start_date, end_date, description
FROM company_holidays
ORDER BY start_date ASC, end_date ASC
");
$companyStmt->execute();
$companyHolidays = $companyStmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($companyHolidays as $row) {
$endInclusive = (new DateTime($row['end_date']))->modify('+1 day')->format('Y-m-d');
$events[] = [
'id' => 'com_' . $row['id'],
'title' => $row['description'] ?: 'Betriebsurlaub',
'start' => $row['start_date'],
'end' => $endInclusive,
'allDay' => true,
'backgroundColor' => '#005f73',
'borderColor' => '#005f73',
'extendedProps' => [
'type' => 'company',
'description' => $row['description'] ?: 'Betriebsurlaub',
],
];
}
include 'header.php';
?>
<div class="container">
<h2>Leitungskalender</h2>
<?php if (!empty($schemaWarning)): ?>
<div class="alert alert-warning"><?php echo htmlspecialchars($schemaWarning); ?></div>
<?php endif; ?>
<div class="alert alert-info">
<strong>Hinweis:</strong> Dieser Kalender zeigt alle Abwesenheiten aller Personen sowie Betriebsurlaub.
</div>
<div id="calendar"></div>
<br>
<div class="d-flex flex-wrap" style="gap: 0.5rem;">
<span class="badge badge-success">Urlaub</span>
<span class="badge badge-danger">Krankheit mit Attest</span>
<span class="badge badge-dark">Krankheit ohne Attest</span>
<span class="badge badge-warning">Berufsschule</span>
<span class="badge badge-primary">Weiterbildung</span>
<span class="badge badge-secondary">Persönliche Gründe</span>
<span class="badge badge-light">Sonstiges</span>
<span class="badge badge-info">Betriebsurlaub</span>
</div>
<br>
<div id="eventDetails" style="display:none;">
<h4>Details</h4>
<div id="detailsContent"></div>
</div>
</div>
<link href='https://cdn.jsdelivr.net/npm/fullcalendar@5.11.3/main.min.css' rel='stylesheet' />
<script src='https://cdn.jsdelivr.net/npm/fullcalendar@5.11.3/main.min.js'></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var calendarEl = document.getElementById('calendar');
var events = <?php echo json_encode($events, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?>;
function formatAllDayRange(start, end) {
var startDate = new Date(start);
var endDate = end ? new Date(end) : null;
var startLabel = startDate.toLocaleDateString('de-DE');
if (!endDate) {
return startLabel;
}
var inclusiveEnd = new Date(endDate.getTime() - 24 * 60 * 60 * 1000);
return startLabel + ' - ' + inclusiveEnd.toLocaleDateString('de-DE');
}
var calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'dayGridMonth',
firstDay: 1,
height: 700,
events: events,
eventClick: function(info) {
var ev = info.event;
var props = ev.extendedProps || {};
var html = '<strong>' + ev.title + '</strong><br>' + formatAllDayRange(ev.start, ev.end) + '<br>';
if (props.type === 'absence') {
html += 'Mitarbeiter: ' + (props.employee_name || '') + '<br>';
html += 'Grund: ' + (props.reason_label || '') + '<br>';
html += 'Status: ' + (props.status_label || '') + '<br>';
html += 'Tage: ' + (props.days || 0) + '<br>';
if (props.comment) {
html += 'Kommentar: ' + props.comment + '<br>';
}
} else if (props.type === 'company') {
html += 'Beschreibung: ' + (props.description || 'Betriebsurlaub') + '<br>';
}
document.getElementById('detailsContent').innerHTML = html;
document.getElementById('eventDetails').style.display = 'block';
}
});
calendar.render();
});
</script>
<?php include 'footer.php'; ?>
+10
View File
@@ -17,6 +17,13 @@ if ($_SERVER["REQUEST_METHOD"] == "POST") {
# echo "Location: createPDF.php?id=$userId&month=$month&year=$year";
header("Location: createPDF.php?id=$userId&month=$month&year=$year");
exit();
} elseif ($_POST["action"] == "Alle PDFs anzeigen") {
$selectedMonth = $_POST["month"];
$monthYear = explode("/", $selectedMonth);
$month = $monthYear[0];
$year = $monthYear[1];
header("Location: createAllPDF.php?month=$month&year=$year");
exit();
}
}
@@ -120,6 +127,9 @@ if ($selectedMonth && $selectedUser) {
<input type="submit" value="Zeiten anzeigen" class="btn btn-primary btn-lg">
<!-- Button zum Anzeigen der PDF -->
<input type="submit" name="action" value="PDF anzeigen" class="btn btn-primary btn-lg" formtarget="_blank">
<?php if (is_admin_user()): ?>
<input type="submit" name="action" value="Alle PDFs anzeigen" class="btn btn-secondary btn-lg" formtarget="_blank">
<?php endif; ?>
</form>
+28
View File
@@ -12,6 +12,10 @@ if (!isset($_SESSION['userid'])) {
$user_id = $_SESSION['userid'];
$user = check_user();
if (!is_admin_user()) {
die("Keine Rechte für diese Ansicht.");
}
?>
<?php include 'header.php'; ?>
@@ -25,6 +29,13 @@ $user = check_user();
<div class="container">
<h2 class="mb-4">Zeitbuchungsfehler Auswertung</h2>
<?php if (!empty($_SESSION['time_error_close_result'])): ?>
<?php $closeResult = $_SESSION['time_error_close_result']; unset($_SESSION['time_error_close_result']); ?>
<div class="alert alert-<?php echo htmlspecialchars($closeResult['type'], ENT_QUOTES, 'UTF-8'); ?>" role="alert">
<?php echo htmlspecialchars($closeResult['message'], ENT_QUOTES, 'UTF-8'); ?>
</div>
<?php endif; ?>
<?php
// Benutzer aus der Datenbank erhalten
@@ -132,6 +143,23 @@ foreach($users AS $user){
<?php if (!empty($invalidDates)): ?>
<form action="closeEmployeeTimeErrors.php" method="post" class="form-inline mb-3">
<input type="hidden" name="employee_id" value="<?php echo (int)$user['id']; ?>">
<label class="mr-2" for="hours_to_close_<?php echo (int)$user['id']; ?>">Fehlbuchungen automatisch schließen mit</label>
<input
type="number"
step="0.25"
min="0.25"
class="form-control mr-2"
id="hours_to_close_<?php echo (int)$user['id']; ?>"
name="hours_to_close"
value="8"
required
>
<span class="mr-2">Stunden</span>
<button type="submit" class="btn btn-primary">Alle schließen</button>
</form>
<table class="table table-striped">
<thead class="thead-dark">
<tr>
+350 -126
View File
@@ -2,129 +2,327 @@
session_start();
require_once(__DIR__ . '/../inc/config.inc.php');
require_once(__DIR__ . '/../inc/functions.inc.php');
require_once(__DIR__ . '/../inc/vacation_absence.inc.php');
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
error_reporting(E_ALL);
if (!function_exists('vacationApiTableHasColumn')) {
function vacationApiTableHasColumn(PDO $pdo, string $table, string $column): bool
{
$stmt = $pdo->prepare(
"SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = :table_name
AND COLUMN_NAME = :column_name"
);
$stmt->execute([
'table_name' => $table,
'column_name' => $column,
]);
return (int)$stmt->fetchColumn() > 0;
}
}
if (!function_exists('vacationApiLower')) {
function vacationApiLower(?string $value): string
{
$value = trim((string)$value);
if ($value === '') {
return '';
}
if (function_exists('mb_strtolower')) {
return mb_strtolower($value, 'UTF-8');
}
return strtolower($value);
}
}
if (!function_exists('vacationApiNormalizeAbsenceType')) {
function vacationApiNormalizeAbsenceType(?string $type): string
{
$type = vacationApiLower($type);
if ($type === '') {
return vacationAbsenceDefaultReason();
}
$type = strtr($type, [
'ä' => 'ae',
'ö' => 'oe',
'ü' => 'ue',
'ß' => 'ss',
]);
$type = preg_replace('/[^a-z0-9]+/u', '_', $type);
$type = trim((string)$type, '_');
$map = [
'urlaub' => 'urlaub',
'urlaubsantrag' => 'urlaub',
'krankheit_mit_attest' => 'krankheit_mit_atest',
'krankheit_mit_atest' => 'krankheit_mit_atest',
'krank_mit_attest' => 'krankheit_mit_atest',
'krankheit_ohne_attest' => 'krankheit_ohne_atest',
'krankheit_ohne_atest' => 'krankheit_ohne_atest',
'krank_ohne_attest' => 'krankheit_ohne_atest',
'berufsschule' => 'berufsschule',
'weiterbildung' => 'weiterbildung',
'persoenliche_gruende' => 'persoenliche_gruende',
'persoenliche_gruende_' => 'persoenliche_gruende',
'persoenliche_gruende_mit' => 'persoenliche_gruende',
'sonstiges' => 'sonstiges',
];
return vacationAbsenceNormalizeReason($map[$type] ?? $type);
}
}
if (!function_exists('vacationApiNormalizeStatus')) {
function vacationApiNormalizeStatus(?string $status): string
{
$status = vacationApiLower($status);
if ($status === '') {
return 'beantragt';
}
$status = preg_replace('/\s+/u', ' ', $status);
return $status;
}
}
if (!function_exists('vacationApiTypeLabel')) {
function vacationApiTypeLabel(string $type): string
{
return vacationAbsenceReasonLabel($type);
}
}
if (!function_exists('vacationApiStatusLabel')) {
function vacationApiStatusLabel(string $status): string
{
$labels = [
'genehmigt' => 'Genehmigt',
'beantragt' => 'Beantragt',
'abgelehnt' => 'Abgelehnt',
];
return $labels[$status] ?? ucfirst($status);
}
}
if (!function_exists('vacationApiStatusColor')) {
function vacationApiStatusColor(string $status): string
{
$colors = [
'genehmigt' => '#28a745',
'beantragt' => '#f0ad4e',
'abgelehnt' => '#6c757d',
];
return $colors[$status] ?? '#17a2b8';
}
}
if (!function_exists('vacationApiTypeColor')) {
function vacationApiTypeColor(string $type): string
{
$colors = [
'urlaub' => '#2f9e44',
'krankheit_mit_atest' => '#d9480f',
'krankheit_ohne_atest' => '#c92a2a',
'berufsschule' => '#1971c2',
'weiterbildung' => '#5f3dc4',
'persoenliche_gruende' => '#e67700',
'sonstiges' => '#495057',
];
return $colors[$type] ?? '#0d6efd';
}
}
if (!function_exists('vacationApiRequestedTypes')) {
function vacationApiRequestedTypes(?string $rawTypes, string $scope): ?array
{
$rawTypes = trim((string)$rawTypes);
if ($rawTypes === '') {
if ($scope === 'team') {
return ['urlaub'];
}
return null;
}
$normalized = vacationApiLower($rawTypes);
if (in_array($normalized, ['all', '*'], true)) {
return null;
}
$types = [];
foreach (preg_split('/\s*,\s*/', $rawTypes) as $rawType) {
$normalizedType = vacationApiNormalizeAbsenceType($rawType);
if ($normalizedType !== '') {
$types[$normalizedType] = true;
}
}
$types = array_keys($types);
sort($types);
return $types;
}
}
if (!function_exists('vacationApiStatusAllowed')) {
function vacationApiStatusAllowed(string $status, string $mode): bool
{
$mode = vacationApiLower($mode);
$status = vacationApiNormalizeStatus($status);
switch ($mode) {
case 'approved':
return $status === 'genehmigt';
case 'open':
return $status === 'beantragt' || $status === '';
case 'active':
return $status === 'genehmigt' || $status === 'beantragt' || $status === '';
case 'rejected':
return $status === 'abgelehnt';
case 'all':
default:
return true;
}
}
}
$user = check_user();
$isAdmin = is_admin_user();
$start = $_GET['start'] ?? null;
$end = $_GET['end'] ?? null;
$start = trim((string)($_GET['start'] ?? ''));
$end = trim((string)($_GET['end'] ?? ''));
if ($start === '' || $end === '') {
http_response_code(400);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'start and end required']);
exit;
}
$onlyApproved = isset($_GET['only_approved']) && ($_GET['only_approved'] == '1' || $_GET['only_approved'] === 'true');
$public = isset($_GET['public']) && ($_GET['public'] == '1' || $_GET['public'] === 'true');
$includeRejected = isset($_GET['include_rejected']) && ($_GET['include_rejected'] == '1' || $_GET['include_rejected'] === 'true');
$onlyPersonal = isset($_GET['only_personal']) && ($_GET['only_personal'] == '1' || $_GET['only_personal'] === 'true');
$publicAll = isset($_GET['public_all']) && ($_GET['public_all'] == '1' || $_GET['public_all'] === 'true');
if (!$start || !$end) {
http_response_code(400);
echo json_encode(['error' => 'start and end required']);
exit;
$scope = strtolower(trim((string)($_GET['scope'] ?? '')));
if ($scope === '') {
if ($onlyPersonal) {
$scope = 'personal';
} elseif ($public || $publicAll) {
$scope = 'team';
} elseif ($isAdmin) {
$scope = 'team';
} else {
$scope = 'personal';
}
}
if (!in_array($scope, ['personal', 'team', 'admin_all'], true)) {
$scope = 'personal';
}
if ($scope === 'admin_all' && !$isAdmin) {
$scope = 'personal';
}
$statusMode = strtolower(trim((string)($_GET['status_filter'] ?? '')));
if ($statusMode === '') {
if ($onlyApproved) {
$statusMode = 'approved';
} elseif ($includeRejected) {
$statusMode = 'all';
} elseif ($scope === 'personal' || $scope === 'admin_all') {
$statusMode = 'all';
} else {
$statusMode = 'active';
}
}
$includeCompany = !isset($_GET['include_company']) || $_GET['include_company'] === '1' || $_GET['include_company'] === 'true';
$requestedTypes = vacationApiRequestedTypes($_GET['absence_types'] ?? null, $scope);
$hasAbsenceReasonColumn = vacationApiTableHasColumn($pdo, 'vacations', 'absence_reason');
$absenceTypeSelect = $hasAbsenceReasonColumn ? 'v.absence_reason' : "'" . vacationAbsenceDefaultReason() . "'";
$selectColumns = "
SELECT
v.id,
v.user_id,
v.start_date,
v.end_date,
v.days,
v.status,
v.comment_user,
u.vorname,
u.nachname,
{$absenceTypeSelect} AS absence_type
FROM vacations v
JOIN users u ON v.user_id = u.id
WHERE v.start_date <= ?
AND v.end_date >= ?
";
$params = [$end, $start];
if ($scope === 'personal') {
$selectColumns .= " AND v.user_id = ?";
$params[] = $_SESSION['userid'];
} elseif ($scope === 'team' || $scope === 'admin_all') {
// No further restriction here. Scope filters are applied in PHP so
// the query remains flexible for future admin and team views.
}
$selectColumns .= " ORDER BY v.start_date ASC, v.id ASC";
$stmt = $pdo->prepare($selectColumns);
$stmt->execute($params);
$vacations = $stmt->fetchAll(PDO::FETCH_ASSOC);
$events = [];
try {
$branch = 'unknown';
$debugMode = isset($_GET['debug']) && ($_GET['debug'] == '1' || $_GET['debug'] === 'true');
$showEmployeeNames = $isAdmin || $public;
if ($onlyPersonal) {
$branch = 'onlyPersonal';
if ($onlyApproved) {
$branch = 'onlyPersonal_onlyApproved';
$stmt = $pdo->prepare("SELECT v.*, u.vorname, u.nachname FROM vacations v JOIN users u ON v.user_id = u.id WHERE v.user_id = ? AND v.start_date <= ? AND v.end_date >= ? AND LOWER(TRIM(v.status)) = 'genehmigt' ORDER BY v.start_date");
$stmt->execute([$_SESSION['userid'], $end, $start]);
} else {
if ($includeRejected) {
$branch = 'onlyPersonal_includeRejected';
$stmt = $pdo->prepare("SELECT v.*, u.vorname, u.nachname FROM vacations v JOIN users u ON v.user_id = u.id WHERE v.user_id = ? AND v.start_date <= ? AND v.end_date >= ? ORDER BY v.start_date");
$stmt->execute([$_SESSION['userid'], $end, $start]);
} else {
$branch = 'onlyPersonal_excludeRejected';
$stmt = $pdo->prepare("SELECT v.*, u.vorname, u.nachname FROM vacations v JOIN users u ON v.user_id = u.id WHERE v.user_id = ? AND v.start_date <= ? AND v.end_date >= ? AND (v.status IS NULL OR LOWER(TRIM(v.status)) != 'abgelehnt') ORDER BY v.start_date");
$stmt->execute([$_SESSION['userid'], $end, $start]);
}
}
} elseif ($isAdmin) {
$branch = 'admin';
if ($onlyApproved) {
$branch = 'admin_onlyApproved';
$stmt = $pdo->prepare("SELECT v.*, u.vorname, u.nachname FROM vacations v JOIN users u ON v.user_id = u.id WHERE v.start_date <= ? AND v.end_date >= ? AND LOWER(TRIM(v.status)) = 'genehmigt' ORDER BY v.start_date");
$stmt->execute([$end, $start]);
} else {
if ($includeRejected) {
$stmt = $pdo->prepare("SELECT v.*, u.vorname, u.nachname FROM vacations v JOIN users u ON v.user_id = u.id WHERE v.start_date <= ? AND v.end_date >= ? ORDER BY v.start_date");
$stmt->execute([$end, $start]);
} else {
$stmt = $pdo->prepare("SELECT v.*, u.vorname, u.nachname FROM vacations v JOIN users u ON v.user_id = u.id WHERE v.start_date <= ? AND v.end_date >= ? AND (v.status IS NULL OR LOWER(TRIM(v.status)) IN ('genehmigt','beantragt')) ORDER BY v.start_date");
$stmt->execute([$end, $start]);
}
}
} else {
$branch = 'public_or_regular';
if ($public && $onlyApproved) {
$branch = 'public_onlyApproved';
$stmt = $pdo->prepare("SELECT v.*, u.vorname, u.nachname FROM vacations v JOIN users u ON v.user_id = u.id WHERE v.start_date <= ? AND v.end_date >= ? AND LOWER(TRIM(v.status)) = 'genehmigt' ORDER BY v.start_date");
$stmt->execute([$end, $start]);
} elseif ($public && $publicAll) {
$branch = 'public_publicAll';
$stmt = $pdo->prepare("SELECT v.*, u.vorname, u.nachname FROM vacations v JOIN users u ON v.user_id = u.id WHERE v.start_date <= ? AND v.end_date >= ? AND (v.status IS NULL OR LOWER(TRIM(v.status)) IN ('genehmigt','beantragt')) ORDER BY v.start_date");
$stmt->execute([$end, $start]);
} else {
if ($onlyApproved) {
$stmt = $pdo->prepare("SELECT v.*, u.vorname, u.nachname FROM vacations v JOIN users u ON v.user_id = u.id WHERE v.user_id = ? AND v.start_date <= ? AND v.end_date >= ? AND LOWER(TRIM(v.status)) = 'genehmigt' ORDER BY v.start_date");
$stmt->execute([$_SESSION['userid'], $end, $start]);
} else {
if ($includeRejected) {
$branch = 'regular_includeRejected';
$stmt = $pdo->prepare("SELECT v.*, u.vorname, u.nachname FROM vacations v JOIN users u ON v.user_id = u.id WHERE v.user_id = ? AND v.start_date <= ? AND v.end_date >= ? ORDER BY v.start_date");
$stmt->execute([$_SESSION['userid'], $end, $start]);
} else {
$branch = 'regular_excludeRejected';
$stmt = $pdo->prepare("SELECT v.*, u.vorname, u.nachname FROM vacations v JOIN users u ON v.user_id = u.id WHERE v.user_id = ? AND v.start_date <= ? AND v.end_date >= ? AND (v.status IS NULL OR LOWER(TRIM(v.status)) != 'abgelehnt') ORDER BY v.start_date");
$stmt->execute([$_SESSION['userid'], $end, $start]);
}
}
}
}
foreach ($vacations as $v) {
$absenceType = vacationApiNormalizeAbsenceType($v['absence_type'] ?? '');
$status = vacationApiNormalizeStatus($v['status'] ?? '');
try {
$vacations = $stmt->fetchAll(PDO::FETCH_ASSOC);
if ($debugMode) {
$rawStatuses = array_map(function($r){ return $r['status'] ?? null; }, $vacations);
$meta = [
'branch' => $branch,
'count' => count($vacations),
'raw_statuses' => $rawStatuses
];
}
foreach ($vacations as $v) {
if (isset($v['status'])) {
$normalized = preg_replace('/\s+/u', ' ', $v['status']);
$status = mb_strtolower(trim($normalized));
} else {
$status = '';
}
if (!$isAdmin && !$includeRejected && mb_stripos($status, 'abgelehnt') !== false) {
if ($scope === 'team' && $absenceType !== 'urlaub') {
continue;
}
$isApproved = (mb_stripos($status, 'genehmigt') !== false);
$employeeName = trim(($v['vorname'] ?? '') . ' ' . ($v['nachname'] ?? ''));
if ($showEmployeeNames && $employeeName !== '') {
$title = $employeeName;
if ($v['status'] !== null && $v['status'] !== '') {
$title .= ' (' . $v['status'] . ')';
if ($requestedTypes !== null && !in_array($absenceType, $requestedTypes, true)) {
continue;
}
} elseif ($isApproved) {
$title = 'Urlaub';
if (!vacationApiStatusAllowed($status, $statusMode)) {
continue;
}
$employeeName = trim((string)($v['vorname'] ?? '') . ' ' . (string)($v['nachname'] ?? ''));
$typeLabel = vacationApiTypeLabel($absenceType);
$statusLabel = vacationApiStatusLabel($status);
if ($scope === 'personal') {
$title = $typeLabel;
} elseif ($employeeName !== '') {
$title = $employeeName . ' - ' . $typeLabel;
} else {
$title = 'Urlaubsantrag';
$title = $typeLabel;
}
if ($status !== '' && $status !== 'genehmigt') {
$title .= ' (' . $statusLabel . ')';
}
try {
@@ -133,59 +331,85 @@ try {
$endInclusive = $v['start_date'];
}
$backgroundColor = vacationApiTypeColor($absenceType);
if ($status === 'abgelehnt') {
$backgroundColor = vacationApiStatusColor($status);
}
$events[] = [
'id' => 'vac_' . $v['id'],
'title' => $title,
'start' => $v['start_date'],
'end' => $endInclusive,
'allDay' => true,
'color' => ($isApproved) ? '#28a745' : '#ffc107',
'backgroundColor' => $backgroundColor,
'borderColor' => $backgroundColor,
'textColor' => '#ffffff',
'extendedProps' => [
'type' => 'user',
'user_id' => $v['user_id'],
'employee_name' => $employeeName,
'absence_type' => $absenceType,
'absence_label' => $typeLabel,
'status' => $v['status'],
'comment' => $v['comment_user'] ?? ''
]
'status_label' => $statusLabel,
'comment' => $v['comment_user'] ?? '',
'scope' => $scope,
],
];
}
} catch (Exception $ex) {
header('Content-Type: application/json; charset=utf-8');
$payload = ['error' => $ex->getMessage(), 'branch' => $branch, 'trace' => $ex->getTraceAsString()];
echo json_encode($payload);
exit;
}
} catch (Exception $ex) {
header('Content-Type: application/json; charset=utf-8');
$payload = ['error' => $ex->getMessage(), 'branch' => $branch, 'trace' => $ex->getTraceAsString()];
echo json_encode($payload);
exit;
}
$stmt = $pdo->prepare("SELECT * FROM company_holidays WHERE start_date <= ? AND end_date >= ? ORDER BY start_date");
$stmt->execute([$end, $start]);
$holidays = $stmt->fetchAll(PDO::FETCH_ASSOC);
if ($includeCompany) {
$stmt = $pdo->prepare("SELECT id, start_date, end_date, description, vertretung, vertretertelefon, vertreteradresse, vertreterurl FROM company_holidays WHERE start_date <= ? AND end_date >= ? ORDER BY start_date");
$stmt->execute([$end, $start]);
$holidays = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($holidays as $h) {
foreach ($holidays as $h) {
try {
$endInclusive = (new DateTime($h['end_date']))->modify('+1 day')->format('Y-m-d');
} catch (Exception $e) {
$endInclusive = $h['start_date'];
}
$description = trim((string)($h['description'] ?? ''));
$title = $description !== '' ? $description : 'Betriebsurlaub';
$events[] = [
'id' => 'com_' . $h['id'],
'title' => $h['description'] ?: 'Betriebsurlaub',
'title' => $title,
'start' => $h['start_date'],
'end' => $endInclusive,
'allDay' => true,
'color' => '#007bff',
'backgroundColor' => '#0b7285',
'borderColor' => '#0b7285',
'textColor' => '#ffffff',
'extendedProps' => [
'type' => 'company',
'description' => $h['description']
]
'description' => $description,
'vertretung' => $h['vertretung'] ?? '',
'vertretertelefon' => $h['vertretertelefon'] ?? '',
'vertreteradresse' => $h['vertreteradresse'] ?? '',
'vertreterurl' => $h['vertreterurl'] ?? '',
],
];
}
}
header('Content-Type: application/json; charset=utf-8');
if ($debugMode) {
echo json_encode(['events' => $events, 'meta' => $meta]);
} else {
echo json_encode($events);
$json = json_encode(
$events,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE
);
if ($json === false) {
http_response_code(500);
echo json_encode([
'error' => 'Kalenderdaten konnten nicht kodiert werden.',
'json_error' => json_last_error_msg(),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
echo $json;
?>
+14 -11
View File
@@ -2,12 +2,15 @@
session_start();
require_once("inc/config.inc.php");
require_once("inc/functions.inc.php");
require_once("inc/vacation_absence.inc.php");
$user = check_user();
if (!is_admin_user()) {
die('Zugriff verweigert. Nur Chefs dürfen Anträge genehmigen.');
}
vacationAbsenceEnsureSchema($pdo);
// Handle approve/reject actions
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['id']) && isset($_POST['action'])) {
$id = (int)$_POST['id'];
@@ -38,12 +41,13 @@ $requests = $stmt->fetchAll();
?>
<div class="container">
<h2>Urlaubsanträge - Genehmigung</h2>
<h2>Abwesenheitsanträge - Genehmigung</h2>
<table class="table table-bordered">
<thead>
<tr>
<th>Mitarbeiter</th>
<th>Grund</th>
<th>Von</th>
<th>Bis</th>
<th>Tage</th>
@@ -56,10 +60,11 @@ $requests = $stmt->fetchAll();
<?php foreach ($requests as $r): ?>
<tr>
<td><?php echo htmlspecialchars($r['vorname'] . ' ' . $r['nachname']); ?></td>
<td><?php echo $r['start_date']; ?></td>
<td><?php echo $r['end_date']; ?></td>
<td><?php echo $r['days']; ?></td>
<td><?php echo htmlspecialchars($r['comment_user']); ?></td>
<td><?php echo htmlspecialchars(vacationAbsenceReasonLabel($r['absence_reason'] ?? 'urlaub')); ?></td>
<td><?php echo htmlspecialchars((string)$r['start_date']); ?></td>
<td><?php echo htmlspecialchars((string)$r['end_date']); ?></td>
<td><?php echo (int)$r['days']; ?></td>
<td><?php echo htmlspecialchars((string)($r['comment_user'] ?? '')); ?></td>
<td>
<?php
if ($r['status'] === 'beantragt' || $r['status'] === null) {
@@ -74,7 +79,7 @@ $requests = $stmt->fetchAll();
<td>
<?php if ($r['status'] !== 'genehmigt'): ?>
<form method="post" style="display:inline-block; margin-right:6px;">
<input type="hidden" name="id" value="<?php echo $r['id']; ?>">
<input type="hidden" name="id" value="<?php echo (int)$r['id']; ?>">
<input type="hidden" name="action" value="approve">
<button class="btn btn-sm btn-success" type="submit">Genehmigen</button>
</form>
@@ -82,13 +87,13 @@ $requests = $stmt->fetchAll();
<?php if ($r['status'] !== 'abgelehnt'): ?>
<form method="post" style="display:inline-block;">
<input type="hidden" name="id" value="<?php echo $r['id']; ?>">
<input type="hidden" name="id" value="<?php echo (int)$r['id']; ?>">
<input type="hidden" name="action" value="reject">
<button class="btn btn-sm btn-danger" type="submit">Ablehnen</button>
</form>
<?php endif; ?>
<form method="post" style="display:inline-block; margin-left:6px;" onsubmit="return confirm('Wirklich löschen?');">
<input type="hidden" name="id" value="<?php echo $r['id']; ?>">
<input type="hidden" name="id" value="<?php echo (int)$r['id']; ?>">
<input type="hidden" name="action" value="delete">
<button class="btn btn-sm btn-outline-danger" type="submit">Löschen</button>
</form>
@@ -100,6 +105,4 @@ $requests = $stmt->fetchAll();
</div>
<?php include 'footer.php';
?>
<?php include 'footer.php'; ?>
+147
View File
@@ -0,0 +1,147 @@
<?php
session_start();
require_once("inc/config.inc.php");
require_once("inc/functions.inc.php");
$user = check_user();
if (!is_admin_user()) {
http_response_code(403);
die("Keine Berechtigung.");
}
if ($_SERVER["REQUEST_METHOD"] !== "POST") {
die("Ungültige Anfrage.");
}
$employeeId = isset($_POST['employee_id']) ? (int)$_POST['employee_id'] : 0;
$hoursRaw = isset($_POST['hours_to_close']) ? str_replace(',', '.', trim((string)$_POST['hours_to_close'])) : '';
$hoursToClose = is_numeric($hoursRaw) ? (float)$hoursRaw : 0.0;
if ($employeeId <= 0) {
$_SESSION['time_error_close_result'] = [
'type' => 'danger',
'message' => 'Es wurde kein gültiger Mitarbeiter übergeben.',
];
header("Location: allefehlbuchungen.php");
exit;
}
if ($hoursToClose <= 0) {
$_SESSION['time_error_close_result'] = [
'type' => 'danger',
'message' => 'Bitte eine gültige Stundenanzahl größer als 0 angeben.',
];
header("Location: allefehlbuchungen.php");
exit;
}
try {
$stmt = $pdo->prepare("SELECT id, vorname, nachname FROM users WHERE id = :employee_id LIMIT 1");
$stmt->bindValue(':employee_id', $employeeId, PDO::PARAM_INT);
$stmt->execute();
$employee = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$employee) {
throw new RuntimeException('Mitarbeiter wurde nicht gefunden.');
}
$stmt = $pdo->prepare(
"SELECT
DATE(timestamp_datetime) AS datum,
GROUP_CONCAT(timestamp_type ORDER BY timestamp_datetime) AS day_sequence
FROM timestamps
WHERE employee_id = :employee_id
GROUP BY DATE(timestamp_datetime)
ORDER BY datum ASC"
);
$stmt->bindValue(':employee_id', $employeeId, PDO::PARAM_INT);
$stmt->execute();
$days = $stmt->fetchAll(PDO::FETCH_ASSOC);
$invalidDates = [];
foreach ($days as $day) {
if (!isValidSequence((string)$day['day_sequence'])) {
$invalidDates[] = (string)$day['datum'];
}
}
if (empty($invalidDates)) {
$_SESSION['time_error_close_result'] = [
'type' => 'info',
'message' => 'Es wurden keine offenen Fehlbuchungen für den Mitarbeiter gefunden.',
];
header("Location: allefehlbuchungen.php");
exit;
}
$pdo->beginTransaction();
$insertedDates = [];
$skippedDates = [];
$secondsToClose = (int)round($hoursToClose * 3600);
$lastEntryStmt = $pdo->prepare(
"SELECT timestamp_type, timestamp_datetime
FROM timestamps
WHERE employee_id = :employee_id
AND DATE(timestamp_datetime) = :datum
ORDER BY timestamp_datetime DESC, timestamp_id DESC
LIMIT 1"
);
$insertStmt = $pdo->prepare(
"INSERT INTO timestamps (employee_id, timestamp_type, timestamp_datetime, timestamp_endpoint)
VALUES (:employee_id, 'GEHEN', :timestamp_datetime, 0)"
);
foreach ($invalidDates as $invalidDate) {
$lastEntryStmt->bindValue(':employee_id', $employeeId, PDO::PARAM_INT);
$lastEntryStmt->bindValue(':datum', $invalidDate);
$lastEntryStmt->execute();
$lastEntry = $lastEntryStmt->fetch(PDO::FETCH_ASSOC);
if (!$lastEntry || (string)$lastEntry['timestamp_type'] !== 'KOMMEN') {
$skippedDates[] = $invalidDate;
continue;
}
$closeTimestamp = (new DateTimeImmutable((string)$lastEntry['timestamp_datetime']))
->modify('+' . $secondsToClose . ' seconds')
->format('Y-m-d H:i:s');
$insertStmt->bindValue(':employee_id', $employeeId, PDO::PARAM_INT);
$insertStmt->bindValue(':timestamp_datetime', $closeTimestamp);
$insertStmt->execute();
$insertedDates[] = $invalidDate;
}
$pdo->commit();
$message = count($insertedDates) . ' Fehlbuchungstage für '
. $employee['vorname'] . ' ' . $employee['nachname']
. ' wurden mit ' . rtrim(rtrim(number_format($hoursToClose, 2, '.', ''), '0'), '.')
. ' Stunden geschlossen.';
if (!empty($skippedDates)) {
$message .= ' ' . count($skippedDates) . ' Tage konnten nicht automatisch geschlossen werden.';
}
$_SESSION['time_error_close_result'] = [
'type' => !empty($insertedDates) ? 'success' : 'warning',
'message' => $message,
];
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
$_SESSION['time_error_close_result'] = [
'type' => 'danger',
'message' => 'Automatisches Schliessen fehlgeschlagen: ' . $e->getMessage(),
];
}
header("Location: allefehlbuchungen.php");
exit;
+173
View File
@@ -0,0 +1,173 @@
<?php
session_start();
require_once('./../admin/tcpdf/tcpdf.php');
require_once("inc/config.inc.php");
require_once("inc/functions.inc.php");
if (!isset($_SESSION['userid'])) {
die("Kein Benutzer angemeldet.");
}
$currentUser = check_user();
if (!is_admin_user()) {
die("Keine Berechtigung.");
}
$selectedMonth = $_GET['month'] ?? date('m');
$selectedYear = $_GET['year'] ?? date('Y');
$firmaName = "Praxis Creutzburg";
try {
$usersStmt = $pdo->prepare("
SELECT DISTINCT u.id, u.vorname, u.nachname
FROM users u
INNER JOIN timestamps t ON t.employee_id = u.id
WHERE u.zeiterfassung = '1'
AND MONTH(t.timestamp_datetime) = :month
AND YEAR(t.timestamp_datetime) = :year
ORDER BY u.nachname ASC, u.vorname ASC
");
$usersStmt->bindValue(':month', (int)$selectedMonth, PDO::PARAM_INT);
$usersStmt->bindValue(':year', (int)$selectedYear, PDO::PARAM_INT);
$usersStmt->execute();
$employees = $usersStmt->fetchAll(PDO::FETCH_ASSOC);
$pdf = new TCPDF(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false);
$pdf->SetCreator(PDF_CREATOR);
$pdf->SetAuthor($currentUser['vorname'] . ' ' . $currentUser['nachname']);
$pdf->SetTitle('Arbeitszeiten aller Mitarbeiter');
$pdf->SetSubject('Arbeitszeiten aller Mitarbeiter für ' . $selectedMonth . '/' . $selectedYear);
$stempdate = $selectedYear . "-" . $selectedMonth . "-1";
$date = new DateTime($stempdate);
$formatter = new IntlDateFormatter(
"de-DE",
IntlDateFormatter::LONG,
IntlDateFormatter::NONE,
"Europe/Berlin",
IntlDateFormatter::GREGORIAN,
"MMMM"
);
$monthName = $formatter->format($date);
$sequenceStmt = $pdo->prepare("
SELECT
DATE(timestamp_datetime) AS datum,
GROUP_CONCAT(timestamp_type ORDER BY timestamp_datetime) AS day_sequence
FROM timestamps
WHERE employee_id = :employee_id
AND MONTH(timestamp_datetime) = :selectedMonth
AND YEAR(timestamp_datetime) = :selectedYear
GROUP BY DATE(timestamp_datetime)
");
$timesStmt = $pdo->prepare("
SELECT
DATE(timestamp_datetime) AS day,
MIN(CASE WHEN timestamp_type = 'KOMMEN' THEN timestamp_datetime END) AS first_come,
MAX(CASE WHEN timestamp_type = 'GEHEN' THEN timestamp_datetime END) AS last_go,
SEC_TO_TIME(SUM(
CASE
WHEN timestamp_type = 'GEHEN' THEN UNIX_TIMESTAMP(timestamp_datetime)
WHEN timestamp_type = 'KOMMEN' THEN -UNIX_TIMESTAMP(timestamp_datetime)
ELSE 0
END
)) AS total_time,
SEC_TO_TIME(
TIME_TO_SEC(
SEC_TO_TIME(
UNIX_TIMESTAMP(MAX(CASE WHEN timestamp_type = 'GEHEN' THEN timestamp_datetime END)) -
UNIX_TIMESTAMP(MIN(CASE WHEN timestamp_type = 'KOMMEN' THEN timestamp_datetime END))
)
) - TIME_TO_SEC(SEC_TO_TIME(SUM(
CASE
WHEN timestamp_type = 'GEHEN' THEN UNIX_TIMESTAMP(timestamp_datetime)
WHEN timestamp_type = 'KOMMEN' THEN -UNIX_TIMESTAMP(timestamp_datetime)
ELSE 0
END
)))
) AS difference_between_total_time_and_pause_time
FROM timestamps
WHERE employee_id = :employee_id
AND MONTH(timestamp_datetime) = :month
AND YEAR(timestamp_datetime) = :year
GROUP BY DATE(timestamp_datetime)
");
foreach ($employees as $employee) {
$employeeId = (int)$employee['id'];
$employeeName = trim($employee['vorname'] . ' ' . $employee['nachname']);
$sequenceStmt->bindValue(':employee_id', $employeeId, PDO::PARAM_INT);
$sequenceStmt->bindValue(':selectedMonth', (int)$selectedMonth, PDO::PARAM_INT);
$sequenceStmt->bindValue(':selectedYear', (int)$selectedYear, PDO::PARAM_INT);
$sequenceStmt->execute();
$sequenceRows = $sequenceStmt->fetchAll(PDO::FETCH_ASSOC);
$fehlerhafteTage = [];
foreach ($sequenceRows as $row) {
if (!isValidSequence((string)$row['day_sequence'])) {
$fehlerhafteTage[] = (string)$row['datum'];
}
}
$pdf->AddPage();
$html = '<h1>Arbeitszeiten - ' . $firmaName . '</h1>';
$html .= '<h2>Mitarbeiter: ' . htmlspecialchars($employeeName, ENT_QUOTES, 'UTF-8') . '</h2>';
$html .= '<h3>Monat: ' . htmlspecialchars((string)$monthName, ENT_QUOTES, 'UTF-8') . ' ' . htmlspecialchars((string)$selectedYear, ENT_QUOTES, 'UTF-8') . '</h3>';
if (!empty($fehlerhafteTage)) {
$html .= '<p><strong>Fehlzeiten erkannt.</strong> Bitte erst beheben.</p>';
$html .= '<p>Betroffene Tage: ' . htmlspecialchars(implode(', ', array_map(static function (string $day): string {
return date('d.m.Y', strtotime($day));
}, $fehlerhafteTage)), ENT_QUOTES, 'UTF-8') . '</p>';
$pdf->writeHTML($html, true, false, true, false, '');
continue;
}
$timesStmt->bindValue(':employee_id', $employeeId, PDO::PARAM_INT);
$timesStmt->bindValue(':month', (int)$selectedMonth, PDO::PARAM_INT);
$timesStmt->bindValue(':year', (int)$selectedYear, PDO::PARAM_INT);
$timesStmt->execute();
$timesData = [];
while ($row = $timesStmt->fetch(PDO::FETCH_ASSOC)) {
$day = date('d', strtotime($row['day']));
$timesData[$day] = $row;
}
$html .= '<table border="1" style="font-size:14px;" width="100%">';
$html .= '<tr><th style="width: 7%;">Tag</th><th style="width: 12%;">Start</th><th style="width: 12%;">Pause</th><th style="width: 12%;">Ende</th><th>Gesamtzeit</th><th style="width: 20%;font-size:12px;">aufgezeichnet am:</th><th style="width: 23%;">Bemerkung</th></tr>';
$totalSeconds = 0;
for ($day = 1; $day <= 31; $day++) {
$daytwo = str_pad((string)$day, 2, '0', STR_PAD_LEFT);
if (isset($timesData[$daytwo])) {
$row = $timesData[$daytwo];
$html .= '<tr><td>' . $day . '</td><td>' . date('H:i:s', strtotime($row['first_come'])) . '</td><td>' . $row['difference_between_total_time_and_pause_time'] . '</td><td>' . date('H:i:s', strtotime($row['last_go'])) . '</td><td>' . $row['total_time'] . '</td><td>' . date('d.m.Y', strtotime($row['day'])) . '</td><td></td></tr>';
if (!empty($row['total_time'])) {
[$hours, $minutes, $seconds] = explode(':', $row['total_time']);
$totalSeconds += ((int)$hours * 3600) + ((int)$minutes * 60) + (int)$seconds;
}
} else {
$html .= '<tr><td>' . $day . '</td><td></td><td></td><td></td><td></td><td></td><td></td></tr>';
}
}
$hours = floor($totalSeconds / 3600);
$mins = floor(($totalSeconds / 60) % 60);
$secs = floor($totalSeconds % 60);
$totalTime = sprintf('%02d:%02d:%02d', $hours, $mins, $secs);
$html .= '<tr><td></td><td></td><td></td><td><b>Gesamt</b></td><td><b>' . $totalTime . '</b></td><td></td><td></td></tr>';
$html .= '</table>';
$pdf->writeHTML($html, true, false, true, false, '');
}
$pdf->Output('Arbeitszeiten_alle_' . $selectedYear . '_' . str_pad((string)$selectedMonth, 2, '0', STR_PAD_LEFT) . '.pdf', 'I');
} catch (PDOException $e) {
echo "Datenbankfehler: " . $e->getMessage();
}
?>
+15 -6
View File
@@ -41,19 +41,28 @@ if (!isset($user)) {
<a class="nav-link" href="fehlbuchungen.php">Fehlbuchungen</a>
</li>
<li class="nav-item">
<a class="nav-link" href="../admin/index.php">Zur Admin-Oberfläche</a>
</li>
<li class="nav-item">
<a class="nav-link" href="../admin/zeiterfassung_hilfe.php">Hilfe</a>
</li>
<!-- Urlaub Dropdown -->
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="urlaubDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Urlaub
Abwesenheit
</a>
<div class="dropdown-menu" aria-labelledby="urlaubDropdown">
<a class="dropdown-item" href="urlaubsantrag.php">Urlaubsantrag</a>
<a class="dropdown-item" href="my_vacations_calendar.php">Mein Urlaubskalender</a>
<a class="dropdown-item" href="vacations_calendar_all.php">Team Urlaubskalender</a>
<a class="dropdown-item" href="urlaubsantrag.php">Abwesenheitsantrag</a>
<a class="dropdown-item" href="my_vacations_calendar.php">Mein Abwesenheitskalender</a>
<a class="dropdown-item" href="vacations_calendar_all.php">Team-Urlaubskalender</a>
<?php if (is_admin_user()) : ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="vacations_overview.php">Urlaubsübersicht</a>
<a class="dropdown-item" href="approveVacation.php">Urlaubsanträge genehmigen</a>
<a class="dropdown-item" href="admin_absence_calendar.php">Leitungskalender</a>
<a class="dropdown-item" href="vacations_overview.php">Abwesenheitsübersicht</a>
<a class="dropdown-item" href="approveVacation.php">Abwesenheiten genehmigen</a>
<a class="dropdown-item" href="company_holidays.php">Betriebsurlaub</a>
<?php endif; ?>
</div>
@@ -0,0 +1,517 @@
<?php
use PHPMailer\PHPMailer\Exception;
use PHPMailer\PHPMailer\PHPMailer;
require_once __DIR__ . '/../../inc/PHPMailer/src/Exception.php';
require_once __DIR__ . '/../../inc/PHPMailer/src/PHPMailer.php';
require_once __DIR__ . '/../../inc/PHPMailer/src/SMTP.php';
if (!function_exists('timeErrorNotificationsEnsureTables')) {
function timeErrorNotificationsEnsureTables(PDO $pdo): void
{
$legacyColumnStmt = $pdo->prepare(
"SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'time_error_notifications'
AND COLUMN_NAME = 'error_date'"
);
$legacyColumnStmt->execute();
if ((int)$legacyColumnStmt->fetchColumn() > 0) {
$pdo->exec("DROP TABLE IF EXISTS time_error_notifications");
}
$pdo->exec(
"CREATE TABLE IF NOT EXISTS time_error_notification_state (
employee_id INT NOT NULL,
cycle_started_on DATE NOT NULL,
first_error_date DATE NOT NULL,
last_notification_stage VARCHAR(50) DEFAULT NULL,
last_notification_sent_at DATETIME DEFAULT NULL,
employee_day_1_sent_at DATETIME DEFAULT NULL,
employee_day_3_sent_at DATETIME DEFAULT NULL,
admin_day_7_sent_at DATETIME DEFAULT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (employee_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS time_error_notifications (
id INT NOT NULL AUTO_INCREMENT,
employee_id INT NOT NULL,
cycle_started_on DATE NOT NULL,
notification_stage VARCHAR(50) NOT NULL,
recipient_email VARCHAR(255) NOT NULL,
sent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uniq_time_error_notification (employee_id, cycle_started_on, notification_stage, recipient_email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
}
}
if (!function_exists('timeErrorNotificationsSendMail')) {
function timeErrorNotificationsSendMail(PDO $pdo, string $recipient, string $subject, string $body): bool
{
$stmt = $pdo->prepare("SELECT * FROM config LIMIT 1");
$stmt->execute();
$config = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$config) {
return false;
}
$mail = new PHPMailer(true);
try {
$mail->SMTPDebug = 0;
$mail->isSMTP();
$mail->Host = (string)$config['mailserver'];
$mail->SMTPAuth = true;
$mail->Username = (string)$config['mailUsername'];
$mail->Password = (string)$config['mailPassword'];
if (!empty($config['mailSMTPSecure'])) {
$mail->SMTPSecure = (string)$config['mailSMTPSecure'];
}
$mail->Port = (int)$config['mailPort'];
$mail->CharSet = 'UTF-8';
$mail->setFrom((string)$config['mailFrom'], (string)$config['mailFromName']);
$mail->addAddress($recipient);
$mail->isHTML(true);
$mail->Subject = $subject;
$mail->Body = $body;
$mail->AltBody = trim(html_entity_decode(strip_tags(str_replace(["<br>", "<br/>", "<br />"], "\n", $body)), ENT_QUOTES, 'UTF-8'));
$mail->send();
return true;
} catch (Exception $e) {
return false;
}
}
}
if (!function_exists('timeErrorNotificationsFetchInvalidEntries')) {
function timeErrorNotificationsFetchInvalidEntries(PDO $pdo): array
{
$stmt = $pdo->prepare(
"SELECT
u.id AS employee_id,
u.email,
u.vorname,
u.nachname,
DATE(t.timestamp_datetime) AS error_date,
GROUP_CONCAT(t.timestamp_type ORDER BY t.timestamp_datetime) AS day_sequence
FROM users u
INNER JOIN timestamps t ON t.employee_id = u.id
WHERE u.zeiterfassung = '1'
AND DATE(t.timestamp_datetime) < CURDATE()
GROUP BY u.id, u.email, u.vorname, u.nachname, DATE(t.timestamp_datetime)
ORDER BY error_date ASC, u.nachname ASC, u.vorname ASC"
);
$stmt->execute();
$entries = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
if (!isValidSequence((string)$row['day_sequence'])) {
$entries[] = $row;
}
}
return $entries;
}
}
if (!function_exists('timeErrorNotificationsGroupEntriesByEmployee')) {
function timeErrorNotificationsGroupEntriesByEmployee(array $entries): array
{
$grouped = [];
foreach ($entries as $entry) {
$employeeId = (int)$entry['employee_id'];
if (!isset($grouped[$employeeId])) {
$grouped[$employeeId] = [
'employee_id' => $employeeId,
'email' => (string)$entry['email'],
'vorname' => (string)$entry['vorname'],
'nachname' => (string)$entry['nachname'],
'first_error_date' => (string)$entry['error_date'],
'error_dates' => [],
];
}
$grouped[$employeeId]['error_dates'][] = (string)$entry['error_date'];
if ((string)$entry['error_date'] < $grouped[$employeeId]['first_error_date']) {
$grouped[$employeeId]['first_error_date'] = (string)$entry['error_date'];
}
}
foreach ($grouped as &$employee) {
$employee['error_dates'] = array_values(array_unique($employee['error_dates']));
sort($employee['error_dates']);
}
unset($employee);
return $grouped;
}
}
if (!function_exists('timeErrorNotificationsFetchAdminRecipients')) {
function timeErrorNotificationsFetchAdminRecipients(PDO $pdo): array
{
$stmt = $pdo->prepare(
"SELECT DISTINCT
u.id,
u.email,
u.vorname,
u.nachname
FROM users u
LEFT JOIN users_admin ua ON ua.userid = u.id
WHERE (u.admin = 1 OR ua.userid IS NOT NULL)
AND u.email IS NOT NULL
AND u.email <> ''
ORDER BY u.nachname ASC, u.vorname ASC"
);
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
}
if (!function_exists('timeErrorNotificationsFetchStateByEmployee')) {
function timeErrorNotificationsFetchStateByEmployee(PDO $pdo): array
{
timeErrorNotificationsEnsureTables($pdo);
$stmt = $pdo->prepare("SELECT * FROM time_error_notification_state");
$stmt->execute();
$states = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$states[(int)$row['employee_id']] = $row;
}
return $states;
}
}
if (!function_exists('timeErrorNotificationsUpsertState')) {
function timeErrorNotificationsUpsertState(PDO $pdo, array $employee, ?array $existingState): array
{
timeErrorNotificationsEnsureTables($pdo);
$cycleStartedOn = $existingState['cycle_started_on'] ?? $employee['first_error_date'];
$stmt = $pdo->prepare(
"INSERT INTO time_error_notification_state (
employee_id,
cycle_started_on,
first_error_date,
last_notification_stage,
last_notification_sent_at,
employee_day_1_sent_at,
employee_day_3_sent_at,
admin_day_7_sent_at
) VALUES (
:employee_id,
:cycle_started_on,
:first_error_date,
:last_notification_stage,
:last_notification_sent_at,
:employee_day_1_sent_at,
:employee_day_3_sent_at,
:admin_day_7_sent_at
)
ON DUPLICATE KEY UPDATE
cycle_started_on = VALUES(cycle_started_on),
first_error_date = VALUES(first_error_date),
last_notification_stage = VALUES(last_notification_stage),
last_notification_sent_at = VALUES(last_notification_sent_at),
employee_day_1_sent_at = VALUES(employee_day_1_sent_at),
employee_day_3_sent_at = VALUES(employee_day_3_sent_at),
admin_day_7_sent_at = VALUES(admin_day_7_sent_at)"
);
$stmt->execute([
'employee_id' => $employee['employee_id'],
'cycle_started_on' => $cycleStartedOn,
'first_error_date' => $employee['first_error_date'],
'last_notification_stage' => $existingState['last_notification_stage'] ?? null,
'last_notification_sent_at' => $existingState['last_notification_sent_at'] ?? null,
'employee_day_1_sent_at' => $existingState['employee_day_1_sent_at'] ?? null,
'employee_day_3_sent_at' => $existingState['employee_day_3_sent_at'] ?? null,
'admin_day_7_sent_at' => $existingState['admin_day_7_sent_at'] ?? null,
]);
return [
'employee_id' => $employee['employee_id'],
'cycle_started_on' => $cycleStartedOn,
'first_error_date' => $employee['first_error_date'],
'last_notification_stage' => $existingState['last_notification_stage'] ?? null,
'last_notification_sent_at' => $existingState['last_notification_sent_at'] ?? null,
'employee_day_1_sent_at' => $existingState['employee_day_1_sent_at'] ?? null,
'employee_day_3_sent_at' => $existingState['employee_day_3_sent_at'] ?? null,
'admin_day_7_sent_at' => $existingState['admin_day_7_sent_at'] ?? null,
];
}
}
if (!function_exists('timeErrorNotificationsClearResolvedStates')) {
function timeErrorNotificationsClearResolvedStates(PDO $pdo, array $openEmployeeIds): int
{
timeErrorNotificationsEnsureTables($pdo);
if (empty($openEmployeeIds)) {
$stmt = $pdo->prepare("DELETE FROM time_error_notification_state");
$stmt->execute();
return $stmt->rowCount();
}
$placeholders = implode(',', array_fill(0, count($openEmployeeIds), '?'));
$stmt = $pdo->prepare("DELETE FROM time_error_notification_state WHERE employee_id NOT IN ($placeholders)");
$stmt->execute(array_values($openEmployeeIds));
return $stmt->rowCount();
}
}
if (!function_exists('timeErrorNotificationsMarkStageSent')) {
function timeErrorNotificationsMarkStageSent(PDO $pdo, array $state, string $stage, string $recipientEmail): array
{
timeErrorNotificationsEnsureTables($pdo);
$stmt = $pdo->prepare(
"INSERT INTO time_error_notifications (employee_id, cycle_started_on, notification_stage, recipient_email)
VALUES (:employee_id, :cycle_started_on, :notification_stage, :recipient_email)
ON DUPLICATE KEY UPDATE sent_at = sent_at"
);
$stmt->execute([
'employee_id' => $state['employee_id'],
'cycle_started_on' => $state['cycle_started_on'],
'notification_stage' => $stage,
'recipient_email' => $recipientEmail,
]);
$now = (new DateTimeImmutable('now'))->format('Y-m-d H:i:s');
$state['last_notification_stage'] = $stage;
$state['last_notification_sent_at'] = $now;
if ($stage === 'employee_day_1') {
$state['employee_day_1_sent_at'] = $now;
} elseif ($stage === 'employee_day_3') {
$state['employee_day_3_sent_at'] = $now;
} elseif ($stage === 'admin_day_7') {
$state['admin_day_7_sent_at'] = $now;
}
$stmt = $pdo->prepare(
"UPDATE time_error_notification_state
SET last_notification_stage = :last_notification_stage,
last_notification_sent_at = :last_notification_sent_at,
employee_day_1_sent_at = :employee_day_1_sent_at,
employee_day_3_sent_at = :employee_day_3_sent_at,
admin_day_7_sent_at = :admin_day_7_sent_at
WHERE employee_id = :employee_id"
);
$stmt->execute([
'last_notification_stage' => $state['last_notification_stage'],
'last_notification_sent_at' => $state['last_notification_sent_at'],
'employee_day_1_sent_at' => $state['employee_day_1_sent_at'],
'employee_day_3_sent_at' => $state['employee_day_3_sent_at'],
'admin_day_7_sent_at' => $state['admin_day_7_sent_at'],
'employee_id' => $state['employee_id'],
]);
return $state;
}
}
if (!function_exists('timeErrorNotificationsDaysSinceDate')) {
function timeErrorNotificationsDaysSinceDate(string $date, ?DateTimeImmutable $today = null): int
{
$today = $today ?: new DateTimeImmutable('today');
$reference = new DateTimeImmutable(substr($date, 0, 10));
return (int)$reference->diff($today)->days;
}
}
if (!function_exists('timeErrorNotificationsFormatDateList')) {
function timeErrorNotificationsFormatDateList(array $errorDates): string
{
$labels = array_map(static function (string $date): string {
return date('d.m.Y', strtotime($date));
}, $errorDates);
return implode(', ', $labels);
}
}
if (!function_exists('timeErrorNotificationsBuildEmployeeMail')) {
function timeErrorNotificationsGetTrackingUrl(): string
{
if (!empty($_SERVER['HTTP_HOST'])) {
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https://' : 'http://';
return $protocol . $_SERVER['HTTP_HOST'] . '/zeiterfassung/';
}
return 'https://www.praxis-creutzburg.de/zeiterfassung/';
}
}
if (!function_exists('timeErrorNotificationsBuildEmployeeMail')) {
function timeErrorNotificationsBuildEmployeeMail(array $employee, string $stage): array
{
$name = trim($employee['vorname'] . ' ' . $employee['nachname']);
$dateList = timeErrorNotificationsFormatDateList($employee['error_dates']);
$trackingUrl = timeErrorNotificationsGetTrackingUrl();
$subject = ($stage === 'employee_day_3')
? 'Erneute Erinnerung zu offenen Zeitfehlern'
: 'Erinnerung zu offenen Zeitfehlern';
$body = '<p>Hallo ' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . ',</p>'
. '<p>in deiner Zeiterfassung gibt es weiterhin offene Buchungsfehler.</p>'
. '<p><strong>Betroffene Tage:</strong> ' . htmlspecialchars($dateList, ENT_QUOTES, 'UTF-8') . '</p>'
. '<p>Bitte korrigiere die Einträge in der Zeiterfassung. Solange die Fehler offen bleiben, wird der Vorgang weiter verfolgt.</p>'
. '<p><a href="' . htmlspecialchars($trackingUrl, ENT_QUOTES, 'UTF-8') . '">Zur Zeiterfassung</a></p>';
return [
'subject' => $subject,
'body' => $body,
];
}
}
if (!function_exists('timeErrorNotificationsBuildAdminMail')) {
function timeErrorNotificationsBuildAdminMail(array $employee): array
{
$employeeName = trim($employee['vorname'] . ' ' . $employee['nachname']);
$dateList = timeErrorNotificationsFormatDateList($employee['error_dates']);
$trackingUrl = timeErrorNotificationsGetTrackingUrl();
$subject = 'Eskalation: Offene Zeitfehler von ' . $employeeName;
$body = '<p>Bei einem Mitarbeiter bestehen weiterhin offene Zeitfehler.</p>'
. '<p><strong>Mitarbeiter:</strong> ' . htmlspecialchars($employeeName, ENT_QUOTES, 'UTF-8') . '<br>'
. '<strong>E-Mail:</strong> ' . htmlspecialchars($employee['email'], ENT_QUOTES, 'UTF-8') . '<br>'
. '<strong>Betroffene Tage:</strong> ' . htmlspecialchars($dateList, ENT_QUOTES, 'UTF-8') . '</p>'
. '<p>Bitte prüfen Sie die Zeiterfassung und stimmen Sie die Korrektur mit dem Mitarbeiter ab.</p>'
. '<p><a href="' . htmlspecialchars($trackingUrl, ENT_QUOTES, 'UTF-8') . '">Zur Zeiterfassung</a></p>';
return [
'subject' => $subject,
'body' => $body,
];
}
}
if (!function_exists('timeErrorNotificationsProcess')) {
function timeErrorNotificationsProcess(PDO $pdo, ?DateTimeImmutable $today = null): array
{
$today = $today ?: new DateTimeImmutable('today');
timeErrorNotificationsEnsureTables($pdo);
$results = [
'processed_errors' => 0,
'affected_employees' => 0,
'employee_day_1_sent' => 0,
'employee_day_3_sent' => 0,
'admin_day_7_sent' => 0,
'resolved_states_cleared' => 0,
'skipped_missing_employee_email' => 0,
'skipped_missing_admin_email' => 0,
'failed' => [],
];
$entries = timeErrorNotificationsFetchInvalidEntries($pdo);
$employees = timeErrorNotificationsGroupEntriesByEmployee($entries);
$adminRecipients = timeErrorNotificationsFetchAdminRecipients($pdo);
$states = timeErrorNotificationsFetchStateByEmployee($pdo);
$results['processed_errors'] = count($entries);
$results['affected_employees'] = count($employees);
$results['resolved_states_cleared'] = timeErrorNotificationsClearResolvedStates($pdo, array_keys($employees));
foreach ($employees as $employeeId => $employee) {
$state = timeErrorNotificationsUpsertState($pdo, $employee, $states[$employeeId] ?? null);
$daysSinceCycleStart = timeErrorNotificationsDaysSinceDate($state['cycle_started_on'], $today);
$employeeEmail = trim($employee['email']);
if ($state['employee_day_1_sent_at'] === null && $daysSinceCycleStart >= 1) {
if ($employeeEmail === '') {
$results['skipped_missing_employee_email']++;
continue;
}
$mail = timeErrorNotificationsBuildEmployeeMail($employee, 'employee_day_1');
if (timeErrorNotificationsSendMail($pdo, $employeeEmail, $mail['subject'], $mail['body'])) {
$state = timeErrorNotificationsMarkStageSent($pdo, $state, 'employee_day_1', $employeeEmail);
$results['employee_day_1_sent']++;
} else {
$results['failed'][] = [
'stage' => 'employee_day_1',
'employee_id' => $employeeId,
'recipient_email' => $employeeEmail,
];
}
continue;
}
if ($state['employee_day_3_sent_at'] === null && $daysSinceCycleStart >= 3) {
if ($employeeEmail === '') {
$results['skipped_missing_employee_email']++;
continue;
}
$mail = timeErrorNotificationsBuildEmployeeMail($employee, 'employee_day_3');
if (timeErrorNotificationsSendMail($pdo, $employeeEmail, $mail['subject'], $mail['body'])) {
$state = timeErrorNotificationsMarkStageSent($pdo, $state, 'employee_day_3', $employeeEmail);
$results['employee_day_3_sent']++;
} else {
$results['failed'][] = [
'stage' => 'employee_day_3',
'employee_id' => $employeeId,
'recipient_email' => $employeeEmail,
];
}
continue;
}
if ($state['admin_day_7_sent_at'] !== null || empty($state['last_notification_sent_at'])) {
continue;
}
$daysSinceLastNotification = timeErrorNotificationsDaysSinceDate($state['last_notification_sent_at'], $today);
if ($daysSinceLastNotification < 7) {
continue;
}
if (empty($adminRecipients)) {
$results['skipped_missing_admin_email']++;
continue;
}
foreach ($adminRecipients as $adminRecipient) {
$adminEmail = trim((string)$adminRecipient['email']);
if ($adminEmail === '') {
$results['skipped_missing_admin_email']++;
continue;
}
$mail = timeErrorNotificationsBuildAdminMail($employee);
if (timeErrorNotificationsSendMail($pdo, $adminEmail, $mail['subject'], $mail['body'])) {
$state = timeErrorNotificationsMarkStageSent($pdo, $state, 'admin_day_7', $adminEmail);
$results['admin_day_7_sent']++;
} else {
$results['failed'][] = [
'stage' => 'admin_day_7',
'employee_id' => $employeeId,
'recipient_email' => $adminEmail,
];
}
}
}
return $results;
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
if (!function_exists('vacationAbsenceTableExists')) {
function vacationAbsenceTableExists(PDO $pdo, string $table): bool
{
$stmt = $pdo->prepare(
"SELECT COUNT(*)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = :table_name"
);
$stmt->execute(['table_name' => $table]);
return (int)$stmt->fetchColumn() > 0;
}
}
if (!function_exists('vacationAbsenceTableHasColumn')) {
function vacationAbsenceTableHasColumn(PDO $pdo, string $table, string $column): bool
{
$stmt = $pdo->prepare(
"SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = :table_name
AND COLUMN_NAME = :column_name"
);
$stmt->execute([
'table_name' => $table,
'column_name' => $column,
]);
return (int)$stmt->fetchColumn() > 0;
}
}
if (!function_exists('vacationAbsenceReasonOptions')) {
function vacationAbsenceReasonOptions(): array
{
return [
'urlaub' => 'Urlaub',
'krankheit_mit_atest' => 'Krankheit mit Attest',
'krankheit_ohne_atest' => 'Krankheit ohne Attest',
'berufsschule' => 'Berufsschule',
'weiterbildung' => 'Weiterbildung',
'persoenliche_gruende' => 'Persoenliche Gründe',
'sonstiges' => 'Sonstiges',
];
}
}
if (!function_exists('vacationAbsenceDefaultReason')) {
function vacationAbsenceDefaultReason(): string
{
return 'urlaub';
}
}
if (!function_exists('vacationAbsenceNormalizeReason')) {
function vacationAbsenceNormalizeReason($reason): string
{
$reason = trim((string)$reason);
$options = vacationAbsenceReasonOptions();
return array_key_exists($reason, $options) ? $reason : vacationAbsenceDefaultReason();
}
}
if (!function_exists('vacationAbsenceReasonLabel')) {
function vacationAbsenceReasonLabel($reason): string
{
$reason = vacationAbsenceNormalizeReason($reason);
$options = vacationAbsenceReasonOptions();
return $options[$reason] ?? $options[vacationAbsenceDefaultReason()];
}
}
if (!function_exists('vacationAbsenceCountsAgainstEntitlement')) {
function vacationAbsenceCountsAgainstEntitlement($reason): bool
{
return vacationAbsenceNormalizeReason($reason) === 'urlaub';
}
}
if (!function_exists('vacationAbsenceEnsureSchema')) {
function vacationAbsenceEnsureSchema(PDO $pdo): void
{
if (!vacationAbsenceTableExists($pdo, 'vacations')) {
return;
}
if (!vacationAbsenceTableHasColumn($pdo, 'vacations', 'absence_reason')) {
$pdo->exec("ALTER TABLE vacations ADD COLUMN absence_reason VARCHAR(50) NOT NULL DEFAULT 'urlaub'");
}
$pdo->exec("
UPDATE vacations
SET absence_reason = 'urlaub'
WHERE absence_reason IS NULL OR TRIM(absence_reason) = ''
");
}
}
+43 -12
View File
@@ -4,21 +4,33 @@ require_once('inc/config.inc.php');
require_once('inc/functions.inc.php');
$user = check_user();
// personal calendar available to any logged-in user
include 'header.php';
?>
<div class="container">
<h2>Mein Urlaubskalender</h2>
<h2>Mein Abwesenheitskalender</h2>
<p>Hier werden alle persönlichen Abwesenheiten angezeigt, inklusive Urlaub, Krankheit, Schule und Weiterbildung. Betriebsurlaub wird ebenfalls mit eingeblendet.</p>
<div id="calendar"></div>
<br>
<div>
<span class="badge badge-success">genehmigt</span>
<span class="badge badge-warning">beantragt</span>
<div class="mb-3">
<strong>Abwesenheitsarten:</strong><br>
<span class="badge badge-success">Urlaub</span>
<span class="badge badge-danger">Krankheit mit Attest</span>
<span class="badge badge-dark">Krankheit ohne Attest</span>
<span class="badge badge-primary">Berufsschule</span>
<span class="badge badge-info">Weiterbildung</span>
<span class="badge badge-warning">Persönliche Gründe</span>
<span class="badge badge-secondary">Sonstiges</span>
<span class="badge badge-primary">Betriebsurlaub</span>
</div>
<br>
<div class="mb-3">
<strong>Status:</strong><br>
<span class="badge badge-success">Genehmigt</span>
<span class="badge badge-warning">Beantragt</span>
<span class="badge badge-secondary">Abgelehnt</span>
</div>
<div id="eventDetails" style="display:none;">
<h4>Details</h4>
<div id="detailsContent"></div>
@@ -31,24 +43,43 @@ include 'header.php';
document.addEventListener('DOMContentLoaded', function() {
var calendarEl = document.getElementById('calendar');
function formatAllDayRange(start, end) {
var startLabel = start.toLocaleDateString('de-DE');
if (!end) {
return startLabel;
}
var inclusiveEnd = new Date(end.getTime() - 24 * 60 * 60 * 1000);
return startLabel + ' - ' + inclusiveEnd.toLocaleDateString('de-DE');
}
var calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'dayGridMonth',
firstDay: 1,
height: 650,
events: function(info, successCallback, failureCallback) {
var url = 'api/vacations.php?start=' + info.startStr + '&end=' + info.endStr + '&only_personal=1';
fetch(url).then(function(res){ return res.json(); }).then(function(data){ successCallback(data); }).catch(function(err){ failureCallback(err); });
var url = 'api/vacations.php?start=' + info.startStr + '&end=' + info.endStr + '&scope=personal&include_company=1&status_filter=all';
fetch(url)
.then(function(res){ return res.json(); })
.then(function(data){ successCallback(data); })
.catch(function(err){ failureCallback(err); });
},
eventClick: function(info) {
var ev = info.event;
var props = ev.extendedProps;
var html = '<strong>' + ev.title + '</strong><br>' + ev.start.toLocaleDateString() + ' - ' + (new Date(ev.end).toLocaleDateString()) + '<br>';
var props = ev.extendedProps || {};
var html = '<strong>' + ev.title + '</strong><br>' +
formatAllDayRange(ev.start, ev.end) + '<br>';
if (props.type === 'user') {
html += 'Status: ' + (props.status || '') + '<br>';
html += 'Kommentar: ' + (props.comment || '') + '<br>';
html += 'Abwesenheitsgrund: ' + (props.absence_label || props.absence_type || '') + '<br>';
html += 'Status: ' + (props.status_label || props.status || '') + '<br>';
if (props.comment) {
html += 'Kommentar: ' + props.comment + '<br>';
}
} else if (props.type === 'company') {
html += 'Beschreibung: ' + (props.description || '') + '<br>';
}
document.getElementById('detailsContent').innerHTML = html;
document.getElementById('eventDetails').style.display = 'block';
}
@@ -0,0 +1,54 @@
<?php
session_start();
require_once __DIR__ . '/inc/config.inc.php';
require_once __DIR__ . '/inc/functions.inc.php';
require_once __DIR__ . '/inc/time_error_notifications.inc.php';
$isCli = PHP_SAPI === 'cli';
if (!$isCli) {
$user = check_user();
if (!$user || !is_admin_user()) {
http_response_code(403);
exit('Keine Berechtigung.');
}
}
$result = timeErrorNotificationsProcess($pdo);
if ($isCli) {
echo "Zeiterfassungs-Benachrichtigungen ausgefuehrt\n";
echo "Fehlerhafte Tage geprueft: " . $result['processed_errors'] . "\n";
echo "Betroffene Mitarbeiter: " . $result['affected_employees'] . "\n";
echo "Mitarbeiter-Erinnerungen Tag 1: " . $result['employee_day_1_sent'] . "\n";
echo "Mitarbeiter-Erinnerungen Tag 3: " . $result['employee_day_3_sent'] . "\n";
echo "Admin-Benachrichtigungen Tag 7: " . $result['admin_day_7_sent'] . "\n";
echo "Bereinigte geloeste Status: " . $result['resolved_states_cleared'] . "\n";
echo "Ohne Mitarbeiter-E-Mail uebersprungen: " . $result['skipped_missing_employee_email'] . "\n";
echo "Ohne Admin-E-Mail uebersprungen: " . $result['skipped_missing_admin_email'] . "\n";
echo "Fehlgeschlagene Sendungen: " . count($result['failed']) . "\n";
exit(0);
}
include __DIR__ . '/header.php';
?>
<div class="container">
<div class="row">
<div class="col-md-12">
<h2>Zeiterfassungs-Benachrichtigungen</h2>
<div class="alert alert-info" role="alert">
<strong>Gepruefte fehlerhafte Tage:</strong> <?php echo (int)$result['processed_errors']; ?><br>
<strong>Betroffene Mitarbeiter:</strong> <?php echo (int)$result['affected_employees']; ?><br>
<strong>Mitarbeiter-Erinnerungen Tag 1:</strong> <?php echo (int)$result['employee_day_1_sent']; ?><br>
<strong>Mitarbeiter-Erinnerungen Tag 3:</strong> <?php echo (int)$result['employee_day_3_sent']; ?><br>
<strong>Admin-Benachrichtigungen Tag 7:</strong> <?php echo (int)$result['admin_day_7_sent']; ?><br>
<strong>Bereinigte geloeste Status:</strong> <?php echo (int)$result['resolved_states_cleared']; ?><br>
<strong>Ohne Mitarbeiter-E-Mail uebersprungen:</strong> <?php echo (int)$result['skipped_missing_employee_email']; ?><br>
<strong>Ohne Admin-E-Mail uebersprungen:</strong> <?php echo (int)$result['skipped_missing_admin_email']; ?><br>
<strong>Fehlgeschlagene Sendungen:</strong> <?php echo count($result['failed']); ?>
</div>
</div>
</div>
</div>
<?php include __DIR__ . '/footer.php'; ?>
+13 -9
View File
@@ -2,8 +2,10 @@
session_start();
require_once("inc/config.inc.php");
require_once("inc/functions.inc.php");
require_once("inc/vacation_absence.inc.php");
$user = check_user();
vacationAbsenceEnsureSchema($pdo);
if (!isset($_SESSION['userid'])) {
die("Kein Benutzer angemeldet.");
@@ -21,7 +23,7 @@ if ($_SERVER["REQUEST_METHOD"] == "POST" && isset($_POST['vacation_id'])) {
$vacation_id = (int)$_POST['vacation_id'];
$action = $_POST['action'];
$comment_admin = trim($_POST['comment_admin']);
$comment_admin = trim((string)($_POST['comment_admin'] ?? ''));
if ($action == "genehmigen") {
$status = "genehmigt";
@@ -61,10 +63,10 @@ $antraege = $stmt->fetchAll();
<div class="row">
<div class="col-md-12">
<h2>Urlaubsanträge genehmigen</h2>
<h2>Abwesenheitsanträge genehmigen</h2>
<?php if ($message): ?>
<div class="alert alert-success"><?php echo $message; ?></div>
<div class="alert alert-success"><?php echo htmlspecialchars($message); ?></div>
<?php endif; ?>
<?php if (count($antraege) == 0): ?>
@@ -74,6 +76,7 @@ $antraege = $stmt->fetchAll();
<table class="table table-bordered table-striped">
<tr>
<th>Mitarbeiter</th>
<th>Grund</th>
<th>Von</th>
<th>Bis</th>
<th>Tage</th>
@@ -84,14 +87,15 @@ $antraege = $stmt->fetchAll();
<?php foreach ($antraege as $a): ?>
<tr>
<td><?php echo htmlspecialchars($a['vorname'] . " " . $a['nachname']); ?></td>
<td><?php echo $a['start_date']; ?></td>
<td><?php echo $a['end_date']; ?></td>
<td><?php echo $a['days']; ?></td>
<td><?php echo htmlspecialchars($a['comment_user']); ?></td>
<td><?php echo htmlspecialchars(vacationAbsenceReasonLabel($a['absence_reason'] ?? 'urlaub')); ?></td>
<td><?php echo htmlspecialchars((string)$a['start_date']); ?></td>
<td><?php echo htmlspecialchars((string)$a['end_date']); ?></td>
<td><?php echo (int)$a['days']; ?></td>
<td><?php echo htmlspecialchars((string)($a['comment_user'] ?? '')); ?></td>
<td>
<form method="post" style="display:inline;">
<input type="hidden" name="vacation_id" value="<?php echo $a['id']; ?>">
<input type="hidden" name="vacation_id" value="<?php echo (int)$a['id']; ?>">
<input type="hidden" name="action" value="genehmigen">
<button type="submit" class="btn btn-success btn-sm">
Genehmigen
@@ -99,7 +103,7 @@ $antraege = $stmt->fetchAll();
</form>
<form method="post" style="display:inline;">
<input type="hidden" name="vacation_id" value="<?php echo $a['id']; ?>">
<input type="hidden" name="vacation_id" value="<?php echo (int)$a['id']; ?>">
<input type="hidden" name="action" value="ablehnen">
<button type="submit" class="btn btn-danger btn-sm">
Ablehnen
+37 -11
View File
@@ -2,8 +2,10 @@
session_start();
require_once("inc/config.inc.php");
require_once("inc/functions.inc.php");
require_once("inc/vacation_absence.inc.php");
$user = check_user();
vacationAbsenceEnsureSchema($pdo);
if (!isset($_SESSION['userid'])) {
die("Kein Benutzer angemeldet.");
@@ -14,6 +16,10 @@ $canManageTeamVacations = can_manage_team_vacations();
$message = "";
$error = "";
$selected_user_id = $user_id;
$selected_absence_reason = vacationAbsenceDefaultReason();
$start_date = '';
$end_date = '';
$comment = '';
$selectableUsers = [];
if ($canManageTeamVacations) {
@@ -49,6 +55,7 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
$start_date = trim((string)($_POST['start_date'] ?? ''));
$end_date = trim((string)($_POST['end_date'] ?? ''));
$comment = trim((string)($_POST['comment'] ?? ''));
$selected_absence_reason = vacationAbsenceNormalizeReason($_POST['absence_reason'] ?? vacationAbsenceDefaultReason());
$selected_user_id = $canManageTeamVacations ? (int)($_POST['user_id'] ?? $user_id) : $user_id;
$selectedUser = null;
@@ -74,7 +81,7 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
$error = "Bitte beide Datumsfelder ausfuellen.";
} elseif ($error === "" && $start_date > $end_date) {
$error = "Enddatum liegt vor dem Startdatum.";
} elseif ($error === "" && $start_date < date("Y-m-d")) {
} elseif ($error === "" && vacationAbsenceCountsAgainstEntitlement($selected_absence_reason) && $start_date < date("Y-m-d")) {
$error = "Urlaub kann nicht in der Vergangenheit beantragt werden.";
} elseif ($error === "") {
$stmt = $pdo->prepare("
@@ -97,15 +104,20 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
$days = calculateWorkingDays($start_date, $end_date);
$insert = $pdo->prepare("
INSERT INTO vacations (user_id, start_date, end_date, days, comment_user)
VALUES (?, ?, ?, ?, ?)
INSERT INTO vacations (user_id, start_date, end_date, days, comment_user, absence_reason)
VALUES (?, ?, ?, ?, ?, ?)
");
$insert->execute([$selected_user_id, $start_date, $end_date, $days, $comment]);
$insert->execute([$selected_user_id, $start_date, $end_date, $days, $comment, $selected_absence_reason]);
$reasonLabel = vacationAbsenceReasonLabel($selected_absence_reason);
if ($selected_user_id !== $user_id && $selectedUser) {
$message = "Urlaub fuer " . $selectedUser['vorname'] . " " . $selectedUser['nachname'] . " erfolgreich eingereicht ($days Werktage).";
$message = "Abwesenheit fuer " . $selectedUser['vorname'] . " " . $selectedUser['nachname'] . " erfolgreich eingetragen ($days Werktage, Grund: " . $reasonLabel . ").";
} else {
if ($selected_absence_reason === 'urlaub') {
$message = "Urlaubsantrag erfolgreich eingereicht ($days Werktage).";
} else {
$message = "Abwesenheitsantrag erfolgreich eingereicht ($days Werktage, Grund: " . $reasonLabel . ").";
}
}
}
}
@@ -118,7 +130,7 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
<div class="row">
<div class="col-md-8 offset-md-2">
<h2>Urlaubsantrag</h2>
<h2>Abwesenheitsantrag</h2>
<?php if ($error): ?>
<div class="alert alert-danger"><?php echo htmlspecialchars($error); ?></div>
@@ -144,32 +156,44 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
</div>
<?php endif; ?>
<div class="form-group">
<label>Abwesenheitsgrund:</label>
<select name="absence_reason" class="form-control" required>
<?php foreach (vacationAbsenceReasonOptions() as $reasonKey => $reasonLabel): ?>
<option value="<?php echo htmlspecialchars($reasonKey); ?>" <?php echo ($selected_absence_reason === $reasonKey) ? 'selected' : ''; ?>>
<?php echo htmlspecialchars($reasonLabel); ?>
</option>
<?php endforeach; ?>
</select>
<small class="form-text text-muted">Nur Urlaub wird auf das Urlaubskontingent angerechnet.</small>
</div>
<div class="form-group">
<label>Von:</label>
<input type="date" name="start_date" class="form-control" required>
<input type="date" name="start_date" class="form-control" value="<?php echo htmlspecialchars($start_date); ?>" required>
</div>
<div class="form-group">
<label>Bis:</label>
<input type="date" name="end_date" class="form-control" required>
<input type="date" name="end_date" class="form-control" value="<?php echo htmlspecialchars($end_date); ?>" required>
</div>
<div class="form-group">
<label>Kommentar (optional):</label>
<textarea name="comment" class="form-control"></textarea>
<textarea name="comment" class="form-control"><?php echo htmlspecialchars($comment); ?></textarea>
</div>
<br>
<button type="submit" class="btn btn-primary btn-block">
<?php echo $canManageTeamVacations ? 'Urlaub eintragen' : 'Urlaub beantragen'; ?>
<?php echo $canManageTeamVacations ? 'Abwesenheit eintragen' : 'Abwesenheit beantragen'; ?>
</button>
</form>
<hr>
<h4><?php echo $canManageTeamVacations ? 'Urlaubseintraege' : 'Meine Antraege'; ?></h4>
<h4><?php echo $canManageTeamVacations ? 'Abwesenheitseintraege' : 'Meine Antraege'; ?></h4>
<?php
$listSql = "
@@ -196,6 +220,7 @@ $antraege = $stmt->fetchAll(PDO::FETCH_ASSOC);
<?php if ($canManageTeamVacations): ?>
<th>Mitarbeiter</th>
<?php endif; ?>
<th>Grund</th>
<th>Von</th>
<th>Bis</th>
<th>Tage</th>
@@ -208,6 +233,7 @@ $antraege = $stmt->fetchAll(PDO::FETCH_ASSOC);
<?php if ($canManageTeamVacations): ?>
<td><?php echo htmlspecialchars(trim($a['vorname'] . ' ' . $a['nachname'])); ?></td>
<?php endif; ?>
<td><?php echo htmlspecialchars(vacationAbsenceReasonLabel($a['absence_reason'] ?? 'urlaub')); ?></td>
<td><?php echo htmlspecialchars((string)$a['start_date']); ?></td>
<td><?php echo htmlspecialchars((string)$a['end_date']); ?></td>
<td><?php echo (int)$a['days']; ?></td>
+36 -10
View File
@@ -13,14 +13,21 @@ include 'header.php';
<div class="container">
<h2>Team Urlaubskalender</h2>
<p>Admin-Sicht auf den Teamkalender. Angezeigt werden nur Urlaub von Personen sowie Betriebsurlaub.</p>
<div id="calendar"></div>
<br>
<div>
<span class="badge badge-success">genehmigt</span>
<span class="badge badge-warning">beantragt</span>
<div class="mb-3">
<strong>Anzeige:</strong><br>
<span class="badge badge-success">Urlaub</span>
<span class="badge badge-primary">Betriebsurlaub</span>
</div>
<br>
<div class="mb-3">
<strong>Status:</strong><br>
<span class="badge badge-success">Genehmigt</span>
<span class="badge badge-warning">Beantragt</span>
<span class="badge badge-secondary">Abgelehnt</span>
</div>
<div id="eventDetails" style="display:none;">
<h4>Details</h4>
<div id="detailsContent"></div>
@@ -33,25 +40,44 @@ include 'header.php';
document.addEventListener('DOMContentLoaded', function() {
var calendarEl = document.getElementById('calendar');
function formatAllDayRange(start, end) {
var startLabel = start.toLocaleDateString('de-DE');
if (!end) {
return startLabel;
}
var inclusiveEnd = new Date(end.getTime() - 24 * 60 * 60 * 1000);
return startLabel + ' - ' + inclusiveEnd.toLocaleDateString('de-DE');
}
var calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'dayGridMonth',
firstDay: 1,
height: 650,
events: function(info, successCallback, failureCallback) {
var url = 'api/vacations.php?start=' + info.startStr + '&end=' + info.endStr;
fetch(url).then(function(res){ return res.json(); }).then(function(data){ successCallback(data); }).catch(function(err){ failureCallback(err); });
var url = 'api/vacations.php?start=' + info.startStr + '&end=' + info.endStr + '&scope=team&include_company=1&status_filter=active';
fetch(url)
.then(function(res){ return res.json(); })
.then(function(data){ successCallback(data); })
.catch(function(err){ failureCallback(err); });
},
eventClick: function(info) {
var ev = info.event;
var props = ev.extendedProps;
var html = '<strong>' + ev.title + '</strong><br>' + ev.start.toLocaleDateString() + ' - ' + (new Date(ev.end).toLocaleDateString()) + '<br>';
var props = ev.extendedProps || {};
var html = '<strong>' + ev.title + '</strong><br>' +
formatAllDayRange(ev.start, ev.end) + '<br>';
if (props.type === 'user') {
html += 'Mitarbeiter: ' + (props.employee_name || '') + '<br>';
html += 'Status: ' + (props.status || '') + '<br>';
html += 'Kommentar: ' + (props.comment || '') + '<br>';
html += 'Abwesenheitsgrund: ' + (props.absence_label || props.absence_type || '') + '<br>';
html += 'Status: ' + (props.status_label || props.status || '') + '<br>';
if (props.comment) {
html += 'Kommentar: ' + props.comment + '<br>';
}
} else if (props.type === 'company') {
html += 'Beschreibung: ' + (props.description || '') + '<br>';
}
document.getElementById('detailsContent').innerHTML = html;
document.getElementById('eventDetails').style.display = 'block';
}
+34 -10
View File
@@ -10,14 +10,21 @@ include 'header.php';
<div class="container">
<h2>Team Urlaubskalender</h2>
<p>Dieser Kalender zeigt nur Urlaub von Personen sowie Betriebsurlaub. Andere Abwesenheitsarten werden hier bewusst ausgeblendet.</p>
<div id="calendar"></div>
<br>
<div>
<span class="badge badge-success">genehmigt</span>
<span class="badge badge-warning">beantragt</span>
<div class="mb-3">
<strong>Anzeige:</strong><br>
<span class="badge badge-success">Urlaub</span>
<span class="badge badge-primary">Betriebsurlaub</span>
</div>
<br>
<div class="mb-3">
<strong>Status:</strong><br>
<span class="badge badge-success">Genehmigt</span>
<span class="badge badge-warning">Beantragt</span>
<span class="badge badge-secondary">Abgelehnt</span>
</div>
<div id="eventDetails" style="display:none;">
<h4>Details</h4>
<div id="detailsContent"></div>
@@ -30,27 +37,44 @@ include 'header.php';
document.addEventListener('DOMContentLoaded', function() {
var calendarEl = document.getElementById('calendar');
function formatAllDayRange(start, end) {
var startLabel = start.toLocaleDateString('de-DE');
if (!end) {
return startLabel;
}
var inclusiveEnd = new Date(end.getTime() - 24 * 60 * 60 * 1000);
return startLabel + ' - ' + inclusiveEnd.toLocaleDateString('de-DE');
}
var calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'dayGridMonth',
firstDay: 1,
height: 650,
events: function(info, successCallback, failureCallback) {
var url = 'api/vacations.php?start=' + info.startStr + '&end=' + info.endStr + '&public=1&public_all=1';
fetch(url).then(function(res){ return res.json(); }).then(function(data){ successCallback(data); }).catch(function(err){ failureCallback(err); });
var url = 'api/vacations.php?start=' + info.startStr + '&end=' + info.endStr + '&scope=team&include_company=1&status_filter=active';
fetch(url)
.then(function(res){ return res.json(); })
.then(function(data){ successCallback(data); })
.catch(function(err){ failureCallback(err); });
},
eventClick: function(info) {
var ev = info.event;
var props = ev.extendedProps;
var html = '<strong>' + ev.title + '</strong><br>' + ev.start.toLocaleDateString() + ' - ' + (new Date(ev.end).toLocaleDateString()) + '<br>';
var props = ev.extendedProps || {};
var html = '<strong>' + ev.title + '</strong><br>' +
formatAllDayRange(ev.start, ev.end) + '<br>';
if (props.type === 'user') {
html += 'Mitarbeiter: ' + (props.employee_name || ev.title || '') + '<br>';
html += 'Status: ' + (props.status || '') + '<br>';
html += 'Mitarbeiter: ' + (props.employee_name || '') + '<br>';
html += 'Abwesenheitsgrund: ' + (props.absence_label || props.absence_type || '') + '<br>';
html += 'Status: ' + (props.status_label || props.status || '') + '<br>';
if (props.comment) {
html += 'Kommentar: ' + props.comment + '<br>';
}
} else if (props.type === 'company') {
html += 'Beschreibung: ' + (props.description || '') + '<br>';
}
document.getElementById('detailsContent').innerHTML = html;
document.getElementById('eventDetails').style.display = 'block';
}
+136 -22
View File
@@ -2,60 +2,167 @@
session_start();
require_once('inc/config.inc.php');
require_once('inc/functions.inc.php');
require_once __DIR__ . '/inc/vacation_absence.inc.php';
$user = check_user();
if (!is_admin_user()) {
die('Zugriff verweigert. Nur Chefs dürfen die Urlaubsübersicht sehen.');
die('Zugriff verweigert. Nur Chefs dürfen die Abwesenheitsübersicht sehen.');
}
$schemaWarning = '';
try {
vacationAbsenceEnsureSchema($pdo);
} catch (Throwable $e) {
$schemaWarning = 'Das Abwesenheitsschema konnte nicht automatisch aktualisiert werden: ' . $e->getMessage();
}
include 'header.php';
// Jahr für Auswertung
$year = date('Y');
$yearStart = $year . '-01-01';
$nextYearStart = (string)((int)$year + 1) . '-01-01';
$reasonOptions = vacationAbsenceReasonOptions();
$reasonKeys = array_keys($reasonOptions);
function vacationOverviewStatusLabel(?string $status): string
{
$status = trim((string)$status);
if ($status === '' || $status === 'beantragt') {
return 'Beantragt';
}
if ($status === 'genehmigt') {
return 'Genehmigt';
}
if ($status === 'abgelehnt') {
return 'Abgelehnt';
}
return ucfirst($status);
}
// Lade alle Mitarbeiter
$stmt = $pdo->prepare("SELECT id, vorname, nachname, email, urlaubstage FROM users ORDER BY nachname, vorname");
$stmt->execute();
$users = $stmt->fetchAll();
$statsStmt = $pdo->prepare("
SELECT
user_id,
COALESCE(SUM(CASE WHEN LOWER(TRIM(COALESCE(absence_reason, 'urlaub'))) = 'urlaub' AND LOWER(TRIM(COALESCE(status, ''))) = 'genehmigt' THEN COALESCE(days, 0) ELSE 0 END), 0) AS urlaub_used_days,
COALESCE(SUM(CASE WHEN LOWER(TRIM(COALESCE(status, ''))) != 'abgelehnt' THEN COALESCE(days, 0) ELSE 0 END), 0) AS total_absence_days,
COALESCE(SUM(CASE WHEN LOWER(TRIM(COALESCE(status, ''))) = 'beantragt' OR TRIM(COALESCE(status, '')) = '' THEN 1 ELSE 0 END), 0) AS pending_count,
COALESCE(SUM(CASE WHEN LOWER(TRIM(COALESCE(absence_reason, 'urlaub'))) = 'urlaub' AND LOWER(TRIM(COALESCE(status, ''))) != 'abgelehnt' THEN COALESCE(days, 0) ELSE 0 END), 0) AS urlaub_total_days,
COALESCE(SUM(CASE WHEN LOWER(TRIM(COALESCE(absence_reason, 'urlaub'))) = 'krankheit_mit_atest' AND LOWER(TRIM(COALESCE(status, ''))) != 'abgelehnt' THEN COALESCE(days, 0) ELSE 0 END), 0) AS krankheit_mit_atest_days,
COALESCE(SUM(CASE WHEN LOWER(TRIM(COALESCE(absence_reason, 'urlaub'))) = 'krankheit_ohne_atest' AND LOWER(TRIM(COALESCE(status, ''))) != 'abgelehnt' THEN COALESCE(days, 0) ELSE 0 END), 0) AS krankheit_ohne_atest_days,
COALESCE(SUM(CASE WHEN LOWER(TRIM(COALESCE(absence_reason, 'urlaub'))) = 'berufsschule' AND LOWER(TRIM(COALESCE(status, ''))) != 'abgelehnt' THEN COALESCE(days, 0) ELSE 0 END), 0) AS berufsschule_days,
COALESCE(SUM(CASE WHEN LOWER(TRIM(COALESCE(absence_reason, 'urlaub'))) = 'weiterbildung' AND LOWER(TRIM(COALESCE(status, ''))) != 'abgelehnt' THEN COALESCE(days, 0) ELSE 0 END), 0) AS weiterbildung_days,
COALESCE(SUM(CASE WHEN LOWER(TRIM(COALESCE(absence_reason, 'urlaub'))) = 'persoenliche_gruende' AND LOWER(TRIM(COALESCE(status, ''))) != 'abgelehnt' THEN COALESCE(days, 0) ELSE 0 END), 0) AS persoenliche_gruende_days,
COALESCE(SUM(CASE WHEN LOWER(TRIM(COALESCE(absence_reason, 'urlaub'))) = 'sonstiges' AND LOWER(TRIM(COALESCE(status, ''))) != 'abgelehnt' THEN COALESCE(days, 0) ELSE 0 END), 0) AS sonstiges_days
FROM vacations
WHERE start_date >= :year_start
AND start_date < :next_year_start
GROUP BY user_id
");
$statsStmt->execute([
'year_start' => $yearStart,
'next_year_start' => $nextYearStart,
]);
$statsRows = $statsStmt->fetchAll(PDO::FETCH_ASSOC);
$statsByUser = [];
foreach ($statsRows as $row) {
$statsByUser[(int)$row['user_id']] = $row;
}
$upcomingStmt = $pdo->prepare("
SELECT
v.user_id,
v.start_date,
v.end_date,
v.days,
v.status,
v.absence_reason,
u.vorname,
u.nachname
FROM vacations v
JOIN users u ON u.id = v.user_id
WHERE v.end_date >= CURDATE()
AND LOWER(TRIM(COALESCE(v.status, ''))) != 'abgelehnt'
ORDER BY v.start_date ASC, v.end_date ASC
");
$upcomingStmt->execute();
$upcomingRows = $upcomingStmt->fetchAll(PDO::FETCH_ASSOC);
$upcomingByUser = [];
foreach ($upcomingRows as $row) {
$uid = (int)$row['user_id'];
if (!isset($upcomingByUser[$uid])) {
$upcomingByUser[$uid] = [];
}
if (count($upcomingByUser[$uid]) < 5) {
$upcomingByUser[$uid][] = $row;
}
}
function vacationOverviewReasonValue(array $statsByUser, int $userId, string $key): int
{
$map = [
'urlaub' => 'urlaub_total_days',
'krankheit_mit_atest' => 'krankheit_mit_atest_days',
'krankheit_ohne_atest' => 'krankheit_ohne_atest_days',
'berufsschule' => 'berufsschule_days',
'weiterbildung' => 'weiterbildung_days',
'persoenliche_gruende' => 'persoenliche_gruende_days',
'sonstiges' => 'sonstiges_days',
];
$field = $map[$key] ?? null;
if ($field === null || !isset($statsByUser[$userId])) {
return 0;
}
return (int)($statsByUser[$userId][$field] ?? 0);
}
?>
<div class="container">
<h2>Urlaubsübersicht (<?php echo $year; ?>)</h2>
<h2>Abwesenheitsübersicht (<?php echo $year; ?>)</h2>
<?php if (!empty($schemaWarning)): ?>
<div class="alert alert-warning"><?php echo htmlspecialchars($schemaWarning); ?></div>
<?php endif; ?>
<div class="alert alert-info">
<strong>Hinweis:</strong> Nur der Grund <em>Urlaub</em> zählt auf den Urlaubsanspruch. Alle anderen Abwesenheitsgründe werden hier zusätzlich pro Jahr ausgewertet.
</div>
<div class="table-responsive">
<table class="table table-striped table-bordered">
<thead>
<tr>
<th>Mitarbeiter</th>
<th>Email</th>
<th>Anspruch</th>
<th>Genutzt (<?php echo $year; ?>)</th>
<th>Urlaub genutzt (<?php echo $year; ?>)</th>
<th>Verbleibend</th>
<th>Ausstehend</th>
<th>Bevorstehende Urlaube</th>
<th>Alle Abwesenheiten</th>
<th>Offene Anträge</th>
<?php foreach ($reasonKeys as $reasonKey): ?>
<th><?php echo htmlspecialchars(vacationAbsenceReasonLabel($reasonKey)); ?></th>
<?php endforeach; ?>
<th>Bevorstehende Einträge</th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $u):
$uid = $u['id'];
// Genutzte Tage (genehmigt) im Jahr
$s = $pdo->prepare("SELECT IFNULL(SUM(days),0) AS used FROM vacations WHERE user_id = ? AND status = 'genehmigt' AND YEAR(start_date) = ?");
$s->execute([$uid, $year]);
$used = (int)$s->fetchColumn();
// Ausstehende Anträge
$p = $pdo->prepare("SELECT COUNT(*) FROM vacations WHERE user_id = ? AND status = 'beantragt'");
$p->execute([$uid]);
$pending = (int)$p->fetchColumn();
// Bevorstehende Urlaube (nächste 5)
$n = $pdo->prepare("SELECT start_date, end_date, days, status FROM vacations WHERE user_id = ? AND end_date >= CURDATE() ORDER BY start_date LIMIT 5");
$n->execute([$uid]);
$upcoming = $n->fetchAll();
$entitlement = isset($u['urlaubstage']) ? (int)$u['urlaubstage'] : 0;
$used = (int)($statsByUser[$uid]['urlaub_used_days'] ?? 0);
$remaining = $entitlement - $used;
$totalAbsences = (int)($statsByUser[$uid]['total_absence_days'] ?? 0);
$pending = (int)($statsByUser[$uid]['pending_count'] ?? 0);
$upcoming = $upcomingByUser[$uid] ?? [];
?>
<tr>
<td><?php echo htmlspecialchars($u['vorname'] . ' ' . $u['nachname']); ?></td>
@@ -63,11 +170,17 @@ $users = $stmt->fetchAll();
<td><?php echo $entitlement; ?></td>
<td><?php echo $used; ?></td>
<td><?php echo $remaining; ?></td>
<td><?php echo $totalAbsences; ?></td>
<td><?php echo $pending; ?></td>
<?php foreach ($reasonKeys as $reasonKey): ?>
<td><?php echo vacationOverviewReasonValue($statsByUser, (int)$uid, $reasonKey); ?></td>
<?php endforeach; ?>
<td>
<?php if (count($upcoming) == 0) { echo '-'; } else {
foreach ($upcoming as $up) {
echo htmlspecialchars($up['start_date'] . ' → ' . $up['end_date'] . ' (' . $up['days'] . 'd) ' . ' [' . $up['status'] . ']');
$reasonLabel = vacationAbsenceReasonLabel($up['absence_reason'] ?? 'urlaub');
$statusLabel = vacationOverviewStatusLabel($up['status'] ?? null);
echo htmlspecialchars($up['start_date'] . ' → ' . $up['end_date'] . ' (' . $up['days'] . 'd) ' . ' [' . $reasonLabel . ', ' . $statusLabel . ']');
echo '<br>';
}
} ?>
@@ -76,8 +189,9 @@ $users = $stmt->fetchAll();
<?php endforeach; ?>
</tbody>
</table>
</div>
<p>Hinweis: Ansprüche werden aus dem Feld <strong>users.urlaubstage</strong> gelesen. Falls dieses Feld leer ist, bitte in der Nutzerverwaltung pflegen.</p>
<p>Hinweis: Der Urlaubsanspruch wird weiterhin aus <strong>users.urlaubstage</strong> gelesen. Die zusätzlichen Spalten zeigen die Abwesenheiten je Grund pro Jahr.</p>
</div>