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 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-02-23 11:04:43 +01:00
commit c65d15a86b
10 changed files with 2083 additions and 0 deletions

28
CHANGELOG.md Normal file
View file

@ -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

183
admin/setup.php Normal file
View file

@ -0,0 +1,183 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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 '<div class="div-table-responsive">';
// Actions
print '<div class="tabsAction">';
print '<a class="butAction" href="'.$_SERVER["PHP_SELF"].'?action=clearall" onclick="return confirm(\'Alle Benachrichtigungen löschen?\')">Alle Benachrichtigungen löschen</a>';
print '<a class="butAction" href="'.$_SERVER["PHP_SELF"].'?action=resetcronjobs" onclick="return confirm(\'Alle hängenden Cron-Jobs zurücksetzen?\')">Hängende Cron-Jobs zurücksetzen</a>';
print '</div>';
// Cron Jobs Overview
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th>Cron-Job</th>';
print '<th>Modul</th>';
print '<th>Status</th>';
print '<th>Letzter Lauf</th>';
print '</tr>';
if ($resql) {
while ($obj = $db->fetch_object($resql)) {
print '<tr class="oddeven">';
print '<td>'.$obj->label.'</td>';
print '<td>'.($obj->module_name ?: '-').'</td>';
print '<td>';
if ($obj->processing) {
print '<span class="badge badge-status1" style="background-color:#ff9800">HÄNGT</span>';
} else {
print '<span class="badge badge-status4">OK</span>';
}
print '</td>';
print '<td>'.dol_print_date($db->jdate($obj->datelastrun), 'dayhour').'</td>';
print '</tr>';
}
}
print '</table>';
// All Notifications
print '<br>';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th>Benachrichtigungen ('.count($allNotifications).')</th>';
print '<th>Modul</th>';
print '<th>Typ</th>';
print '<th>Datum</th>';
print '<th>Status</th>';
print '</tr>';
if (empty($allNotifications)) {
print '<tr class="oddeven"><td colspan="5" class="center opacitymedium">Keine Benachrichtigungen</td></tr>';
} else {
foreach ($allNotifications as $notif) {
print '<tr class="oddeven">';
print '<td>';
print '<strong>'.dol_escape_htmltag($notif['title']).'</strong><br>';
print '<small>'.dol_escape_htmltag($notif['message']).'</small>';
if (!empty($notif['action_url'])) {
print '<br><a href="'.dol_escape_htmltag($notif['action_url']).'">'.$notif['action_label'].'</a>';
}
print '</td>';
print '<td>'.$notif['module'].'</td>';
print '<td>';
$typeLabels = array(
'error' => '<span class="badge badge-status8">Fehler</span>',
'warning' => '<span class="badge badge-status1" style="background:#ff9800">Warnung</span>',
'info' => '<span class="badge badge-status4">Info</span>',
'success' => '<span class="badge badge-status4">Erfolg</span>',
'action' => '<span class="badge badge-status6">Aktion</span>',
);
print $typeLabels[$notif['type']] ?? $notif['type'];
print '</td>';
print '<td>'.dol_print_date($notif['created'], 'dayhour').'</td>';
print '<td>';
if (!empty($notif['read'])) {
print '<span class="opacitymedium">Gelesen</span>';
} else {
print '<strong>Ungelesen</strong>';
}
print '</td>';
print '</tr>';
}
}
print '</table>';
print '</div>';
llxFooter();
$db->close();

110
ajax/action.php Normal file
View file

@ -0,0 +1,110 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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);

View file

