PWA: Dezimal-Mengen, STZ-Freigabe, Notiz-Trennung, qty_done=1

- Mengenanzeige klickbar für Dezimaleingabe (Komma), +/- bleiben Ganzzahl
- Freigeben/Wiedereröffnen-Button für einzelne Stundenzettel
- Warnung bei Freigabe ohne Leistung mit Service-Auswahl-Dialog
  (Standard-Dienstleistung des Kunden vorausgewählt)
- API: validate_stz und setdraft_stz Endpunkte
- API: default_service_id/label im get_order_context
- Produktübernahme: qty_done Standard auf 1 statt 0
- Merkzettel auf Produktliste: nur Anzeige + Abhaken, kein Hinzufügen
- Scroll-Position nach Panel-Neurendern zurücksetzen

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-03-21 21:42:56 +01:00
parent dabcdbde13
commit 4ca3ea5deb
4 changed files with 321 additions and 44 deletions

View file

@ -30,6 +30,7 @@ require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php';
require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
require_once DOL_DOCUMENT_ROOT.'/user/class/user.class.php';
dol_include_once('/stundenzettel/class/stundenzettel.class.php');
dol_include_once('/stundenzettel/lib/stundenzettel.lib.php');
header('Content-Type: application/json; charset=UTF-8');
@ -237,6 +238,20 @@ switch ($action) {
$customer = new Societe($db);
$customer->fetch($order->socid);
// Standard-Dienstleistung des Kunden ermitteln
$defaultServiceId = 0;
$defaultServiceLabel = '';
if (isset($customer->array_options['options_stundenzettel_default_service'])) {
$defaultServiceId = (int)$customer->array_options['options_stundenzettel_default_service'];
if ($defaultServiceId > 0) {
$sqlDS = "SELECT ref, label FROM ".MAIN_DB_PREFIX."product WHERE rowid = ".((int)$defaultServiceId);
$resDS = $db->query($sqlDS);
if ($resDS && ($objDS = $db->fetch_object($resDS))) {
$defaultServiceLabel = $objDS->ref.' - '.$objDS->label;
}
}
}
// Auftragsdaten
$response['order'] = array(
'id' => (int)$order->id,
@ -244,7 +259,9 @@ switch ($action) {
'date' => dol_print_date($order->date_commande, 'day'),
'customer_name' => $customer->name,
'customer_id' => (int)$customer->id,
'status' => (int)$order->statut
'status' => (int)$order->statut,
'default_service_id' => $defaultServiceId,
'default_service_label' => $defaultServiceLabel
);
// Stundenzettel finden (per ID oder letzten Draft)
@ -1497,7 +1514,7 @@ switch ($action) {
$objLine->rowid, // fk_commandedet
null,
$objLine->qty, // qty_original
0, // qty_done
1, // qty_done (Standard: 1)
'order',
$objLine->description
);
@ -1543,7 +1560,7 @@ switch ($action) {
null, // fk_commandedet (kein Auftragsbezug)
null,
$qty, // qty_original = Zielmenge
0, // qty_done
1, // qty_done (Standard: 1)
'added',
$description ?: $productLabel
);
@ -1556,6 +1573,65 @@ switch ($action) {
$response['added'] = $added;
break;
// ---- Stundenzettel freigeben (validieren) ----
case 'validate_stz':
if (!$canWrite) {
$response['error'] = 'Keine Schreibberechtigung';
break;
}
$stzId = GETPOST('stz_id', 'int');
$stz = new Stundenzettel($db);
if ($stz->fetch($stzId) <= 0) {
$response['error'] = 'Stundenzettel nicht gefunden';
break;
}
if ($stz->status != Stundenzettel::STATUS_DRAFT) {
$response['error'] = 'Stundenzettel ist nicht im Entwurf';
break;
}
if (!canEditStz($stz, $user, $canWriteAll)) {
$response['error'] = 'Keine Berechtigung';
break;
}
$result = $stz->validate($user);
if ($result > 0) {
// Netto-Wert aller Stundenzettel des Auftrags neu berechnen
updateOrderNettoSTZ($db, $stz->fk_commande);
$response['success'] = true;
} else {
$response['error'] = $stz->error ?: 'Fehler beim Freigeben';
}
break;
// ---- Stundenzettel zurueck auf Entwurf setzen ----
case 'setdraft_stz':
if (!$canWrite) {
$response['error'] = 'Keine Schreibberechtigung';
break;
}
$stzId = GETPOST('stz_id', 'int');
$stz = new Stundenzettel($db);
if ($stz->fetch($stzId) <= 0) {
$response['error'] = 'Stundenzettel nicht gefunden';
break;
}
if ($stz->status == Stundenzettel::STATUS_DRAFT) {
$response['error'] = 'Stundenzettel ist bereits im Entwurf';
break;
}
$result = $stz->setDraft($user);
if ($result > 0) {
updateOrderNettoSTZ($db, $stz->fk_commande);
$response['success'] = true;
} else {
$response['error'] = $stz->error ?: 'Fehler beim Zuruecksetzen';
}
break;
default:
$response['error'] = 'Unbekannte Aktion: '.$action;
}

View file

@ -530,6 +530,29 @@ body {
font-weight: 600;
font-size: 16px;
}
.qty-editable {
cursor: pointer;
border-radius: 6px;
padding: 2px 6px;
border: 1px dashed transparent;
transition: border-color 0.15s, background 0.15s;
}
.qty-editable:active {
background: var(--colorbackline);
border-color: var(--colorborder);
}
.qty-inline-input {
width: 56px;
text-align: center;
font-weight: 600;
font-size: 16px;
padding: 2px 4px;
border: 1px solid var(--primary);
border-radius: 6px;
background: var(--colorbackinput);
color: var(--colortext);
outline: none;
}
/* === Accordion-Sections === */
.accordion-section {
@ -1269,6 +1292,25 @@ body {
width: 100%;
}
/* === STZ Aktions-Buttons === */
.stz-actions {
padding: 8px 0;
}
.warning-box {
background: rgba(255, 193, 7, 0.15);
border: 1px solid rgba(255, 193, 7, 0.4);
border-radius: 8px;
padding: 12px;
font-size: 14px;
line-height: 1.5;
}
.warning-box p {
margin: 0 0 4px 0;
}
.warning-box p:last-child {
margin-bottom: 0;
}
/* Safe-Area fuer Geraete mit Notch */
@supports (padding-bottom: env(safe-area-inset-bottom)) {
.fab {

237
js/pwa.js
View file

@ -464,6 +464,8 @@
self.state.customerName = res.order.customer_name;
self.state.canWrite = res.can_write;
self.state.canEditStz = res.can_edit_stz;
self.state.defaultServiceId = res.order.default_service_id || 0;
self.state.defaultServiceLabel = res.order.default_service_label || '';
if (res.stz) {
self.state.stzId = res.stz.id;
@ -652,6 +654,9 @@
this.renderPanelStundenzettel();
this.renderPanelProducts();
this.renderPanelTracking();
// Scroll-Position aller Panels nach oben zuruecksetzen
$('.swipe-panel').scrollTop(0);
},
// ---- Panel 0: Alle Stundenzettel ----
@ -892,9 +897,30 @@
html += '</div>'; // accordion-section
}
// ---- AKTIONS-BUTTONS (Freigeben / Wiedereroeffnen) ----
if (self.state.canWrite) {
html += '<div class="stz-actions mt-12">';
if (isDraft) {
html += '<button class="btn btn-primary w-full" id="btn-validate-stz">&#9989; Stundenzettel freigeben</button>';
} else {
html += '<button class="btn btn-ghost w-full" id="btn-setdraft-stz">&#128275; Zur\u00fcck auf Entwurf</button>';
}
html += '</div>';
}
$panel.html(html);
// ---- Event-Listener ----
// Freigeben / Wiedereroeffnen
$('#btn-validate-stz').on('click', function() {
self.validateStz();
});
$('#btn-setdraft-stz').on('click', function() {
self.showConfirm('Zur\u00fcck auf Entwurf?', 'Stundenzettel wird wieder bearbeitbar.', 'Zur\u00fcck auf Entwurf').then(function(ok) {
if (ok) self.setDraftStz();
});
});
// Leistungen
$panel.find('.btn-edit-leistung').on('click', function(e) {
e.stopPropagation();
@ -929,6 +955,52 @@
}
});
// Menge direkt bearbeiten (Klick auf Zahl)
$panel.find('.qty-editable').on('click', function(e) {
e.stopPropagation();
var $display = $(this);
if ($display.find('input').length) return; // Bereits im Edit-Modus
var id = $display.data('id');
var qty = parseFloat($display.data('qty'));
var max = parseFloat($display.data('max'));
var qtyStr = qty.toLocaleString('de-DE', {minimumFractionDigits: 0, maximumFractionDigits: 2});
$display.html('<input type="text" inputmode="decimal" class="qty-inline-input" value="' + qtyStr + '">');
var $input = $display.find('input');
$input.focus().select();
var submitQty = function() {
var raw = $input.val().replace(',', '.').trim();
var newQty = parseFloat(raw);
if (isNaN(newQty) || newQty < 0) {
$display.html(self.formatQty(qty));
return;
}
// Auf 2 Dezimalstellen runden
newQty = Math.round(newQty * 100) / 100;
if (newQty === qty) {
$display.html(self.formatQty(qty));
return;
}
if (max > 0 && newQty > max) {
self.showConfirm('Auftragsmenge \u00fcberschritten', 'Auftragsmenge: ' + self.formatQty(max) + '\nNeue Menge: ' + self.formatQty(newQty), 'Trotzdem', 'btn-warning').then(function(ok) {
if (ok) {
self.updateQty(id, newQty);
} else {
$display.html(self.formatQty(qty));
}
});
} else {
self.updateQty(id, newQty);
}
};
$input.on('blur', submitQty);
$input.on('keydown', function(ev) {
if (ev.key === 'Enter') { ev.preventDefault(); $input.blur(); }
if (ev.key === 'Escape') { $input.off('blur'); $display.html(self.formatQty(qty)); }
});
});
// Produkt loeschen
$panel.find('.btn-delete-product').on('click', function(e) {
e.stopPropagation();
@ -1060,7 +1132,7 @@
var activeFilter = self.state.productFilter || 'open';
var isDraft = stz && stz.status == 0;
// Merkzettel-Notizen oben anzeigen (wie stundenzettel_commande.php)
// Merkzettel-Notizen oben anzeigen (Abhaken moeglich, neue Notizen nur auf Panel 1)
if (self.data.notes && self.data.notes.length) {
html += '<div class="merkzettel-box">';
html += '<div class="merkzettel-box-header">';
@ -1083,25 +1155,6 @@
html += '</li>';
});
html += '</ul>';
// Notiz-Input (nur im Entwurf)
if (isDraft && canWrite) {
html += '<div class="merkzettel-box-add">';
html += '<input type="text" id="note-input-p2" placeholder="Neue Notiz...">';
html += '<button class="btn btn-primary btn-icon" id="btn-add-note-p2">+</button>';
html += '</div>';
}
html += '</div>';
} else if (isDraft && canWrite) {
// Leere Box mit nur Input zum Hinzufuegen
html += '<div class="merkzettel-box">';
html += '<div class="merkzettel-box-header">';
html += '<span class="merkzettel-box-icon">&#128203;</span>';
html += '<strong>Merkzettel</strong>';
html += '</div>';
html += '<div class="merkzettel-box-add">';
html += '<input type="text" id="note-input-p2" placeholder="Neue Notiz...">';
html += '<button class="btn btn-primary btn-icon" id="btn-add-note-p2">+</button>';
html += '</div>';
html += '</div>';
}
@ -1295,12 +1348,10 @@
self.showCreateStzDialog();
});
// Merkzettel auf Panel 2: Abhaken + Hinzufuegen
// Merkzettel auf Panel 2: Abhaken moeglich, neue Notizen nur auf Panel 1
$panel.find('.merkzettel-box .note-checkbox').on('click', function() {
self.toggleNote($(this).data('id'), $(this).data('checked'));
});
$('#btn-add-note-p2').on('click', function() { self.addNoteFromPanel2(); });
$('#note-input-p2').on('keypress', function(e) { if (e.which === 13) self.addNoteFromPanel2(); });
},
renderProductCard: function(p, isDraft, canWrite) {
@ -1336,7 +1387,7 @@
if (isDraft && canWrite) {
html += '<div class="qty-controls">';
html += '<button class="qty-btn btn-qty-minus" data-id="' + p.id + '" data-qty="' + p.qty_done + '">&#8722;</button>';
html += '<span class="qty-display">' + self.formatQty(p.qty_done) + '</span>';
html += '<span class="qty-display qty-editable" data-id="' + p.id + '" data-qty="' + p.qty_done + '" data-max="' + (p.qty_original || 9999) + '">' + self.formatQty(p.qty_done) + '</span>';
html += '<button class="qty-btn btn-qty-plus" data-id="' + p.id + '" data-qty="' + p.qty_done + '" data-max="' + (p.qty_original || 9999) + '">+</button>';
html += '</div>';
} else {
@ -1698,6 +1749,129 @@
});
},
// ============================================================
// AKTIONEN: Freigeben / Wiedereroeffnen
// ============================================================
validateStz: function() {
var self = this;
// Pruefen ob Leistungen vorhanden
if (!self.data.leistungen || !self.data.leistungen.length) {
// Keine Leistungen - Warnung mit Option eine hinzuzufuegen
self.showValidateWarningDialog();
return;
}
// Leistungen vorhanden - direkt freigeben
self._doValidateStz();
},
showValidateWarningDialog: function() {
var self = this;
var html = '';
html += '<div class="warning-box mb-12">';
html += '<p><strong>&#9888; Keine Leistungen erfasst!</strong></p>';
html += '<p>F\u00fcr die Rechnungsstellung wird mindestens eine Leistungsposition ben\u00f6tigt.</p>';
html += '</div>';
html += '<div class="form-group"><label>Leistung ausw\u00e4hlen</label>';
html += '<select id="dlg-validate-service" style="width:100%;padding:12px;background:var(--colorbackinput);border:1px solid var(--colorborder);border-radius:8px;color:var(--colortext);font-size:16px;min-height:48px;">';
html += '<option value="">-- Ohne Leistung freigeben --</option>';
// Standard-Service des Kunden vorselektieren
if (self.state.defaultServiceId > 0) {
html += '<option value="' + self.state.defaultServiceId + '" selected>' + self.escHtml(self.state.defaultServiceLabel) + ' (Standard)</option>';
}
html += '</select></div>';
// Dienste laden
self.api('get_services', {}).then(function(res) {
if (res.success && res.services) {
var $select = $('#dlg-validate-service');
res.services.forEach(function(s) {
// Standard-Service nicht doppelt anzeigen
if (s.id == self.state.defaultServiceId) return;
$select.append('<option value="' + s.id + '">' + self.escHtml(s.ref + ' - ' + s.label) + '</option>');
});
}
});
var footer = '';
footer += '<button class="btn btn-primary w-full mb-8" id="dlg-validate-with-leistung">Mit Leistung freigeben</button>';
footer += '<button class="btn btn-ghost w-full" id="dlg-validate-without">Ohne Leistung freigeben</button>';
self.openBottomSheet('Stundenzettel freigeben', html, footer);
$('#dlg-validate-with-leistung').on('click', function() {
var serviceId = $('#dlg-validate-service').val();
if (!serviceId) {
self.showToast('Bitte eine Leistung ausw\u00e4hlen', 'error');
return;
}
self.closeBottomSheet();
// Leistung mit heutigem Datum und Standard-Zeiten anlegen, dann freigeben
var stz = self.data.stz;
var data = {
stz_id: self.state.stzId,
date: stz.date_iso || new Date().toISOString().substr(0, 10),
time_start: '08:00',
time_end: '16:00',
description: '',
fk_product: serviceId
};
self.showLoading();
self.api('add_leistung', data).then(function(res) {
if (res.success) {
self._doValidateStz();
} else {
self.hideLoading();
self.showToast(res.error || 'Fehler', 'error');
}
});
});
$('#dlg-validate-without').on('click', function() {
self.closeBottomSheet();
self._doValidateStz();
});
},
_doValidateStz: function() {
var self = this;
self.showLoading();
self.api('validate_stz', {stz_id: self.state.stzId}).then(function(res) {
self.hideLoading();
if (res.success) {
self.showToast('Stundenzettel freigegeben', 'success');
self.reloadData();
} else {
self.showToast(res.error || 'Fehler beim Freigeben', 'error');
}
}).catch(function() {
self.hideLoading();
self.showToast('Verbindungsfehler', 'error');
});
},
setDraftStz: function() {
var self = this;
self.showLoading();
self.api('setdraft_stz', {stz_id: self.state.stzId}).then(function(res) {
self.hideLoading();
if (res.success) {
self.showToast('Stundenzettel zur\u00fcck auf Entwurf', 'success');
self.reloadData();
} else {
self.showToast(res.error || 'Fehler', 'error');
}
}).catch(function() {
self.hideLoading();
self.showToast('Verbindungsfehler', 'error');
});
},
// ============================================================
// AKTIONEN: Notizen
// ============================================================
@ -1717,21 +1891,6 @@
});
},
addNoteFromPanel2: function() {
var self = this;
var text = $('#note-input-p2').val().trim();
if (!text) return;
self.api('add_note', {stz_id: self.state.stzId, note: text}).then(function(res) {
if (res.success) {
$('#note-input-p2').val('');
self.reloadData();
} else {
self.showToast(res.error || 'Fehler', 'error');
}
});
},
toggleNote: function(id, checked) {
var self = this;
self.api('toggle_note', {note_id: id, checked: checked, stz_id: self.state.stzId}).then(function(res) {

View file

@ -38,7 +38,7 @@ $themeColor = getDolGlobalString('THEME_ELDY_TOPMENU_BACK1', '#4390dc');
<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.8">
<link rel="stylesheet" href="css/pwa.css?v=2.9">
<style>:root { --primary: <?php echo htmlspecialchars($themeColor); ?>; }</style>
</head>
<body>
@ -160,7 +160,7 @@ $themeColor = getDolGlobalString('THEME_ELDY_TOPMENU_BACK1', '#4390dc');
authUrl: '<?php echo dol_buildpath('/stundenzettel/ajax/pwa_auth.php', 1); ?>'
};
</script>
<script src="js/pwa.js?v=2.8"></script>
<script src="js/pwa.js?v=2.9"></script>
<!-- Service Worker Registration -->
<script>