Version 2.0.0: PWA Mobile App + Produktliste-Verbesserungen

PWA (neue Dateien):
- Vollständige Progressive Web App mit Token-basierter Auth
- 4 Swipe-Panels: Alle STZ, Stundenzettel, Produktliste, Lieferauflistung
- Kundensuche, Leistungen-Accordion, Mehraufwand-Sektion
- Produkt-Übernahme aus Auftrag + Mehraufwand in STZ
- Service Worker, Manifest, App-Icons für Installation

Desktop-Änderungen:
- Produktliste: Checkboxen immer sichtbar (außer bereits auf STZ)
- Lieferauflistung: Vereinfachte Ansicht (nur Verbaut-Spalte)
- Admin: PWA-Link in Einstellungen
- Sprachdatei: PWA-Übersetzungen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-02-27 21:21:14 +01:00
parent 6257713e5b
commit 292db5d40c
32 changed files with 5262 additions and 96 deletions

22
admin/setup.php Normal file → Executable file
View file

@ -212,6 +212,28 @@ print '</tr>';
print '</table>';
// PWA Mobile App Bereich
print '<br>';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th class="titlefield">'.$langs->trans("PWAMobileApp").'</th>';
print '<th style="width: 300px;">'.$langs->trans("Value").'</th>';
print '<th class="right" style="width: 100px;"></th>';
print '</tr>';
$pwaUrl = dol_buildpath('/stundenzettel/pwa.php', 2);
print '<tr class="oddeven">';
print '<td>'.$langs->trans("PWADescription").'<br><small class="opacitymedium">'.$langs->trans("PWAInstallHint").'</small></td>';
print '<td>';
print '<a href="'.$pwaUrl.'" target="_blank" class="butAction small" style="display: inline-block; text-decoration: none;">';
print $langs->trans("PWALink").' &#8599;';
print '</a>';
print '</td>';
print '<td></td>';
print '</tr>';
print '</table>';
print dol_get_fiche_end();
llxFooter();

0
ajax/add_leistung.php Normal file → Executable file
View file

0
ajax/add_product.php Normal file → Executable file
View file

1564
ajax/pwa_api.php Normal file

File diff suppressed because it is too large Load diff

144
ajax/pwa_auth.php Normal file
View file

