commit c65d15a86b9158d5b46a7931bcf8d3e34cc87476 Author: data Date: Mon Feb 23 11:04:43 2026 +0100 feat: GlobalNotify v1.1.0 - Schwebendes Benachrichtigungs-Widget Messenger-artiges Benachrichtigungssystem für Dolibarr: - Schwebendes FAB (Floating Action Button) unten links - Ausklappbares Panel mit allen Benachrichtigungen - Historie der gelesenen Nachrichten - Direktes Abhaken per Checkbox - Click-to-Navigate für Aktionen - Pulsierender Button bei dringenden Nachrichten - Draggable Panel-Header - Automatische Erkennung hängender Cron-Jobs - API für andere Module: GlobalNotify::error(), ::warning(), etc. Co-Authored-By: Claude Opus 4.5 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8f25fd9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog + +Alle wesentlichen Änderungen an diesem Projekt werden in dieser Datei dokumentiert. + +## [1.1.0] - 2026-02-23 + +### Neu +- **Schwebendes Widget**: Messenger-artiges Panel unten links, immer sichtbar +- **Draggable**: Panel-Header kann gezogen werden um Position zu ändern +- **Historie**: Zeigt abgehakte/gelesene Benachrichtigungen in ausklappbarem Bereich +- **Direktes Abhaken**: Checkbox neben jeder Nachricht zum schnellen Als-gelesen-markieren +- **Click-to-Navigate**: Klick auf Nachricht mit Aktion navigiert direkt zur Zielseite +- **Pulsierender Button**: FAB pulsiert rot bei dringenden Nachrichten (Fehler/Aktionen) + +### Verbessert +- Modernes Card-Design statt Dropdown +- Bessere visuelle Unterscheidung zwischen gelesen/ungelesen +- Animierte Übergänge beim Abhaken + +## [1.0.0] - 2026-02-23 + +### Erste Version +- Benachrichtigungs-Bell im Top-Menü +- Integration für alle Module via API +- Automatische Erkennung von hängenden Cron-Jobs +- Unterstützung für: Fehler, Warnungen, Info, Erfolg, Aktion erforderlich +- BankImport und ImportZugferd Integration +- Admin-Seite zur Übersicht aller Benachrichtigungen diff --git a/admin/setup.php b/admin/setup.php new file mode 100644 index 0000000..92e7063 --- /dev/null +++ b/admin/setup.php @@ -0,0 +1,183 @@ + + * + * GlobalNotify Admin Setup Page + */ + +// Load Dolibarr environment +$res = 0; +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) { + $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +} +$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; +$tmp2 = realpath(__FILE__); +$i = strlen($tmp) - 1; +$j = strlen($tmp2) - 1; +while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res && file_exists("../../../main.inc.php")) { + $res = @include "../../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +require_once DOL_DOCUMENT_ROOT."/core/lib/admin.lib.php"; +dol_include_once('/globalnotify/class/globalnotify.class.php'); + +/** + * @var Conf $conf + * @var DoliDB $db + * @var Translate $langs + * @var User $user + */ + +$langs->loadLangs(array("admin", "globalnotify@globalnotify")); + +// Access control +if (!$user->admin) { + accessforbidden(); +} + +$action = GETPOST('action', 'aZ09'); + +/* + * Actions + */ + +if ($action == 'clearall') { + // Clear all notifications + $sql = "DELETE FROM ".MAIN_DB_PREFIX."const WHERE name LIKE 'GLOBALNOTIFY_%'"; + $db->query($sql); + setEventMessages("Alle Benachrichtigungen gelöscht", null, 'mesgs'); + header("Location: ".$_SERVER["PHP_SELF"]); + exit; +} + +if ($action == 'resetcronjobs') { + // Reset all stuck cron jobs + $sql = "UPDATE ".MAIN_DB_PREFIX."cronjob SET processing = 0 WHERE processing = 1"; + $resql = $db->query($sql); + $affected = $db->affected_rows($resql); + setEventMessages("{$affected} Cron-Jobs zurückgesetzt", null, 'mesgs'); + header("Location: ".$_SERVER["PHP_SELF"]); + exit; +} + +/* + * View + */ + +$page_name = "GlobalNotify - ".$langs->trans("Settings"); +llxHeader('', $page_name); + +print load_fiche_titre($page_name, '', 'title_setup'); + +// Get all notifications +$notify = new GlobalNotify($db); +$allNotifications = $notify->getAllNotifications(0, false); // All, including read + +// Get stuck cron jobs +$sql = "SELECT rowid, label, module_name, datelastrun, processing, status + FROM ".MAIN_DB_PREFIX."cronjob + WHERE status = 1 + ORDER BY processing DESC, label"; +$resql = $db->query($sql); + +print '
'; + +// Actions +print ''; + +// Cron Jobs Overview +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; + +if ($resql) { + while ($obj = $db->fetch_object($resql)) { + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + } +} +print '
Cron-JobModulStatusLetzter Lauf
'.$obj->label.''.($obj->module_name ?: '-').''; + if ($obj->processing) { + print 'HÄNGT'; + } else { + print 'OK'; + } + print ''.dol_print_date($db->jdate($obj->datelastrun), 'dayhour').'
'; + +// All Notifications +print '
'; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; + +if (empty($allNotifications)) { + print ''; +} else { + foreach ($allNotifications as $notif) { + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + print ''; + } +} +print '
Benachrichtigungen ('.count($allNotifications).')ModulTypDatumStatus
Keine Benachrichtigungen
'; + print ''.dol_escape_htmltag($notif['title']).'
'; + print ''.dol_escape_htmltag($notif['message']).''; + if (!empty($notif['action_url'])) { + print '
'.$notif['action_label'].''; + } + print '
'.$notif['module'].''; + $typeLabels = array( + 'error' => 'Fehler', + 'warning' => 'Warnung', + 'info' => 'Info', + 'success' => 'Erfolg', + 'action' => 'Aktion', + ); + print $typeLabels[$notif['type']] ?? $notif['type']; + print ''.dol_print_date($notif['created'], 'dayhour').''; + if (!empty($notif['read'])) { + print 'Gelesen'; + } else { + print 'Ungelesen'; + } + print '
'; + +print '
'; + +llxFooter(); +$db->close(); diff --git a/ajax/action.php b/ajax/action.php new file mode 100644 index 0000000..58e2ce4 --- /dev/null +++ b/ajax/action.php @@ -0,0 +1,110 @@ + + * + * AJAX handler for GlobalNotify module + */ + +if (!defined('NOTOKENRENEWAL')) { + define('NOTOKENRENEWAL', '1'); +} +if (!defined('NOREQUIREMENU')) { + define('NOREQUIREMENU', '1'); +} +if (!defined('NOREQUIREHTML')) { + define('NOREQUIREHTML', '1'); +} +if (!defined('NOREQUIREAJAX')) { + define('NOREQUIREAJAX', '1'); +} + +// Load Dolibarr environment +$res = 0; +if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) { + $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; +} +$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; +$tmp2 = realpath(__FILE__); +$i = strlen($tmp) - 1; +$j = strlen($tmp2) - 1; +while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { + $i--; + $j--; +} +if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { + $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; +} +if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { + $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php"; +} +if (!$res && file_exists("../../main.inc.php")) { + $res = @include "../../main.inc.php"; +} +if (!$res && file_exists("../../../main.inc.php")) { + $res = @include "../../../main.inc.php"; +} +if (!$res) { + die("Include of main fails"); +} + +dol_include_once('/globalnotify/class/globalnotify.class.php'); + +/** + * @var User $user + * @var DoliDB $db + */ + +// Security check +if (!$user->admin) { + http_response_code(403); + echo json_encode(array('success' => false, 'error' => 'Access denied')); + exit; +} + +$action = GETPOST('action', 'aZ09'); +$id = GETPOST('id', 'alphanohtml'); + +header('Content-Type: application/json'); + +$notify = new GlobalNotify($db); +$response = array('success' => false); + +switch ($action) { + case 'dismiss': + if (!empty($id)) { + $result = $notify->markAsRead($id); + $response['success'] = $result; + } + break; + + case 'delete': + if (!empty($id)) { + $result = $notify->deleteNotification($id); + $response['success'] = $result; + } + break; + + case 'markallread': + $count = $notify->markAllAsRead(); + $response['success'] = true; + $response['count'] = $count; + break; + + case 'getall': + $notifications = $notify->getAllNotifications($user->id, true); + $response['success'] = true; + $response['notifications'] = $notifications; + $response['count'] = count($notifications); + // Could also return rendered HTML here if needed + break; + + case 'getcount': + $count = $notify->getUnreadCount(); + $response['success'] = true; + $response['count'] = $count; + break; + + default: + $response['error'] = 'Unknown action'; +} + +echo json_encode($response); diff --git a/class/actions_globalnotify.class.php b/class/actions_globalnotify.class.php new file mode 100644 index 0000000..e33d37f --- /dev/null +++ b/class/actions_globalnotify.class.php @@ -0,0 +1,464 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +/** + * \file globalnotify/class/actions_globalnotify.class.php + * \ingroup globalnotify + * \brief Hook class for displaying notifications in top bar + */ + +require_once DOL_DOCUMENT_ROOT.'/core/class/commonhookactions.class.php'; +dol_include_once('/globalnotify/class/globalnotify.class.php'); + +/** + * Class ActionsGlobalNotify + * Hook actions for GlobalNotify module + */ +class ActionsGlobalNotify extends CommonHookActions +{ + /** + * @var DoliDB Database handler + */ + public $db; + + /** + * @var string Error message + */ + public $error = ''; + + /** + * @var array Error messages + */ + public $errors = array(); + + /** + * @var array Results + */ + public $results = array(); + + /** + * @var string Returned string + */ + public $resprints = ''; + + /** + * Constructor + * + * @param DoliDB $db Database handler + */ + public function __construct($db) + { + $this->db = $db; + } + + /** + * Hook to add floating notification widget + * + * @param array $parameters Parameters + * @param object $object Object + * @param string $action Action + * @return int 0=OK, >0=KO + */ + public function printTopRightMenu($parameters, &$object, &$action) + { + global $conf, $user, $langs; + + if (!$user->admin) { + return 0; // Only show to admins + } + + $langs->load('globalnotify@globalnotify'); + + $notify = new GlobalNotify($this->db); + $unreadNotifications = $notify->getAllNotifications($user->id, true); + $readNotifications = $notify->getReadNotifications($user->id, 20); + $unreadCount = count($unreadNotifications); + $readCount = count($readNotifications); + $totalCount = $unreadCount + $readCount; + + // Check for urgent notifications + $hasUrgent = false; + foreach ($unreadNotifications as $n) { + if (in_array($n['type'], array('error', 'action'))) { + $hasUrgent = true; + break; + } + } + + // Build floating widget HTML + $html = ''; + + // Floating button (always visible, bottom-left corner) + $html .= '
'; + + // Draggable handle + toggle button + $html .= '
'; + $html .= ''; + if ($unreadCount > 0) { + $html .= ''.$unreadCount.''; + } + $html .= '
'; + + // Floating panel (expandable) + $html .= ''; // panel + $html .= '
'; // widget + + $this->resprints = $html; + return 0; + } + + /** + * Render a single notification item + * + * @param array $notif Notification data + * @param bool $isRead Is this a read notification + * @return string HTML + */ + private function renderNotificationItem($notif, $isRead = false) + { + $typeClass = 'globalnotify-item globalnotify-item-'.$notif['type']; + if ($isRead) { + $typeClass .= ' globalnotify-item-read'; + } + + $html = '
'; + + // Checkbox for quick action + $html .= '
'; + if ($isRead) { + $html .= ''; + } else { + $html .= ''; + } + $html .= '
'; + + // Content area (clickable if has action) + $hasAction = !empty($notif['action_url']); + $contentClass = 'globalnotify-item-content'.($hasAction ? ' globalnotify-clickable' : ''); + $onclick = $hasAction ? ' onclick="GlobalNotify.goToAction(\''.$notif['id'].'\', \''.dol_escape_htmltag($notif['action_url']).'\')"' : ''; + + $html .= '
'; + + // Icon + $icon = 'info-circle'; + if ($notif['type'] == 'error') $icon = 'exclamation-circle'; + elseif ($notif['type'] == 'warning') $icon = 'exclamation-triangle'; + elseif ($notif['type'] == 'success') $icon = 'check-circle'; + elseif ($notif['type'] == 'action') $icon = 'hand-point-right'; + + $html .= '
'; + + $html .= '
'; + $html .= '
'.dol_escape_htmltag($notif['title']).'
'; + $html .= '
'.dol_escape_htmltag($notif['message']).'
'; + $html .= '
'; + $html .= ''.dol_escape_htmltag($notif['module']).''; + $html .= ''.dol_print_date($notif['created'], 'dayhour').''; + $html .= '
'; + $html .= '
'; + + $html .= '
'; // content + + $html .= '
'; // item + + return $html; + } + + /** + * Hook called on every page load - collect notifications from modules + * + * @param array $parameters Parameters + * @param object $object Object + * @param string $action Action + * @return int 0=OK + */ + public function addMoreActionsButtons($parameters, &$object, &$action) + { + global $conf, $user; + + if (!$user->admin) { + return 0; + } + + // Check ALL stuck cron jobs (generic check) + $this->checkStuckCronJobs(); + + // Check BankImport cron status (module-specific) + if (isModEnabled('bankimport')) { + $this->checkBankImportStatus(); + } + + // Check ImportZugferd cron status (module-specific) + if (isModEnabled('importzugferd')) { + $this->checkImportZugferdStatus(); + } + + return 0; + } + + /** + * Check all cron jobs for stuck/hanging state + * This is the generic check that works for all modules + */ + private function checkStuckCronJobs() + { + global $conf; + + // Only check once per minute (cache) + $lastCheck = getDolGlobalInt('GLOBALNOTIFY_CRON_LASTCHECK'); + if ($lastCheck > (time() - 60)) { + return; + } + dolibarr_set_const($this->db, 'GLOBALNOTIFY_CRON_LASTCHECK', time(), 'chaine', 0, '', $conf->entity); + + // Find all stuck cron jobs (processing=1 for more than 30 minutes) + $sql = "SELECT rowid, label, module_name, datelastrun, processing + FROM ".MAIN_DB_PREFIX."cronjob + WHERE processing = 1 + AND status = 1 + AND datelastrun < DATE_SUB(NOW(), INTERVAL 30 MINUTE)"; + + $resql = $this->db->query($sql); + if (!$resql) { + return; + } + + $notify = new GlobalNotify($this->db); + + while ($obj = $this->db->fetch_object($resql)) { + $module = !empty($obj->module_name) ? strtolower($obj->module_name) : 'cron'; + $lastRun = $this->db->jdate($obj->datelastrun); + $stuckMinutes = round((time() - $lastRun) / 60); + + // Check if we already have an active notification for this job + $notifKey = 'cronjob_stuck_'.$obj->rowid; + $existing = $notify->getModuleNotifications('cron'); + $hasNotif = false; + foreach ($existing as $n) { + if (strpos($n['id'], $notifKey) !== false && !$n['read']) { + $hasNotif = true; + break; + } + } + + if (!$hasNotif) { + $notify->addNotification( + 'cron', + GlobalNotify::TYPE_ERROR, + 'Cron-Job hängt: '.$obj->label, + "Der Job läuft seit {$stuckMinutes} Minuten ohne Antwort. Modul: {$module}", + dol_buildpath('/cron/list.php', 1), + 'Cron-Jobs anzeigen', + 10, // highest priority + 0 + ); + } + } + + // Also check for jobs that haven't run when they should have + // (datenextrun is in the past but job hasn't started) + $sql = "SELECT rowid, label, module_name, datenextrun, datelastrun + FROM ".MAIN_DB_PREFIX."cronjob + WHERE processing = 0 + AND status = 1 + AND datenextrun < DATE_SUB(NOW(), INTERVAL 2 HOUR)"; + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $module = !empty($obj->module_name) ? strtolower($obj->module_name) : 'cron'; + $missedTime = $this->db->jdate($obj->datenextrun); + $missedHours = round((time() - $missedTime) / 3600); + + $existing = $notify->getModuleNotifications('cron'); + $hasNotif = false; + foreach ($existing as $n) { + if (strpos($n['message'], $obj->label) !== false && strpos($n['title'], 'verpasst') !== false && !$n['read']) { + $hasNotif = true; + break; + } + } + + if (!$hasNotif) { + $notify->addNotification( + 'cron', + GlobalNotify::TYPE_WARNING, + 'Cron-Job verpasst', + "Job '{$obj->label}' hätte vor {$missedHours} Stunden laufen sollen. Prüfe Cron-Aufruf.", + dol_buildpath('/cron/list.php', 1), + 'Cron-Jobs prüfen', + 7, + 0 + ); + } + } + } + } + + /** + * Check BankImport module for issues + */ + private function checkBankImportStatus() + { + dol_include_once('/bankimport/class/bankimportcron.class.php'); + + if (!class_exists('BankImportCron')) { + return; + } + + $status = BankImportCron::getCronStatus(); + $notify = new GlobalNotify($this->db); + + // Clear old notifications first + $existing = $notify->getModuleNotifications('bankimport'); + + // Check for issues + if ($status['paused']) { + // Check if we already have this notification + $hasNotif = false; + foreach ($existing as $n) { + if ($n['type'] == 'warning' && strpos($n['title'], 'pausiert') !== false && !$n['read']) { + $hasNotif = true; + break; + } + } + if (!$hasNotif) { + GlobalNotify::warning( + 'bankimport', + 'BankImport Cron pausiert', + $status['pause_reason'], + dol_buildpath('/bankimport/admin/cronmonitor.php', 1), + 'Cron Monitor öffnen' + ); + } + } + + if (!empty($status['notification'])) { + $notifType = $status['notification']['type']; + $messages = array( + 'tan_required' => array('TAN erforderlich', 'Bank-Login erfordert TAN-Bestätigung'), + 'login_error' => array('Login-Fehler', 'Bank-Login fehlgeschlagen'), + 'fetch_error' => array('Abruf-Fehler', 'Kontoauszüge konnten nicht abgerufen werden'), + 'session_expired' => array('Session abgelaufen', 'Bank-Session ist abgelaufen, neuer Login erforderlich'), + ); + + if (isset($messages[$notifType])) { + $hasNotif = false; + foreach ($existing as $n) { + if (strpos($n['title'], $messages[$notifType][0]) !== false && !$n['read']) { + $hasNotif = true; + break; + } + } + if (!$hasNotif) { + if ($notifType == 'tan_required') { + GlobalNotify::actionRequired( + 'bankimport', + $messages[$notifType][0], + $messages[$notifType][1], + dol_buildpath('/bankimport/fetch.php', 1), + 'TAN eingeben' + ); + } else { + GlobalNotify::error( + 'bankimport', + $messages[$notifType][0], + $messages[$notifType][1], + dol_buildpath('/bankimport/admin/cronmonitor.php', 1), + 'Details anzeigen' + ); + } + } + } + } + } + + /** + * Check ImportZugferd module for issues + */ + private function checkImportZugferdStatus() + { + // Similar check for ImportZugferd can be added here + // Check if cron job is stuck + $sql = "SELECT processing, datelastrun FROM ".MAIN_DB_PREFIX."cronjob WHERE label = 'ImportZugferdScheduled' AND processing = 1"; + $resql = $this->db->query($sql); + if ($resql && $obj = $this->db->fetch_object($resql)) { + $lastRun = $this->db->jdate($obj->datelastrun); + // If running for more than 30 minutes, it's probably stuck + if ($lastRun < (time() - 1800)) { + $notify = new GlobalNotify($this->db); + $existing = $notify->getModuleNotifications('importzugferd'); + $hasNotif = false; + foreach ($existing as $n) { + if (strpos($n['title'], 'hängt') !== false && !$n['read']) { + $hasNotif = true; + break; + } + } + if (!$hasNotif) { + GlobalNotify::error( + 'importzugferd', + 'ImportZugferd Cron hängt', + 'Der Cron-Job läuft seit '.dol_print_date($lastRun, 'dayhour').' und reagiert nicht mehr.', + dol_buildpath('/importzugferd/admin/setup.php', 1), + 'Einstellungen öffnen' + ); + } + } + } + } +} diff --git a/class/globalnotify.class.php b/class/globalnotify.class.php new file mode 100644 index 0000000..56aeb20 --- /dev/null +++ b/class/globalnotify.class.php @@ -0,0 +1,372 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +/** + * \file globalnotify/class/globalnotify.class.php + * \ingroup globalnotify + * \brief Global notification manager class + */ + +/** + * Class GlobalNotify + * Manages global notifications from all modules + */ +class GlobalNotify +{ + /** + * @var DoliDB Database handler + */ + public $db; + + /** + * Notification types with styling + */ + const TYPE_ERROR = 'error'; + const TYPE_WARNING = 'warning'; + const TYPE_INFO = 'info'; + const TYPE_SUCCESS = 'success'; + const TYPE_ACTION = 'action'; // Requires user action + + /** + * Constructor + * + * @param DoliDB $db Database handler + */ + public function __construct($db) + { + $this->db = $db; + } + + /** + * Add a notification + * + * @param string $module Module name (e.g., 'bankimport', 'importzugferd') + * @param string $type Notification type (error, warning, info, success, action) + * @param string $title Short title + * @param string $message Detailed message + * @param string $actionUrl URL for action button (optional) + * @param string $actionLabel Label for action button (optional) + * @param int $priority Priority 1-10 (10 = highest) + * @param int $userId Specific user ID or 0 for all admins + * @return int Notification ID or -1 on error + */ + public function addNotification($module, $type, $title, $message, $actionUrl = '', $actionLabel = '', $priority = 5, $userId = 0) + { + global $conf; + + // Store in llx_const as JSON array + $key = 'GLOBALNOTIFY_'.strtoupper($module); + + $notification = array( + 'id' => uniqid($module.'_'), + 'module' => $module, + 'type' => $type, + 'title' => $title, + 'message' => $message, + 'action_url' => $actionUrl, + 'action_label' => $actionLabel, + 'priority' => $priority, + 'user_id' => $userId, + 'created' => time(), + 'read' => false + ); + + // Get existing notifications for this module + $existing = $this->getModuleNotifications($module); + $existing[] = $notification; + + // Keep only last 50 notifications per module + if (count($existing) > 50) { + $existing = array_slice($existing, -50); + } + + $result = dolibarr_set_const($this->db, $key, json_encode($existing), 'chaine', 0, '', $conf->entity); + + return $result > 0 ? $notification['id'] : -1; + } + + /** + * Get notifications for a specific module + * + * @param string $module Module name + * @return array Notifications + */ + public function getModuleNotifications($module) + { + $key = 'GLOBALNOTIFY_'.strtoupper($module); + $data = getDolGlobalString($key); + + if (empty($data)) { + return array(); + } + + $notifications = json_decode($data, true); + return is_array($notifications) ? $notifications : array(); + } + + /** + * Get all active notifications for current user + * + * @param int $userId User ID + * @param bool $unreadOnly Only return unread notifications + * @return array All notifications sorted by priority and date + */ + public function getAllNotifications($userId = 0, $unreadOnly = true) + { + global $user; + + if ($userId == 0) { + $userId = $user->id; + } + + $allNotifications = array(); + + // Get all notification constants (exclude internal settings) + $sql = "SELECT name, value FROM ".MAIN_DB_PREFIX."const WHERE name LIKE 'GLOBALNOTIFY_%' AND name NOT LIKE 'GLOBALNOTIFY_CRON%' AND value != '' AND value LIKE '[%'"; + $resql = $this->db->query($sql); + + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $notifications = json_decode($obj->value, true); + if (is_array($notifications)) { + foreach ($notifications as $notif) { + // Filter by user if specified in notification + if (!empty($notif['user_id']) && $notif['user_id'] != $userId) { + continue; + } + // Filter by read status + if ($unreadOnly && !empty($notif['read'])) { + continue; + } + $allNotifications[] = $notif; + } + } + } + } + + // Sort by priority (desc) and date (desc) + usort($allNotifications, function($a, $b) { + if ($a['priority'] != $b['priority']) { + return $b['priority'] - $a['priority']; + } + return $b['created'] - $a['created']; + }); + + return $allNotifications; + } + + /** + * Get read notifications for history display + * + * @param int $userId User ID + * @param int $limit Maximum number to return + * @return array Read notifications sorted by date (newest first) + */ + public function getReadNotifications($userId = 0, $limit = 20) + { + global $user; + + if ($userId == 0) { + $userId = $user->id; + } + + $readNotifications = array(); + + // Get all notification constants (exclude internal settings) + $sql = "SELECT name, value FROM ".MAIN_DB_PREFIX."const WHERE name LIKE 'GLOBALNOTIFY_%' AND name NOT LIKE 'GLOBALNOTIFY_CRON%' AND value != '' AND value LIKE '[%'"; + $resql = $this->db->query($sql); + + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $notifications = json_decode($obj->value, true); + if (is_array($notifications)) { + foreach ($notifications as $notif) { + // Filter by user if specified + if (!empty($notif['user_id']) && $notif['user_id'] != $userId) { + continue; + } + // Only read notifications + if (!empty($notif['read'])) { + $readNotifications[] = $notif; + } + } + } + } + } + + // Sort by date (newest first) + usort($readNotifications, function($a, $b) { + return $b['created'] - $a['created']; + }); + + // Limit results + return array_slice($readNotifications, 0, $limit); + } + + /** + * Mark notification as read + * + * @param string $notificationId Notification ID + * @return bool Success + */ + public function markAsRead($notificationId) + { + global $conf; + + // Find which module this notification belongs to + $sql = "SELECT name, value FROM ".MAIN_DB_PREFIX."const WHERE name LIKE 'GLOBALNOTIFY_%' AND value LIKE '%".addslashes($notificationId)."%'"; + $resql = $this->db->query($sql); + + if ($resql && $obj = $this->db->fetch_object($resql)) { + $notifications = json_decode($obj->value, true); + if (is_array($notifications)) { + foreach ($notifications as &$notif) { + if ($notif['id'] == $notificationId) { + $notif['read'] = true; + break; + } + } + dolibarr_set_const($this->db, $obj->name, json_encode($notifications), 'chaine', 0, '', $conf->entity); + return true; + } + } + + return false; + } + + /** + * Mark all notifications as read + * + * @param string $module Optional: only for specific module + * @return int Number of notifications marked as read + */ + public function markAllAsRead($module = '') + { + global $conf; + + $count = 0; + $sql = "SELECT name, value FROM ".MAIN_DB_PREFIX."const WHERE name LIKE 'GLOBALNOTIFY_%'"; + if (!empty($module)) { + $sql .= " AND name = 'GLOBALNOTIFY_".strtoupper($module)."'"; + } + + $resql = $this->db->query($sql); + if ($resql) { + while ($obj = $this->db->fetch_object($resql)) { + $notifications = json_decode($obj->value, true); + if (is_array($notifications)) { + $changed = false; + foreach ($notifications as &$notif) { + if (empty($notif['read'])) { + $notif['read'] = true; + $changed = true; + $count++; + } + } + if ($changed) { + dolibarr_set_const($this->db, $obj->name, json_encode($notifications), 'chaine', 0, '', $conf->entity); + } + } + } + } + + return $count; + } + + /** + * Delete a notification + * + * @param string $notificationId Notification ID + * @return bool Success + */ + public function deleteNotification($notificationId) + { + global $conf; + + $sql = "SELECT name, value FROM ".MAIN_DB_PREFIX."const WHERE name LIKE 'GLOBALNOTIFY_%' AND value LIKE '%".addslashes($notificationId)."%'"; + $resql = $this->db->query($sql); + + if ($resql && $obj = $this->db->fetch_object($resql)) { + $notifications = json_decode($obj->value, true); + if (is_array($notifications)) { + $notifications = array_filter($notifications, function($n) use ($notificationId) { + return $n['id'] != $notificationId; + }); + dolibarr_set_const($this->db, $obj->name, json_encode(array_values($notifications)), 'chaine', 0, '', $conf->entity); + return true; + } + } + + return false; + } + + /** + * Clear all notifications for a module + * + * @param string $module Module name + * @return bool Success + */ + public function clearModuleNotifications($module) + { + global $conf; + + $key = 'GLOBALNOTIFY_'.strtoupper($module); + return dolibarr_del_const($this->db, $key, $conf->entity) >= 0; + } + + /** + * Get unread count for badge display + * + * @return int Number of unread notifications + */ + public function getUnreadCount() + { + return count($this->getAllNotifications(0, true)); + } + + /** + * Helper: Add error notification + */ + public static function error($module, $title, $message, $actionUrl = '', $actionLabel = '') + { + global $db; + $notify = new self($db); + return $notify->addNotification($module, self::TYPE_ERROR, $title, $message, $actionUrl, $actionLabel, 10); + } + + /** + * Helper: Add warning notification + */ + public static function warning($module, $title, $message, $actionUrl = '', $actionLabel = '') + { + global $db; + $notify = new self($db); + return $notify->addNotification($module, self::TYPE_WARNING, $title, $message, $actionUrl, $actionLabel, 7); + } + + /** + * Helper: Add info notification + */ + public static function info($module, $title, $message, $actionUrl = '', $actionLabel = '') + { + global $db; + $notify = new self($db); + return $notify->addNotification($module, self::TYPE_INFO, $title, $message, $actionUrl, $actionLabel, 3); + } + + /** + * Helper: Add action required notification + */ + public static function actionRequired($module, $title, $message, $actionUrl, $actionLabel = 'Aktion erforderlich') + { + global $db; + $notify = new self($db); + return $notify->addNotification($module, self::TYPE_ACTION, $title, $message, $actionUrl, $actionLabel, 9); + } +} diff --git a/core/modules/modGlobalNotify.class.php b/core/modules/modGlobalNotify.class.php new file mode 100644 index 0000000..cb97c6d --- /dev/null +++ b/core/modules/modGlobalNotify.class.php @@ -0,0 +1,107 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +/** + * \defgroup globalnotify Module GlobalNotify + * \brief Global notification system for all modules + * \file core/modules/modGlobalNotify.class.php + * \ingroup globalnotify + * \brief Description and activation file for module GlobalNotify + */ + +include_once DOL_DOCUMENT_ROOT.'/core/modules/DolibarrModules.class.php'; + +/** + * Class modGlobalNotify + * Description and activation class for module GlobalNotify + */ +class modGlobalNotify extends DolibarrModules +{ + /** + * Constructor + * + * @param DoliDB $db Database handler + */ + public function __construct($db) + { + global $langs, $conf; + $this->db = $db; + + $this->numero = 500100; + $this->rights_class = 'globalnotify'; + $this->family = "technic"; + $this->module_position = '90'; + $this->name = preg_replace('/^mod/i', '', get_class($this)); + $this->description = "Global notification system - displays alerts from all modules"; + $this->descriptionlong = "Provides a unified notification bell in the top bar that collects and displays alerts from any module (cron errors, warnings, action required, etc.)"; + $this->editor_name = 'Data IT Solution'; + $this->editor_url = 'https://data-it-solution.de'; + $this->version = '1.1.0'; + $this->const_name = 'MAIN_MODULE_'.strtoupper($this->name); + $this->picto = 'bell'; + + $this->module_parts = array( + 'hooks' => array('main', 'toprightmenu'), + 'css' => array('/globalnotify/css/globalnotify.css'), + 'js' => array('/globalnotify/js/globalnotify.js'), + ); + + $this->dirs = array("/globalnotify/temp"); + + $this->config_page_url = array("setup.php@globalnotify"); + + $this->hidden = false; + $this->depends = array(); + $this->requiredby = array(); + $this->conflictwith = array(); + + $this->langfiles = array("globalnotify@globalnotify"); + + $this->const = array(); + + if (!isset($conf->globalnotify) || !isset($conf->globalnotify->enabled)) { + $conf->globalnotify = new stdClass(); + $conf->globalnotify->enabled = 0; + } + + $this->tabs = array(); + $this->dictionaries = array(); + $this->boxes = array(); + $this->cronjobs = array(); + + $this->rights = array(); + + $this->menu = array(); + } + + /** + * Function called when module is enabled + * + * @param string $options Options when enabling module + * @return int 1 if OK, 0 if KO + */ + public function init($options = '') + { + $result = $this->_load_tables('/install/mysql/', 'globalnotify'); + $sql = array(); + return $this->_init($sql, $options); + } + + /** + * Function called when module is disabled + * + * @param string $options Options when disabling module + * @return int 1 if OK, 0 if KO + */ + public function remove($options = '') + { + $sql = array(); + return $this->_remove($sql, $options); + } +} diff --git a/css/globalnotify.css b/css/globalnotify.css new file mode 100644 index 0000000..066ed22 --- /dev/null +++ b/css/globalnotify.css @@ -0,0 +1,431 @@ +/** + * GlobalNotify CSS + * Floating messenger-style notification widget + */ + +/* ============================================ + FLOATING WIDGET CONTAINER + ============================================ */ +.globalnotify-widget { + position: fixed; + bottom: 20px; + left: 20px; + z-index: 99999; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +/* ============================================ + FLOATING ACTION BUTTON (FAB) + ============================================ */ +.globalnotify-fab { + width: 50px; + height: 50px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4); + transition: transform 0.2s, box-shadow 0.2s; + position: relative; +} + +.globalnotify-fab:hover { + transform: scale(1.1); + box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5); +} + +.globalnotify-fab .fa-bell { + font-size: 20px; +} + +/* Urgent state - pulsing */ +.globalnotify-fab-urgent { + animation: globalnotify-pulse 2s infinite; + background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%); + box-shadow: 0 4px 15px rgba(231, 76, 60, 0.4); +} + +.globalnotify-fab-urgent:hover { + box-shadow: 0 6px 20px rgba(231, 76, 60, 0.5); +} + +@keyframes globalnotify-pulse { + 0%, 100% { + box-shadow: 0 4px 15px rgba(231, 76, 60, 0.4); + } + 50% { + box-shadow: 0 4px 25px rgba(231, 76, 60, 0.7), 0 0 0 8px rgba(231, 76, 60, 0.1); + } +} + +/* Badge on FAB */ +.globalnotify-fab-badge { + position: absolute; + top: -5px; + right: -5px; + background: #e74c3c; + color: white; + font-size: 11px; + font-weight: bold; + padding: 2px 6px; + border-radius: 10px; + min-width: 18px; + text-align: center; + border: 2px solid white; + box-shadow: 0 2px 5px rgba(0,0,0,0.2); +} + +/* ============================================ + FLOATING PANEL + ============================================ */ +.globalnotify-panel { + position: absolute; + bottom: 60px; + left: 0; + width: 360px; + max-height: 500px; + background: white; + border-radius: 12px; + box-shadow: 0 10px 40px rgba(0,0,0,0.2); + overflow: hidden; + display: flex; + flex-direction: column; +} + +/* Panel Header */ +.globalnotify-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 15px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + cursor: move; + user-select: none; +} + +.globalnotify-panel-title { + font-weight: 600; + font-size: 14px; +} + +.globalnotify-panel-actions { + display: flex; + gap: 10px; +} + +.globalnotify-action-link { + cursor: pointer; + opacity: 0.8; + transition: opacity 0.2s; + padding: 4px; +} + +.globalnotify-action-link:hover { + opacity: 1; +} + +/* Panel Content Section */ +.globalnotify-section { + flex: 1; + overflow-y: auto; + max-height: 300px; +} + +/* Empty State */ +.globalnotify-empty { + padding: 40px 20px; + text-align: center; + color: #999; + font-size: 14px; +} + +.globalnotify-empty .fa { + font-size: 40px; + color: #27ae60; + margin-bottom: 10px; +} + +/* ============================================ + NOTIFICATION ITEMS + ============================================ */ +.globalnotify-item { + display: flex; + align-items: flex-start; + padding: 10px 12px; + border-bottom: 1px solid #f0f0f0; + transition: background-color 0.2s; +} + +.globalnotify-item:hover { + background-color: #f8f9fa; +} + +.globalnotify-item:last-child { + border-bottom: none; +} + +/* Read items (history) */ +.globalnotify-item-read { + opacity: 0.6; + background-color: #fafafa; +} + +.globalnotify-item-read:hover { + opacity: 0.8; +} + +/* Checkbox column */ +.globalnotify-checkbox { + width: 24px; + flex-shrink: 0; + cursor: pointer; + padding-top: 2px; +} + +.globalnotify-unchecked { + color: #ccc; + font-size: 16px; +} + +.globalnotify-checked { + color: #27ae60; + font-size: 16px; +} + +.globalnotify-checkbox:hover .globalnotify-unchecked { + color: #27ae60; +} + +/* Item content */ +.globalnotify-item-content { + flex: 1; + display: flex; + min-width: 0; +} + +.globalnotify-clickable { + cursor: pointer; +} + +.globalnotify-clickable:hover .globalnotify-item-title { + color: #667eea; +} + +/* Item icon */ +.globalnotify-item-icon { + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-right: 10px; + font-size: 12px; +} + +.globalnotify-item-error .globalnotify-item-icon { + background-color: #fdeaea; + color: #e74c3c; +} + +.globalnotify-item-warning .globalnotify-item-icon { + background-color: #fef5e7; + color: #f39c12; +} + +.globalnotify-item-info .globalnotify-item-icon { + background-color: #eaf2f8; + color: #3498db; +} + +.globalnotify-item-success .globalnotify-item-icon { + background-color: #e8f8f0; + color: #27ae60; +} + +.globalnotify-item-action .globalnotify-item-icon { + background-color: #f4ecf7; + color: #9b59b6; +} + +/* Item text */ +.globalnotify-item-text { + flex: 1; + min-width: 0; +} + +.globalnotify-item-title { + font-weight: 600; + font-size: 12px; + color: #333; + margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.globalnotify-item-message { + font-size: 11px; + color: #666; + line-height: 1.3; + margin-bottom: 4px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.globalnotify-item-meta { + display: flex; + gap: 8px; + align-items: center; +} + +.globalnotify-module-tag { + background: #eee; + padding: 1px 5px; + border-radius: 3px; + font-size: 9px; + text-transform: uppercase; + color: #666; +} + +.globalnotify-time { + font-size: 10px; + color: #999; +} + +/* ============================================ + HISTORY SECTION + ============================================ */ +.globalnotify-history-toggle { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 15px; + background: #f5f5f5; + border-top: 1px solid #eee; + cursor: pointer; + font-size: 12px; + color: #666; + transition: background-color 0.2s; +} + +.globalnotify-history-toggle:hover { + background: #eee; +} + +.globalnotify-history-toggle .fa-history { + margin-right: 6px; +} + +.globalnotify-chevron { + transition: transform 0.3s; +} + +.globalnotify-history-toggle.open .globalnotify-chevron { + transform: rotate(180deg); +} + +.globalnotify-history { + max-height: 200px; + overflow-y: auto; + background: #fafafa; + display: none; +} + +.globalnotify-history.open { + display: block; +} + +/* ============================================ + PANEL FOOTER + ============================================ */ +.globalnotify-panel-footer { + padding: 10px 15px; + background: #f8f9fa; + border-top: 1px solid #eee; + text-align: center; +} + +.globalnotify-panel-footer a { + font-size: 12px; + color: #667eea; + text-decoration: none; +} + +.globalnotify-panel-footer a:hover { + text-decoration: underline; +} + +.globalnotify-panel-footer .fa-cog { + margin-right: 5px; +} + +/* ============================================ + RESPONSIVE + ============================================ */ +@media (max-width: 480px) { + .globalnotify-panel { + width: calc(100vw - 40px); + max-width: 360px; + } +} + +/* ============================================ + DARK MODE SUPPORT + ============================================ */ +@media (prefers-color-scheme: dark) { + .globalnotify-panel { + background: #2d2d2d; + box-shadow: 0 10px 40px rgba(0,0,0,0.5); + } + + .globalnotify-item { + border-color: #444; + } + + .globalnotify-item:hover { + background-color: #383838; + } + + .globalnotify-item-read { + background-color: #333; + } + + .globalnotify-item-title { + color: #eee; + } + + .globalnotify-item-message { + color: #aaa; + } + + .globalnotify-empty { + color: #888; + } + + .globalnotify-history-toggle { + background: #333; + border-color: #444; + color: #aaa; + } + + .globalnotify-history-toggle:hover { + background: #3a3a3a; + } + + .globalnotify-history { + background: #2a2a2a; + } + + .globalnotify-panel-footer { + background: #333; + border-color: #444; + } +} diff --git a/js/globalnotify.js b/js/globalnotify.js new file mode 100644 index 0000000..2237121 --- /dev/null +++ b/js/globalnotify.js @@ -0,0 +1,330 @@ +/** + * GlobalNotify JavaScript + * Floating messenger-style notification widget + */ + +var GlobalNotify = { + isOpen: false, + isDragging: false, + dragOffset: { x: 0, y: 0 }, + + /** + * Toggle panel visibility + */ + toggle: function() { + var panel = document.getElementById('globalnotify-panel'); + if (!panel) return; + + if (this.isOpen) { + this.close(); + } else { + this.open(); + } + }, + + /** + * Open panel + */ + open: function() { + var panel = document.getElementById('globalnotify-panel'); + if (!panel) return; + + panel.style.display = 'flex'; + this.isOpen = true; + + // Close when clicking outside + setTimeout(function() { + document.addEventListener('click', GlobalNotify.handleOutsideClick); + }, 10); + }, + + /** + * Close panel + */ + close: function() { + var panel = document.getElementById('globalnotify-panel'); + if (!panel) return; + + panel.style.display = 'none'; + this.isOpen = false; + document.removeEventListener('click', GlobalNotify.handleOutsideClick); + }, + + /** + * Handle click outside + */ + handleOutsideClick: function(event) { + var widget = document.getElementById('globalnotify-widget'); + if (widget && !widget.contains(event.target)) { + GlobalNotify.close(); + } + }, + + /** + * Toggle history section + */ + toggleHistory: function() { + var toggle = document.querySelector('.globalnotify-history-toggle'); + var history = document.getElementById('globalnotify-history'); + + if (!toggle || !history) return; + + if (history.classList.contains('open')) { + history.classList.remove('open'); + toggle.classList.remove('open'); + } else { + history.classList.add('open'); + toggle.classList.add('open'); + } + }, + + /** + * Toggle item (mark as read/unread) + */ + toggleItem: function(notificationId, event) { + if (event) { + event.stopPropagation(); + } + + var item = document.querySelector('.globalnotify-item[data-id="' + notificationId + '"]'); + if (!item) return; + + // Visual feedback + item.style.opacity = '0.5'; + + // AJAX call to toggle read status + this.ajaxCall('dismiss', { id: notificationId }, function(data) { + if (data.success) { + // Move item to history or remove + item.style.transition = 'opacity 0.3s, transform 0.3s'; + item.style.opacity = '0'; + item.style.transform = 'translateX(-100%)'; + + setTimeout(function() { + item.remove(); + GlobalNotify.updateBadge(); + GlobalNotify.updateCounts(); + }, 300); + } else { + item.style.opacity = '1'; + } + }); + }, + + /** + * Go to action URL and mark as read + */ + goToAction: function(notificationId, url) { + // Mark as read first + this.ajaxCall('dismiss', { id: notificationId }, function() { + // Navigate to URL + window.location.href = url; + }); + }, + + /** + * Mark all as read + */ + markAllRead: function() { + var section = document.querySelector('.globalnotify-section'); + var items = section ? section.querySelectorAll('.globalnotify-item') : []; + + // Visual feedback + items.forEach(function(item) { + item.style.opacity = '0.5'; + }); + + this.ajaxCall('markallread', {}, function(data) { + if (data.success) { + // Clear the section + if (section) { + section.innerHTML = '

