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