@ -0,0 +1,144 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* Stundenzettel PWA - Token-basierte Authentifizierung
*/
if (!defined('NOLOGIN')) {
define('NOLOGIN', '1');
}
if (!defined('NOREQUIREMENU')) {
define('NOREQUIREMENU', '1');
}
if (!defined('NOREQUIREHTML')) {
define('NOREQUIREHTML', '1');
}
if (!defined('NOREQUIREAJAX')) {
define('NOREQUIREAJAX', '1');
}
// Dolibarr-Umgebung laden
$res = 0;
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 && file_exists("../../../../main.inc.php")) $res = @include "../../../../main.inc.php";
if (!$res) die(json_encode(array('success' => false, 'error' => 'Dolibarr nicht geladen')));
header('Content-Type: application/json; charset=UTF-8');
$action = GETPOST('action', 'aZ09');
$response = array('success' => false);
switch ($action) {
case 'login':
$username = GETPOST('username', 'alphanohtml');
$password = GETPOST('password', 'none');
if (empty($username) || empty($password)) {
$response['error'] = 'Benutzername und Passwort erforderlich';
break;
}
// Brute-Force-Schutz
usleep(100000); // 100ms Verzoegerung
require_once DOL_DOCUMENT_ROOT.'/user/class/user.class.php';
$userLogin = new User($db);
// Benutzer per Login suchen
$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."user WHERE login = '".$db->escape($username)."' AND statut = 1";
$result = $db->query($sql);
if ($result && $db->num_rows($result) > 0) {
$obj = $db->fetch_object($result);
$userLogin->fetch($obj->rowid);
$userLogin->getrights();
// Passwort pruefen
require_once DOL_DOCUMENT_ROOT.'/core/lib/security2.lib.php';
$passOk = false;
if (!empty($userLogin->pass_indatabase_crypted)) {
$passOk = dol_verifyHash($password, $userLogin->pass_indatabase_crypted);
}
if ($passOk) {
// Stundenzettel-Berechtigung pruefen
if ($userLogin->hasRight('stundenzettel', 'read')) {
// Token generieren (15 Tage gueltig)
$tokenData = array(
'user_id' => $userLogin->id,
'login' => $userLogin->login,
'created' => time(),
'expires' => time() + (15 * 24 * 60 * 60),
'hash' => md5($userLogin->id . $userLogin->login . getDolGlobalString('MAIN_SECURITY_SALT', 'defaultsalt'))
);
$token = base64_encode(json_encode($tokenData));
$response['success'] = true;
$response['token'] = $token;
$response['user'] = array(
'id' => $userLogin->id,
'login' => $userLogin->login,
'name' => $userLogin->getFullName($langs)
);
} else {
$response['error'] = 'Keine Berechtigung fuer Stundenzettel';
}
} else {
$response['error'] = 'Falsches Passwort';
}
} else {
$response['error'] = 'Benutzer nicht gefunden';
}
break;
case 'verify':
$token = GETPOST('token', 'none');
if (empty($token)) {
$response['error'] = 'Kein Token';
break;
}
$tokenData = json_decode(base64_decode($token), true);
if (!$tokenData || empty($tokenData['user_id']) || empty($tokenData['expires'])) {
$response['error'] = 'Ungueltiges Token';
break;
}
// Ablaufdatum pruefen
if ($tokenData['expires'] < time()) {
$response['error'] = 'Token abgelaufen';
break;
}
// Hash verifizieren
$expectedHash = md5($tokenData['user_id'] . $tokenData['login'] . getDolGlobalString('MAIN_SECURITY_SALT', 'defaultsalt'));
if ($tokenData['hash'] !== $expectedHash) {
$response['error'] = 'Token manipuliert';
break;
}
// Benutzer noch aktiv?
require_once DOL_DOCUMENT_ROOT.'/user/class/user.class.php';
$userCheck = new User($db);
if ($userCheck->fetch($tokenData['user_id']) > 0 && $userCheck->statut == 1) {
$response['success'] = true;
$response['user'] = array(
'id' => $userCheck->id,
'login' => $userCheck->login,
'name' => $userCheck->getFullName($langs)
);
} else {
$response['error'] = 'Benutzer nicht mehr aktiv';
}
break;
default:
$response['error'] = 'Unbekannte Aktion';
}
echo json_encode($response);
$db->close();

0
card.php Normal file → Executable file
View file

0
class/stundenzettel.class.php Normal file → Executable file
View file

1200
css/pwa.css Normal file

File diff suppressed because it is too large Load diff

0
debug_netto.php Normal file → Executable file
View file

BIN
img/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

BIN
img/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

0
index.php Normal file → Executable file
View file

2087
js/pwa.js Normal file

File diff suppressed because it is too large Load diff

6
langs/de_DE/stundenzettel.lang Normal file → Executable file
View file

@ -282,3 +282,9 @@ incl = inkl.
# Extrafields Aufträge
NettoSTZ = Netto STZ
NettoSTZHelp = Netto-Wert aller freigegebenen Stundenzettel (Produkte + Arbeitsstunden)
# PWA Mobile App
PWAMobileApp = PWA Mobile App
PWALink = Stundenzettel PWA öffnen
PWAInstallHint = Am Handy öffnen und "Zum Startbildschirm hinzufügen" wählen
PWADescription = Stundenzettel als installierbare Mobile-App für unterwegs

0
lib/stundenzettel.lib.php Normal file → Executable file
View file

0
list.php Normal file → Executable file
View file