Keine Benachrichtigungen
'; + } + GlobalNotify.updateBadge(); + GlobalNotify.updateCounts(); + } + }); + }, + + /** + * Update badge on FAB + */ + updateBadge: function() { + var badge = document.querySelector('.globalnotify-fab-badge'); + var items = document.querySelectorAll('.globalnotify-section .globalnotify-item'); + var count = items.length; + + if (count > 0) { + if (badge) { + badge.textContent = count; + } else { + // Create badge + var fab = document.getElementById('globalnotify-fab'); + if (fab) { + badge = document.createElement('span'); + badge.className = 'globalnotify-fab-badge'; + badge.textContent = count; + fab.appendChild(badge); + } + } + } else if (badge) { + badge.remove(); + } + + // Update urgent state + var fab = document.getElementById('globalnotify-fab'); + if (fab) { + var hasUrgent = document.querySelector('.globalnotify-item-error, .globalnotify-item-action'); + if (hasUrgent && count > 0) { + fab.classList.add('globalnotify-fab-urgent'); + } else { + fab.classList.remove('globalnotify-fab-urgent'); + } + } + }, + + /** + * Update header counts + */ + updateCounts: function() { + var title = document.querySelector('.globalnotify-panel-title'); + if (!title) return; + + var unreadItems = document.querySelectorAll('.globalnotify-section .globalnotify-item'); + var historyItems = document.querySelectorAll('.globalnotify-history .globalnotify-item'); + var unreadCount = unreadItems.length; + var totalCount = unreadCount + historyItems.length; + + title.textContent = 'Benachrichtigungen (' + unreadCount + '/' + totalCount + ')'; + + // Hide/show mark all link + var markAll = document.querySelector('.globalnotify-panel-actions .globalnotify-action-link'); + if (markAll) { + markAll.style.display = unreadCount > 0 ? '' : 'none'; + } + }, + + /** + * AJAX helper + */ + ajaxCall: function(action, params, callback) { + var url = (typeof DOL_URL_ROOT !== 'undefined' ? DOL_URL_ROOT : '') + '/custom/globalnotify/ajax/action.php'; + var body = 'action=' + encodeURIComponent(action); + + for (var key in params) { + body += '&' + key + '=' + encodeURIComponent(params[key]); + } + + if (typeof TOKEN !== 'undefined') { + body += '&token=' + TOKEN; + } + + fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: body + }) + .then(function(response) { return response.json(); }) + .then(function(data) { + if (callback) callback(data); + }) + .catch(function(error) { + console.error('GlobalNotify error:', error); + if (callback) callback({ success: false, error: error }); + }); + }, + + /** + * Initialize dragging + */ + initDrag: function() { + var widget = document.getElementById('globalnotify-widget'); + var handle = document.getElementById('globalnotify-drag-handle'); + + if (!widget || !handle) return; + + handle.addEventListener('mousedown', function(e) { + if (e.target.closest('.globalnotify-action-link')) return; + + GlobalNotify.isDragging = true; + var rect = widget.getBoundingClientRect(); + GlobalNotify.dragOffset = { + x: e.clientX - rect.left, + y: e.clientY - rect.top + }; + document.body.style.userSelect = 'none'; + }); + + document.addEventListener('mousemove', function(e) { + if (!GlobalNotify.isDragging) return; + + var x = e.clientX - GlobalNotify.dragOffset.x; + var y = e.clientY - GlobalNotify.dragOffset.y; + + // Keep within viewport + x = Math.max(0, Math.min(x, window.innerWidth - 60)); + y = Math.max(0, Math.min(y, window.innerHeight - 60)); + + widget.style.left = x + 'px'; + widget.style.top = y + 'px'; + widget.style.bottom = 'auto'; + widget.style.right = 'auto'; + }); + + document.addEventListener('mouseup', function() { + GlobalNotify.isDragging = false; + document.body.style.userSelect = ''; + }); + }, + + /** + * Refresh notifications via AJAX + */ + refresh: function() { + this.ajaxCall('getcount', {}, function(data) { + if (data.success) { + GlobalNotify.updateBadgeFromServer(data.count); + } + }); + }, + + /** + * Update badge from server count + */ + updateBadgeFromServer: function(count) { + var badge = document.querySelector('.globalnotify-fab-badge'); + var fab = document.getElementById('globalnotify-fab'); + + if (count > 0) { + if (badge) { + badge.textContent = count; + } else if (fab) { + badge = document.createElement('span'); + badge.className = 'globalnotify-fab-badge'; + badge.textContent = count; + fab.appendChild(badge); + } + } else if (badge) { + badge.remove(); + } + }, + + /** + * Initialize + */ + init: function() { + this.initDrag(); + + // Periodic refresh every 2 minutes + setInterval(function() { + GlobalNotify.refresh(); + }, 120000); + } +}; + +// Initialize when DOM is ready +document.addEventListener('DOMContentLoaded', function() { + GlobalNotify.init(); +}); diff --git a/langs/de_DE/globalnotify.lang b/langs/de_DE/globalnotify.lang new file mode 100644 index 0000000..732e746 --- /dev/null +++ b/langs/de_DE/globalnotify.lang @@ -0,0 +1,29 @@ +# GlobalNotify - German translations + +# Module +Module500100Name = GlobalNotify +Module500100Desc = Globales Benachrichtigungssystem für alle Module + +# General +Notifications = Benachrichtigungen +NoNotifications = Keine Benachrichtigungen +MarkAllRead = Alle als gelesen markieren +ViewAll = Alle anzeigen +Action = Aktion + +# Notification types +NotificationError = Fehler +NotificationWarning = Warnung +NotificationInfo = Information +NotificationSuccess = Erfolg +NotificationAction = Aktion erforderlich + +# Cron notifications +CronJobStuck = Cron-Job hängt +CronJobMissed = Cron-Job verpasst +CronJobsOverview = Cron-Jobs Übersicht + +# Admin +GlobalNotifySetup = GlobalNotify Einstellungen +EnableNotifications = Benachrichtigungen aktivieren +NotificationSettings = Benachrichtigungs-Einstellungen diff --git a/langs/en_US/globalnotify.lang b/langs/en_US/globalnotify.lang new file mode 100644 index 0000000..e946aea --- /dev/null +++ b/langs/en_US/globalnotify.lang @@ -0,0 +1,29 @@ +# GlobalNotify - English translations + +# Module +Module500100Name = GlobalNotify +Module500100Desc = Global notification system for all modules + +# General +Notifications = Notifications +NoNotifications = No notifications +MarkAllRead = Mark all as read +ViewAll = View all +Action = Action + +# Notification types +NotificationError = Error +NotificationWarning = Warning +NotificationInfo = Information +NotificationSuccess = Success +NotificationAction = Action required + +# Cron notifications +CronJobStuck = Cron job stuck +CronJobMissed = Cron job missed +CronJobsOverview = Cron jobs overview + +# Admin +GlobalNotifySetup = GlobalNotify Settings +EnableNotifications = Enable notifications +NotificationSettings = Notification settings