@ -0,0 +1,464 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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 .= '<div id="globalnotify-widget" class="globalnotify-widget">';
// Draggable handle + toggle button
$html .= '<div id="globalnotify-fab" class="globalnotify-fab'.($hasUrgent ? ' globalnotify-fab-urgent' : '').'" onclick="GlobalNotify.toggle()" title="'.$langs->trans('Notifications').'">';
$html .= '<span class="fa fa-bell"></span>';
if ($unreadCount > 0) {
$html .= '<span class="globalnotify-fab-badge">'.$unreadCount.'</span>';
}
$html .= '</div>';
// Floating panel (expandable)
$html .= '<div id="globalnotify-panel" class="globalnotify-panel" style="display:none;">';
// Panel header with drag handle
$html .= '<div class="globalnotify-panel-header" id="globalnotify-drag-handle">';
$html .= '<span class="globalnotify-panel-title">'.$langs->trans('Notifications').' ('.$unreadCount.'/'.$totalCount.')</span>';
$html .= '<div class="globalnotify-panel-actions">';
if ($unreadCount > 0) {
$html .= '<span class="globalnotify-action-link" onclick="GlobalNotify.markAllRead()" title="'.$langs->trans('MarkAllRead').'"><span class="fa fa-check-double"></span></span>';
}
$html .= '<span class="globalnotify-action-link" onclick="GlobalNotify.toggle()" title="Schließen"><span class="fa fa-times"></span></span>';
$html .= '</div>';
$html .= '</div>';
// Unread notifications section
$html .= '<div class="globalnotify-section">';
if (empty($unreadNotifications)) {
$html .= '<div class="globalnotify-empty">';
$html .= '<span class="fa fa-check-circle"></span><br>';
$html .= $langs->trans('NoNotifications');
$html .= '</div>';
} else {
foreach ($unreadNotifications as $notif) {
$html .= $this->renderNotificationItem($notif, false);
}
}
$html .= '</div>';
// History section (collapsible)
if (!empty($readNotifications)) {
$html .= '<div class="globalnotify-history-toggle" onclick="GlobalNotify.toggleHistory()">';
$html .= '<span class="fa fa-history"></span> Historie ('.$readCount.')';
$html .= '<span class="fa fa-chevron-down globalnotify-chevron"></span>';
$html .= '</div>';
$html .= '<div id="globalnotify-history" class="globalnotify-history">';
foreach ($readNotifications as $notif) {
$html .= $this->renderNotificationItem($notif, true);
}
$html .= '</div>';
}
// Footer link
$html .= '<div class="globalnotify-panel-footer">';
$html .= '<a href="'.dol_buildpath('/globalnotify/admin/setup.php', 1).'">';
$html .= '<span class="fa fa-cog"></span> Alle anzeigen';
$html .= '</a>';
$html .= '</div>';
$html .= '</div>'; // panel
$html .= '</div>'; // 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 = '<div class="'.$typeClass.'" data-id="'.dol_escape_htmltag($notif['id']).'">';
// Checkbox for quick action
$html .= '<div class="globalnotify-checkbox" onclick="GlobalNotify.toggleItem(\''.$notif['id'].'\', event)">';
if ($isRead) {
$html .= '<span class="fa fa-check-square globalnotify-checked"></span>';
} else {
$html .= '<span class="fa fa-square-o globalnotify-unchecked"></span>';
}
$html .= '</div>';
// 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 .= '<div class="'.$contentClass.'"'.$onclick.'>';
// 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 .= '<div class="globalnotify-item-icon"><span class="fa fa-'.$icon.'"></span></div>';
$html .= '<div class="globalnotify-item-text">';
$html .= '<div class="globalnotify-item-title">'.dol_escape_htmltag($notif['title']).'</div>';
$html .= '<div class="globalnotify-item-message">'.dol_escape_htmltag($notif['message']).'</div>';
$html .= '<div class="globalnotify-item-meta">';
$html .= '<span class="globalnotify-module-tag">'.dol_escape_htmltag($notif['module']).'</span>';
$html .= '<span class="globalnotify-time">'.dol_print_date($notif['created'], 'dayhour').'</span>';
$html .= '</div>';
$html .= '</div>';
$html .= '</div>'; // content
$html .= '</div>'; // 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'
);
}
}
}
}
}

View file

@ -0,0 +1,372 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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);
}
}

View file

@ -0,0 +1,107 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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);
}
}

431
css/globalnotify.css Normal file
View file

@ -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;
}
}

330
js/globalnotify.js Normal file
View file

@ -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 = '<div class="globalnotify-empty"><span class="fa fa-check-circle"></span><br>Keine Benachrichtigungen</div>';
}
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();
});

View file

@ -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

View file

@ -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