26
manifest.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "Stundenzettel",
"short_name": "STZ",
"description": "Stundenzettel Mobile App - Arbeitszeiten und Material erfassen",
"start_url": "./pwa.php",
"display": "standalone",
"orientation": "portrait",
"background_color": "#1d1e20",
"theme_color": "#1d1e20",
"lang": "de-DE",
"categories": ["business", "productivity"],
"icons": [
{
"src": "img/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "img/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

174
pwa.php Normal file
View file

@ -0,0 +1,174 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* Stundenzettel PWA - Standalone Mobile App
*/
// Kein Dolibarr-Login erforderlich - eigenes Token-System
if (!defined('NOLOGIN')) {
define('NOLOGIN', '1');
}
if (!defined('NOREQUIREMENU')) {
define('NOREQUIREMENU', '1');
}
if (!defined('NOREQUIREHTML')) {
define('NOREQUIREHTML', '1');
}
// Dolibarr-Umgebung laden (fuer Theme-Color und Config)
$res = 0;
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 && file_exists("../../../main.inc.php")) $res = @include "../../../main.inc.php";
if (!$res) die("Dolibarr konnte nicht geladen werden");
// Theme-Farbe aus Dolibarr
$themeColor = getDolGlobalString('THEME_ELDY_TOPMENU_BACK1', '#4390dc');
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="theme-color" content="#1d1e20">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="STZ">
<title>Stundenzettel</title>
<link rel="manifest" href="manifest.json">
<link rel="icon" type="image/png" sizes="192x192" href="img/icon-192.png">
<link rel="apple-touch-icon" href="img/icon-192.png">
<link rel="stylesheet" href="css/pwa.css?v=2.6">
<style>:root { --primary: <?php echo htmlspecialchars($themeColor); ?>; }</style>
</head>
<body>
<div id="app" class="app">
<!-- Toast-Container -->
<div id="toast-container" class="toast-container"></div>
<!-- Loading-Overlay -->
<div id="loading-overlay" class="loading-overlay">
<div class="loading-spinner"></div>
</div>
<!-- Confirm-Dialog -->
<div id="confirm-dialog" class="confirm-dialog">
<div class="confirm-box">
<div id="confirm-title" class="confirm-title"></div>
<div id="confirm-text" class="confirm-text"></div>
<div class="confirm-actions">
<button id="confirm-cancel" class="btn btn-ghost">Abbrechen</button>
<button id="confirm-ok" class="btn btn-danger">OK</button>
</div>
</div>
</div>
<!-- Bottom-Sheet (wiederverwendbar) -->
<div id="bottom-sheet-overlay" class="bottom-sheet-overlay"></div>
<div id="bottom-sheet" class="bottom-sheet">
<div class="bottom-sheet-handle"></div>
<div id="bottom-sheet-header" class="bottom-sheet-header"></div>
<div id="bottom-sheet-body" class="bottom-sheet-body"></div>
<div id="bottom-sheet-footer" class="bottom-sheet-footer"></div>
</div>
<!-- ===== LOGIN SCREEN ===== -->
<div id="screen-login" class="screen login-screen active">
<div class="login-container">
<div class="login-logo">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/>
</svg>
</div>
<h1 class="login-title">Stundenzettel</h1>
<p class="login-subtitle">Mobile Zeiterfassung</p>
<form id="login-form" class="login-form">
<div class="form-group">
<label for="login-user">Benutzername</label>
<input type="text" id="login-user" autocomplete="username" required>
</div>
<div class="form-group">
<label for="login-pass">Passwort</label>
<input type="password" id="login-pass" autocomplete="current-password" required>
</div>
<button type="submit" class="btn btn-primary">Anmelden</button>
<p id="login-error" class="error-text"></p>
</form>
</div>
</div>
<!-- ===== SEARCH SCREEN ===== -->
<div id="screen-search" class="screen search-screen">
<div class="search-header">
<div class="search-input-wrap">
<span class="search-icon">&#128269;</span>
<input type="search" id="search-input" placeholder="Kunde suchen..." autocomplete="off">
</div>
<button id="btn-logout" class="btn btn-ghost btn-small">Abmelden</button>
</div>
<div id="search-results" class="search-results">
<div class="search-empty">
<p>Kundenname eingeben um Auftr&auml;ge zu finden</p>
</div>
</div>
</div>
<!-- ===== MAIN SCREEN (Swipe-Panels) ===== -->
<div id="screen-main" class="screen main-screen">
<!-- Tab-Bar -->
<div class="tab-bar">
<div id="btn-back" class="back-btn">&#8592;</div>
<div class="tab-items">
<div class="tab-item" data-panel="0">Alle STZ</div>
<div class="tab-item" data-panel="1">Stundenzettel</div>
<div class="tab-item active" data-panel="2">Produktliste</div>
<div class="tab-item" data-panel="3">Lieferauflistung</div>
</div>
</div>
<!-- Swipe-Viewport -->
<div class="swipe-viewport">
<div id="swipe-container" class="swipe-container">
<!-- Panel 0: Alle Stundenzettel -->
<div id="panel-stzlist" class="swipe-panel"></div>
<!-- Panel 1: Stundenzettel (Leistungen + Merkzettel) -->
<div id="panel-stundenzettel" class="swipe-panel"></div>
<!-- Panel 2: Produktliste (Standard) -->
<div id="panel-products" class="swipe-panel"></div>
<!-- Panel 3: Lieferauflistung -->
<div id="panel-tracking" class="swipe-panel"></div>
</div>
</div>
<!-- FAB -->
<button id="fab-add" class="fab hidden">+</button>
</div>
</div>
<!-- JavaScript -->
<script src="<?php echo DOL_URL_ROOT; ?>/includes/jquery/js/jquery.min.js"></script>
<script>
window.STZ_CONFIG = {
moduleUrl: '<?php echo dol_buildpath('/stundenzettel/', 1); ?>',
apiUrl: '<?php echo dol_buildpath('/stundenzettel/ajax/pwa_api.php', 1); ?>',
authUrl: '<?php echo dol_buildpath('/stundenzettel/ajax/pwa_auth.php', 1); ?>'
};
</script>
<script src="js/pwa.js?v=2.6"></script>
<!-- Service Worker Registration -->
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js')
.then(function(reg) { console.log('[STZ-PWA] Service Worker registriert'); })
.catch(function(err) { console.error('[STZ-PWA] SW Registrierung fehlgeschlagen:', err); });
}
</script>
</body>
</html>

