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:
commit
c65d15a86b
10 changed files with 2083 additions and 0 deletions
28
CHANGELOG.md
Normal file
28
CHANGELOG.md
Normal 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
183
admin/setup.php
Normal 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
110
ajax/action.php
Normal 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);
|
||||||
464
class/actions_globalnotify.class.php
Normal file
464
class/actions_globalnotify.class.php
Normal 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'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
372
class/globalnotify.class.php
Normal file
372
class/globalnotify.class.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
107
core/modules/modGlobalNotify.class.php
Normal file
107
core/modules/modGlobalNotify.class.php
Normal 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
431
css/globalnotify.css
Normal 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
330
js/globalnotify.js
Normal 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();
|
||||||
|
});
|
||||||
29
langs/de_DE/globalnotify.lang
Normal file
29
langs/de_DE/globalnotify.lang
Normal 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
|
||||||
29
langs/en_US/globalnotify.lang
Normal file
29
langs/en_US/globalnotify.lang
Normal 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
|
||||||
Loading…
Reference in a new issue