0
sql/dolibarr_allversions.sql Normal file → Executable file
View file

0
sql/llx_product_services.sql Normal file → Executable file
View file

0
sql/llx_stundenzettel.key.sql Normal file → Executable file
View file

0
sql/llx_stundenzettel.sql Normal file → Executable file
View file

0
sql/llx_stundenzettel_leistung.key.sql Normal file → Executable file
View file

0
sql/llx_stundenzettel_leistung.sql Normal file → Executable file
View file

0
sql/llx_stundenzettel_note.sql Normal file → Executable file
View file

0
sql/llx_stundenzettel_product.key.sql Normal file → Executable file
View file

0
sql/llx_stundenzettel_product.sql Normal file → Executable file
View file

0
sql/llx_stundenzettel_tracking.key.sql Normal file → Executable file
View file

0
sql/llx_stundenzettel_tracking.sql Normal file → Executable file
View file

0
sql/update_1.2.0.sql Normal file → Executable file
View file

View file

@ -1865,12 +1865,12 @@ if ($tab == 'products') {
print '<tr class="oddeven'.$sectionClass.'">';
// Checkbox (nur bei offenen anzeigen UND wenn noch nicht auf diesem Stundenzettel)
// Checkbox immer anzeigen, ausser wenn bereits auf diesem Stundenzettel
$isAlreadyOnStz = isset($alreadyOnStundenzettel[$obj->rowid]);
print '<td class="center"'.$styleFirst.'>';
if (!$isDone && !$isAlreadyOnStz) {
if (!$isAlreadyOnStz) {
print '<input type="checkbox" name="selected[]" value="'.$obj->rowid.'" class="product-checkbox">';
} elseif ($isAlreadyOnStz) {
} else {
print '<span class="fas fa-check" style="color: #28a745;" title="'.$langs->trans("AlreadyOnStundenzettel").'"></span>';
}
print '</td>';
@ -2456,9 +2456,7 @@ if ($tab == 'products') {
// TAB: LIEFERAUFLISTUNG / TRACKING
// =============================================
if ($tab == 'tracking') {
$total_ordered = 0;
$total_delivered = 0;
$total_remaining = 0;
// Alle Stundenzettel-Details pro Produkt laden (für ausklappbare Ansicht)
$trackingDetails = array(); // Array[fk_commandedet] => array of entries
@ -2503,10 +2501,7 @@ if ($tab == 'tracking') {
print '<table class="noborder centpercent" id="tracking-table">';
print '<tr class="liste_titre">';
print '<th>'.$langs->trans("Product").'</th>';
print '<th class="right">'.$langs->trans("QtyOrdered").'</th>';
print '<th class="right">'.$langs->trans("QtyDelivered").'</th>';
print '<th class="right">'.$langs->trans("QtyRemaining").'</th>';
print '<th>'.$langs->trans("Status").'</th>';
print '</tr>';
// Live-Berechnung aus allen Stundenzetteln (inkl. Entwürfe)
@ -2585,22 +2580,7 @@ if ($tab == 'tracking') {
}
print '</td>';
// Bestellt (mit Badges für Mehraufwand/Entfällt/Rücknahmen)
print '<td class="right">';
print '<strong>'.formatQty($effective_ordered).'</strong>';
if ($qty_additional > 0) {
print ' <span class="badge" style="background-color: #28a745; color: #fff; font-size: 0.75em;" title="'.$langs->trans("Mehraufwand").'">+'.formatQty($qty_additional).'</span>';
}
if ($qty_omitted > 0) {
print ' <span class="badge" style="background-color: #dc3545; color: #fff; font-size: 0.75em;" title="'.$langs->trans("Entfaellt").'">-'.formatQty($qty_omitted).'</span>';
}
if ($qty_returned > 0) {
print ' <span class="badge badge-danger" title="'.$langs->trans("Ruecknahme").'">-'.formatQty($qty_returned).'</span>';
}
print '</td>';
$total_ordered += $effective_ordered;
// Geliefert/Erfasst (mit Rücknahme-Hinweis)
// Verbaut (mit Rücknahme-Hinweis)
print '<td class="right">';
print formatQty($effective_delivered);
if ($qty_returned > 0) {
@ -2609,38 +2589,12 @@ if ($tab == 'tracking') {
print '</td>';
$total_delivered += $effective_delivered;
// Verbleibend
print '<td class="right">';
if ($qty_remaining > 0) {
print '<span class="badge badge-warning">'.formatQty($qty_remaining).'</span>';
} elseif ($qty_remaining == 0) {
print '<span class="badge badge-success">0</span>';
} else {
print '<span class="badge badge-info">'.formatQty($qty_remaining).'</span>';
}
print '</td>';
$total_remaining += $qty_remaining;
// Status
print '<td>';
if ($effective_ordered <= 0 && $qty_returned > 0) {
// Alles zurückgenommen - Erledigt
print '<span class="badge badge-success">'.$langs->trans("TrackingDone").'</span>';
} elseif ($qty_remaining <= 0) {
print '<span class="badge badge-success">'.$langs->trans("TrackingDone").'</span>';
} elseif ($effective_delivered > 0) {
print '<span class="badge badge-warning">'.$langs->trans("TrackingPartial").'</span>';
} else {
print '<span class="badge badge-secondary">'.$langs->trans("TrackingOpen").'</span>';
}
print '</td>';
print '</tr>';
// Detail-Zeile (standardmäßig eingeklappt)
if ($hasDetails) {
print '<tr id="tdetail_'.$detailRowId.'" class="tracking-detail-row" style="display:none;">';
print '<td class="stz-subtable-bg" colspan="5" style="padding: 0 0 0 30px;">';
print '<td class="stz-subtable-bg" colspan="2" style="padding: 0 0 0 30px;">';
// Sub-Tabelle für Details
print '<table class="noborder" style="width:100%; margin: 5px 0;">';
@ -2707,14 +2661,11 @@ if ($tab == 'tracking') {
// Summenzeile
print '<tr class="liste_total">';
print '<td><strong>'.$langs->trans("Total").'</strong></td>';
print '<td class="right"><strong>'.formatQty($total_ordered).'</strong></td>';
print '<td class="right"><strong>'.formatQty($total_delivered).'</strong></td>';
print '<td class="right"><strong>'.formatQty($total_remaining).'</strong></td>';
print '<td></td>';
print '</tr>';
} else {
print '<tr class="oddeven"><td colspan="5" class="opacitymedium">'.$langs->trans("NoRecordFound").'</td></tr>';
print '<tr class="oddeven"><td colspan="2" class="opacitymedium">'.$langs->trans("NoRecordFound").'</td></tr>';
}
}
@ -2771,7 +2722,7 @@ if ($tab == 'tracking') {
if ($resqlMehr && $db->num_rows($resqlMehr) > 0) {
// Separator-Zeile für Mehraufwand
print '<tr class="liste_titre">';
print '<td class="stz-mehraufwand-header" colspan="5"><strong>'.$langs->trans("Mehraufwand").'</strong> <span class="opacitymedium">('.$langs->trans("MehraufwandDesc").')</span></td>';
print '<td class="stz-mehraufwand-header" colspan="2"><strong>'.$langs->trans("Mehraufwand").'</strong> <span class="opacitymedium">('.$langs->trans("MehraufwandDesc").')</span></td>';
print '</tr>';
while ($objMehr = $db->fetch_object($resqlMehr)) {
@ -2835,16 +2786,7 @@ if ($tab == 'tracking') {
}
print '</td>';
// Bestellt (Mehraufwand-Menge, MINUS Rücknahmen)
print '<td class="right">';
print '<strong>'.formatQty($qty_ordered_mehr).'</strong>';
if ($qtyReturned > 0) {
print ' <span class="badge badge-danger" title="'.$langs->trans("QtyReturned").'">-'.formatQty($qtyReturned).'</span>';
}
print '</td>';
$total_ordered += $qty_ordered_mehr;
// Geliefert (mit Rücknahme-Hinweis wenn vorhanden)
// Verbaut (mit Rücknahme-Hinweis wenn vorhanden)
print '<td class="right">';
print formatQty($qty_delivered_mehr);
if ($qtyReturned > 0) {
@ -2853,38 +2795,12 @@ if ($tab == 'tracking') {
print '</td>';
$total_delivered += $qty_delivered_mehr;
// Verbleibend
print '<td class="right">';
if ($qty_remaining_mehr > 0) {
print '<span class="badge badge-warning">'.formatQty($qty_remaining_mehr).'</span>';
} elseif ($qty_remaining_mehr == 0) {
print '<span class="badge badge-success">0</span>';
} else {
print '<span class="badge badge-info">'.formatQty($qty_remaining_mehr).'</span>';
}
print '</td>';
$total_remaining += $qty_remaining_mehr;
// Status
print '<td>';
if ($qty_ordered_mehr <= 0 && $qtyReturned > 0) {
// Alles zurückgenommen - Erledigt
print '<span class="badge badge-success">'.$langs->trans("TrackingDone").'</span>';
} elseif ($qty_remaining_mehr <= 0) {
print '<span class="badge badge-success">'.$langs->trans("TrackingDone").'</span>';
} elseif ($qty_delivered_mehr > 0) {
print '<span class="badge badge-warning">'.$langs->trans("TrackingPartial").'</span>';
} else {
print '<span class="badge badge-secondary">'.$langs->trans("TrackingOpen").'</span>';
}
print '</td>';
print '</tr>';
// Detail-Zeile für Mehraufwand
if ($hasDetailsMehr) {
print '<tr id="tdetail_'.$detailRowId.'" class="tracking-detail-row" style="display:none;">';
print '<td class="stz-subtable-bg" colspan="5" style="padding: 0 0 0 30px;">';
print '<td class="stz-subtable-bg" colspan="2" style="padding: 0 0 0 30px;">';
print '<table class="noborder" style="width:100%; margin: 5px 0;">';
print '<tr class="liste_titre stz-subtable-header">';
print '<th style="width:120px;">'.$langs->trans("Stundenzettel").'</th>';
@ -2913,10 +2829,7 @@ if ($tab == 'tracking') {
// Neue Summenzeile mit Mehraufwand
print '<tr class="liste_total">';
print '<td><strong>'.$langs->trans("Total").' ('.$langs->trans("Mehraufwand").' '.$langs->trans("incl").')</strong></td>';
print '<td class="right"><strong>'.formatQty($total_ordered).'</strong></td>';
print '<td class="right"><strong>'.formatQty($total_delivered).'</strong></td>';
print '<td class="right"><strong>'.formatQty($total_remaining).'</strong></td>';
print '<td></td>';
print '</tr>';
}

30
sw.js Normal file
View file

@ -0,0 +1,30 @@
/**
* Stundenzettel PWA - Minimaler Service Worker
* Nur fuer Installierbarkeit, kein Offline-Caching
*/
const CACHE_VERSION = 'stundenzettel-pwa-v1.0';
self.addEventListener('install', function(event) {
// Sofort aktivieren, nicht auf andere Tabs warten
self.skipWaiting();
});
self.addEventListener('activate', function(event) {
// Alte Caches loeschen
event.waitUntil(
caches.keys().then(function(cacheNames) {
return Promise.all(
cacheNames.filter(function(name) {
return name.startsWith('stundenzettel-pwa-') && name !== CACHE_VERSION;
}).map(function(name) {
return caches.delete(name);
})
);
}).then(function() {
return self.clients.claim();
})
);
});
// Kein Fetch-Intercepting - alle Requests gehen direkt ans Netzwerk