diff --git a/admin/setup.php b/admin/setup.php
old mode 100644
new mode 100755
index 1129faa..c20aea9
--- a/admin/setup.php
+++ b/admin/setup.php
@@ -212,6 +212,28 @@ print '';
print '';
+// PWA Mobile App Bereich
+print ' ';
+print '
';
+print '';
+print ''.$langs->trans("PWAMobileApp").' ';
+print ''.$langs->trans("Value").' ';
+print ' ';
+print ' ';
+
+$pwaUrl = dol_buildpath('/stundenzettel/pwa.php', 2);
+print '';
+print ''.$langs->trans("PWADescription").''.$langs->trans("PWAInstallHint").' ';
+print '';
+print '';
+print $langs->trans("PWALink").' ↗';
+print ' ';
+print ' ';
+print ' ';
+print ' ';
+
+print '
';
+
print dol_get_fiche_end();
llxFooter();
diff --git a/ajax/add_leistung.php b/ajax/add_leistung.php
old mode 100644
new mode 100755
diff --git a/ajax/add_product.php b/ajax/add_product.php
old mode 100644
new mode 100755
diff --git a/ajax/pwa_api.php b/ajax/pwa_api.php
new file mode 100644
index 0000000..959e2e4
--- /dev/null
+++ b/ajax/pwa_api.php
@@ -0,0 +1,1564 @@
+
+ *
+ * Stundenzettel PWA - Zentrale JSON-API
+ * Alle CRUD-Operationen fuer die Mobile App
+ */
+
+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')));
+
+require_once DOL_DOCUMENT_ROOT.'/commande/class/commande.class.php';
+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');
+
+header('Content-Type: application/json; charset=UTF-8');
+
+$action = GETPOST('action', 'aZ09');
+$response = array('success' => false);
+
+// ============================================================
+// TOKEN-VALIDIERUNG
+// ============================================================
+
+$token = GETPOST('token', 'none');
+
+if (empty($token)) {
+ http_response_code(401);
+ echo json_encode(array('success' => false, 'error' => 'Kein Token'));
+ exit;
+}
+
+$tokenData = json_decode(base64_decode($token), true);
+
+if (!$tokenData || empty($tokenData['user_id']) || empty($tokenData['expires'])) {
+ http_response_code(401);
+ echo json_encode(array('success' => false, 'error' => 'Ungueltiges Token'));
+ exit;
+}
+
+if ($tokenData['expires'] < time()) {
+ http_response_code(401);
+ echo json_encode(array('success' => false, 'error' => 'Token abgelaufen'));
+ exit;
+}
+
+$expectedHash = md5($tokenData['user_id'] . $tokenData['login'] . getDolGlobalString('MAIN_SECURITY_SALT', 'defaultsalt'));
+if ($tokenData['hash'] !== $expectedHash) {
+ http_response_code(401);
+ echo json_encode(array('success' => false, 'error' => 'Token manipuliert'));
+ exit;
+}
+
+// Benutzer laden
+$user = new User($db);
+if ($user->fetch($tokenData['user_id']) <= 0 || $user->statut != 1) {
+ http_response_code(401);
+ echo json_encode(array('success' => false, 'error' => 'Benutzer nicht aktiv'));
+ exit;
+}
+$user->getrights();
+
+// Basis-Berechtigung
+if (!$user->hasRight('stundenzettel', 'read')) {
+ http_response_code(403);
+ echo json_encode(array('success' => false, 'error' => 'Keine Berechtigung'));
+ exit;
+}
+
+$langs->loadLangs(array("stundenzettel@stundenzettel", "orders", "products"));
+
+// Schreib-Berechtigung pruefen
+$canWrite = $user->hasRight('stundenzettel', 'write');
+$canWriteAll = $user->hasRight('stundenzettel', 'write', 'all') || $user->admin;
+$canReadAll = $user->hasRight('stundenzettel', 'read', 'all') || $user->admin;
+
+/**
+ * Hilfsfunktion: Mengen formatieren
+ */
+function pwaFormatQty($qty) {
+ $qty = (float)$qty;
+ if ($qty == floor($qty)) {
+ return number_format($qty, 0, ',', '.');
+ }
+ return rtrim(rtrim(number_format($qty, 2, ',', '.'), '0'), ',');
+}
+
+/**
+ * Hilfsfunktion: Pruefen ob User Schreibzugriff auf STZ hat
+ */
+function canEditStz($stz, $user, $canWriteAll) {
+ if ($stz->status != Stundenzettel::STATUS_DRAFT) return false;
+ if ($stz->fk_user_author == $user->id) return true;
+ if ($canWriteAll) return true;
+ return false;
+}
+
+/**
+ * Hilfsfunktion: Status-Label zurueckgeben
+ */
+function getStatusLabel($status) {
+ switch ((int)$status) {
+ case 0: return 'Entwurf';
+ case 1: return 'Freigegeben';
+ case 2: return 'Abgerechnet';
+ case 9: return 'Storniert';
+ default: return 'Unbekannt';
+ }
+}
+
+// ============================================================
+// API ACTIONS
+// ============================================================
+
+switch ($action) {
+
+ // ---- Kundensuche ----
+ case 'search_customers':
+ $term = GETPOST('term', 'alphanohtml');
+ if (strlen($term) < 2) {
+ $response['error'] = 'Mindestens 2 Zeichen';
+ break;
+ }
+
+ $sql = "SELECT s.rowid as id, s.nom as name, COUNT(DISTINCT c.rowid) as order_count";
+ $sql .= " FROM ".MAIN_DB_PREFIX."societe as s";
+ $sql .= " INNER JOIN ".MAIN_DB_PREFIX."commande as c ON c.fk_soc = s.rowid";
+ $sql .= " WHERE s.nom LIKE '%".$db->escape($term)."%'";
+ $sql .= " AND c.fk_statut = 1"; // Nur validierte (laufende) Auftraege
+ $sql .= " AND s.entity IN (".getEntity('societe').")";
+ $sql .= " GROUP BY s.rowid, s.nom";
+ $sql .= " ORDER BY s.nom ASC";
+ $sql .= " LIMIT 20";
+
+ $resql = $db->query($sql);
+ $customers = array();
+ if ($resql) {
+ while ($obj = $db->fetch_object($resql)) {
+ $customers[] = array(
+ 'id' => (int)$obj->id,
+ 'name' => $obj->name,
+ 'order_count' => (int)$obj->order_count
+ );
+ }
+ }
+ $response['success'] = true;
+ $response['customers'] = $customers;
+ break;
+
+ // ---- Auftraege eines Kunden ----
+ case 'get_customer_orders':
+ $customerId = GETPOST('customer_id', 'int');
+ if ($customerId <= 0) {
+ $response['error'] = 'Keine Kunden-ID';
+ break;
+ }
+
+ $sql = "SELECT c.rowid as id, c.ref, c.ref_client, c.date_commande, c.fk_statut as status";
+ $sql .= " FROM ".MAIN_DB_PREFIX."commande as c";
+ $sql .= " WHERE c.fk_soc = ".((int)$customerId);
+ $sql .= " AND c.fk_statut = 1"; // Nur validierte (laufende) Auftraege, nicht geliefert/fakturiert
+ $sql .= " AND c.entity IN (".getEntity('commande').")";
+ $sql .= " ORDER BY c.date_commande DESC, c.rowid DESC";
+
+ $resql = $db->query($sql);
+ $orders = array();
+ if ($resql) {
+ while ($obj = $db->fetch_object($resql)) {
+ // Offener Stundenzettel pruefen
+ $sqlStz = "SELECT rowid FROM ".MAIN_DB_PREFIX."stundenzettel";
+ $sqlStz .= " WHERE fk_commande = ".((int)$obj->id);
+ $sqlStz .= " AND status = 0";
+ if (!$canReadAll) {
+ $sqlStz .= " AND fk_user_author = ".((int)$user->id);
+ }
+ $sqlStz .= " ORDER BY date_stundenzettel DESC LIMIT 1";
+ $resStz = $db->query($sqlStz);
+ $hasDraftStz = false;
+ $draftStzId = 0;
+ if ($resStz && $db->num_rows($resStz) > 0) {
+ $objStz = $db->fetch_object($resStz);
+ $hasDraftStz = true;
+ $draftStzId = (int)$objStz->rowid;
+ }
+
+ $orders[] = array(
+ 'id' => (int)$obj->id,
+ 'ref' => $obj->ref,
+ 'ref_client' => $obj->ref_client ?: '',
+ 'date' => dol_print_date($db->jdate($obj->date_commande), 'day'),
+ 'status' => (int)$obj->status,
+ 'has_draft_stz' => $hasDraftStz,
+ 'draft_stz_id' => $draftStzId
+ );
+ }
+ }
+ $response['success'] = true;
+ $response['orders'] = $orders;
+ break;
+
+ // ---- Komplett-Kontext fuer einen Auftrag ----
+ case 'get_order_context':
+ $orderId = GETPOST('order_id', 'int');
+ $stzId = GETPOST('stz_id', 'int');
+
+ if ($orderId <= 0) {
+ $response['error'] = 'Keine Auftrags-ID';
+ break;
+ }
+
+ // Auftrag laden
+ $order = new Commande($db);
+ if ($order->fetch($orderId) <= 0) {
+ $response['error'] = 'Auftrag nicht gefunden';
+ break;
+ }
+
+ // Kunde laden
+ $customer = new Societe($db);
+ $customer->fetch($order->socid);
+
+ // Auftragsdaten
+ $response['order'] = array(
+ 'id' => (int)$order->id,
+ 'ref' => $order->ref,
+ 'date' => dol_print_date($order->date_commande, 'day'),
+ 'customer_name' => $customer->name,
+ 'customer_id' => (int)$customer->id,
+ 'status' => (int)$order->statut
+ );
+
+ // Stundenzettel finden (per ID oder letzten Draft)
+ $stz = new Stundenzettel($db);
+ $stzFound = false;
+
+ if ($stzId > 0) {
+ if ($stz->fetch($stzId) > 0) {
+ $stzFound = true;
+ }
+ }
+
+ if (!$stzFound) {
+ // Letzten Draft fuer diesen Auftrag suchen
+ $sqlStz = "SELECT rowid FROM ".MAIN_DB_PREFIX."stundenzettel";
+ $sqlStz .= " WHERE fk_commande = ".((int)$orderId);
+ if (!$canReadAll) {
+ $sqlStz .= " AND fk_user_author = ".((int)$user->id);
+ }
+ $sqlStz .= " ORDER BY status ASC, date_stundenzettel DESC, rowid DESC";
+ $sqlStz .= " LIMIT 1";
+ $resStz = $db->query($sqlStz);
+ if ($resStz && $db->num_rows($resStz) > 0) {
+ $objStz = $db->fetch_object($resStz);
+ $stz->fetch($objStz->rowid);
+ $stzFound = true;
+ }
+ }
+
+ // Prüfe ob User den aktuellen STZ editieren kann
+ $canEdit = false;
+ if ($stzFound) {
+ $canEdit = canEditStz($stz, $user, $canWriteAll);
+ }
+ $response['can_write'] = $canWrite; // Allgemeine Schreibberechtigung
+ $response['can_edit_stz'] = $canWrite && $canEdit; // Aktuellen STZ editierbar
+
+ // STZ-Daten
+ if ($stzFound) {
+ $response['stz'] = array(
+ 'id' => (int)$stz->id,
+ 'ref' => $stz->ref,
+ 'date' => dol_print_date($stz->date_stundenzettel, 'day'),
+ 'date_iso' => dol_print_date($stz->date_stundenzettel, '%Y-%m-%d'),
+ 'status' => (int)$stz->status,
+ 'status_label' => getStatusLabel($stz->status),
+ 'hourly_rate' => $stz->hourly_rate,
+ 'fk_user_author' => (int)$stz->fk_user_author
+ );
+
+ // Produkte laden
+ $stz->fetchProducts();
+ $products = array();
+ foreach ($stz->products as $p) {
+ $products[] = array(
+ 'id' => (int)$p->rowid,
+ 'fk_product' => (int)$p->fk_product,
+ 'fk_commandedet' => (int)$p->fk_commandedet,
+ 'ref' => $p->product_ref,
+ 'label' => $p->product_label,
+ 'description' => strip_tags($p->description),
+ 'qty_original' => (float)$p->qty_original,
+ 'qty_done' => (float)$p->qty_done,
+ 'origin' => $p->origin
+ );
+ }
+ $response['products'] = $products;
+
+ // Leistungen laden
+ $stz->fetchLeistungen();
+ $leistungen = array();
+ foreach ($stz->leistungen as $l) {
+ $leistungen[] = array(
+ 'id' => (int)$l->rowid,
+ 'date' => dol_print_date($db->jdate($l->date_leistung), 'day'),
+ 'date_iso' => dol_print_date($db->jdate($l->date_leistung), '%Y-%m-%d'),
+ 'time_start' => substr($l->time_start, 0, 5),
+ 'time_end' => substr($l->time_end, 0, 5),
+ 'duration_minutes' => (int)$l->duration,
+ 'description' => strip_tags($l->description ?: ''),
+ 'service_name' => $l->product_label ?: '',
+ 'fk_product' => (int)$l->fk_product
+ );
+ }
+ $response['leistungen'] = $leistungen;
+
+ // Notizen laden
+ $stz->fetchNotes();
+ $notes = array();
+ foreach ($stz->notes as $n) {
+ $notes[] = array(
+ 'id' => (int)$n->rowid,
+ 'note' => $n->note,
+ 'checked' => (int)$n->checked,
+ 'date' => dol_print_date($db->jdate($n->datec), 'dayhour')
+ );
+ }
+ $response['notes'] = $notes;
+ } else {
+ $response['stz'] = null;
+ $response['products'] = array();
+ $response['leistungen'] = array();
+ $response['notes'] = array();
+ }
+
+ // Tracking-Daten (Lieferauflistung) - Live-Berechnung wie Desktop
+ // Auftragspositionen mit Verbaut/Mehraufwand/Entfaellt/Ruecknahme berechnen
+ $tracking = array();
+ $sqlTrack = "SELECT cd.rowid, cd.fk_product, cd.qty as qty_ordered, cd.description,";
+ $sqlTrack .= " p.ref as product_ref, p.label as product_label,";
+ // Verbaut (origin order/added)
+ $sqlTrack .= " COALESCE((SELECT SUM(sp.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
+ $sqlTrack .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
+ $sqlTrack .= " WHERE sp.fk_commandedet = cd.rowid AND sp.origin IN ('order', 'added')), 0) as qty_delivered,";
+ // Mehraufwand
+ $sqlTrack .= " COALESCE((SELECT SUM(sp2.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp2";
+ $sqlTrack .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s2 ON s2.rowid = sp2.fk_stundenzettel";
+ $sqlTrack .= " WHERE sp2.fk_product = cd.fk_product AND cd.fk_product > 0 AND sp2.origin = 'additional' AND s2.fk_commande = ".((int)$orderId)."), 0) as qty_additional,";
+ // Entfaellt
+ $sqlTrack .= " COALESCE((SELECT SUM(sp3.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp3";
+ $sqlTrack .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s3 ON s3.rowid = sp3.fk_stundenzettel";
+ $sqlTrack .= " WHERE sp3.origin = 'omitted' AND s3.fk_commande = ".((int)$orderId);
+ $sqlTrack .= " AND ((sp3.fk_product = cd.fk_product AND cd.fk_product > 0) OR sp3.fk_commandedet = cd.rowid)), 0) as qty_omitted,";
+ // Ruecknahmen
+ $sqlTrack .= " COALESCE((SELECT SUM(sp4.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp4";
+ $sqlTrack .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s4 ON s4.rowid = sp4.fk_stundenzettel";
+ $sqlTrack .= " WHERE sp4.origin = 'returned' AND s4.fk_commande = ".((int)$orderId);
+ $sqlTrack .= " AND ((sp4.fk_product = cd.fk_product AND cd.fk_product > 0) OR sp4.fk_commandedet = cd.rowid)), 0) as qty_returned";
+ $sqlTrack .= " FROM ".MAIN_DB_PREFIX."commandedet as cd";
+ $sqlTrack .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product";
+ $sqlTrack .= " WHERE cd.fk_commande = ".((int)$orderId);
+ $sqlTrack .= " AND (cd.fk_product > 0 OR ((cd.fk_product IS NULL OR cd.fk_product = 0) AND cd.description IS NOT NULL AND cd.description != ''))";
+ $sqlTrack .= " AND (cd.special_code IS NULL OR cd.special_code = 0)";
+ $sqlTrack .= " ORDER BY cd.rang";
+
+ $resTrack = $db->query($sqlTrack);
+ if ($resTrack) {
+ while ($obj = $db->fetch_object($resTrack)) {
+ $qtyAdditional = (float)$obj->qty_additional;
+ $qtyOmitted = (float)$obj->qty_omitted;
+ $qtyReturned = (float)$obj->qty_returned;
+ // Effektiv bestellt = Original + Mehraufwand - Entfaellt - Ruecknahmen
+ $effectiveOrdered = $obj->qty_ordered + $qtyAdditional - $qtyOmitted - $qtyReturned;
+ // Effektiv verbaut = Verbaut - Ruecknahmen
+ $effectiveDelivered = $obj->qty_delivered - $qtyReturned;
+ $qtyRemaining = $effectiveOrdered - $effectiveDelivered;
+
+ $label = '';
+ if ($obj->product_ref) {
+ $label = $obj->product_ref.' - '.$obj->product_label;
+ } else {
+ $label = strip_tags($obj->description ?: '');
+ if (strlen($label) > 80) $label = substr($label, 0, 77).'...';
+ }
+
+ $tracking[] = array(
+ 'ref' => $obj->product_ref ?: '',
+ 'label' => $label,
+ 'qty_ordered' => (float)$effectiveOrdered,
+ 'qty_delivered' => (float)$effectiveDelivered,
+ 'qty_remaining' => (float)$qtyRemaining,
+ 'qty_additional' => $qtyAdditional,
+ 'qty_omitted' => $qtyOmitted,
+ 'qty_returned' => $qtyReturned
+ );
+ }
+ }
+ $response['tracking'] = $tracking;
+
+ // Leistungen/Arbeitsstunden gruppiert nach Service (fuer Lieferauflistung)
+ $leistungenSummary = array();
+ $sqlLeist = "SELECT COALESCE(l.fk_product, 0) as service_id,";
+ $sqlLeist .= " p.ref as service_ref, p.label as service_label,";
+ $sqlLeist .= " SUM(l.duration) as total_minutes, COUNT(l.rowid) as entry_count";
+ $sqlLeist .= " FROM ".MAIN_DB_PREFIX."stundenzettel_leistung l";
+ $sqlLeist .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = l.fk_stundenzettel";
+ $sqlLeist .= " LEFT JOIN ".MAIN_DB_PREFIX."product p ON p.rowid = l.fk_product";
+ $sqlLeist .= " WHERE s.fk_commande = ".((int)$orderId);
+ if (!$canReadAll) {
+ $sqlLeist .= " AND s.fk_user_author = ".((int)$user->id);
+ }
+ $sqlLeist .= " GROUP BY COALESCE(l.fk_product, 0), p.ref, p.label";
+ $sqlLeist .= " ORDER BY p.ref, p.label";
+
+ $resLeist = $db->query($sqlLeist);
+ if ($resLeist) {
+ while ($obj = $db->fetch_object($resLeist)) {
+ $totalMin = (int)$obj->total_minutes;
+ $h = floor($totalMin / 60);
+ $m = $totalMin % 60;
+ $leistungenSummary[] = array(
+ 'service_ref' => $obj->service_ref ?: '',
+ 'service_label' => $obj->service_label ?: 'Nicht zugeordnet',
+ 'total_minutes' => $totalMin,
+ 'total_hours' => sprintf('%d:%02d h', $h, $m),
+ 'entry_count' => (int)$obj->entry_count
+ );
+ }
+ }
+ $response['leistungen_summary'] = $leistungenSummary;
+
+ // Einzelne Leistungen aller STZ (fuer Lieferauflistung Detail)
+ $leistungenAll = array();
+ $sqlLeistAll = "SELECT l.rowid, l.fk_stundenzettel, l.date_leistung, l.time_start, l.time_end,";
+ $sqlLeistAll .= " l.duration, l.description, s.ref as stz_ref,";
+ $sqlLeistAll .= " p.ref as service_ref, p.label as service_label";
+ $sqlLeistAll .= " FROM ".MAIN_DB_PREFIX."stundenzettel_leistung l";
+ $sqlLeistAll .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = l.fk_stundenzettel";
+ $sqlLeistAll .= " LEFT JOIN ".MAIN_DB_PREFIX."product p ON p.rowid = l.fk_product";
+ $sqlLeistAll .= " WHERE s.fk_commande = ".((int)$orderId);
+ if (!$canReadAll) {
+ $sqlLeistAll .= " AND s.fk_user_author = ".((int)$user->id);
+ }
+ $sqlLeistAll .= " ORDER BY s.date_stundenzettel DESC, l.date_leistung, l.time_start";
+
+ $resLeistAll = $db->query($sqlLeistAll);
+ if ($resLeistAll) {
+ while ($obj = $db->fetch_object($resLeistAll)) {
+ $dur = (int)$obj->duration;
+ $h = floor($dur / 60);
+ $m = $dur % 60;
+ $leistungenAll[] = array(
+ 'stz_ref' => $obj->stz_ref,
+ 'date' => dol_print_date($db->jdate($obj->date_leistung), 'day'),
+ 'time_start' => $obj->time_start ? substr($obj->time_start, 0, 5) : '',
+ 'time_end' => $obj->time_end ? substr($obj->time_end, 0, 5) : '',
+ 'duration' => sprintf('%d:%02d h', $h, $m),
+ 'service' => $obj->service_ref ? $obj->service_ref : '',
+ 'description' => strip_tags($obj->description ?: '')
+ );
+ }
+ }
+ $response['leistungen_all'] = $leistungenAll;
+
+ // Alle STZ fuer diesen Auftrag
+ $stzList = array();
+ $sqlList = "SELECT s.rowid, s.ref, s.date_stundenzettel, s.status, s.fk_user_author,";
+ $sqlList .= " (SELECT COUNT(*) FROM ".MAIN_DB_PREFIX."stundenzettel_leistung WHERE fk_stundenzettel = s.rowid) as leistung_count,";
+ $sqlList .= " (SELECT COALESCE(SUM(duration), 0) FROM ".MAIN_DB_PREFIX."stundenzettel_leistung WHERE fk_stundenzettel = s.rowid) as total_minutes,";
+ $sqlList .= " (SELECT COUNT(*) FROM ".MAIN_DB_PREFIX."stundenzettel_product WHERE fk_stundenzettel = s.rowid AND origin IN ('order','added')) as product_count";
+ $sqlList .= " FROM ".MAIN_DB_PREFIX."stundenzettel as s";
+ $sqlList .= " WHERE s.fk_commande = ".((int)$orderId);
+ if (!$canReadAll) {
+ $sqlList .= " AND s.fk_user_author = ".((int)$user->id);
+ }
+ $sqlList .= " ORDER BY s.date_stundenzettel DESC, s.rowid DESC";
+
+ $resList = $db->query($sqlList);
+ if ($resList) {
+ while ($obj = $db->fetch_object($resList)) {
+ $totalMin = (int)$obj->total_minutes;
+ $h = floor($totalMin / 60);
+ $m = $totalMin % 60;
+ $totalHours = ($h > 0 ? $h.'h' : '') . ($m > 0 ? ' '.$m.'min' : '');
+ if (!$totalHours) $totalHours = '0h';
+
+ $stzList[] = array(
+ 'id' => (int)$obj->rowid,
+ 'ref' => $obj->ref,
+ 'date' => dol_print_date($db->jdate($obj->date_stundenzettel), 'day'),
+ 'status' => (int)$obj->status,
+ 'status_label' => getStatusLabel($obj->status),
+ 'leistung_count' => (int)$obj->leistung_count,
+ 'total_hours' => trim($totalHours),
+ 'product_count' => (int)$obj->product_count
+ );
+ }
+ }
+ $response['stz_list'] = $stzList;
+
+ // Auftragspositionen fuer Produktliste-Tab (Uebernahme in STZ)
+ // Live-Berechnung wie im Desktop (stundenzettel_commande.php)
+ $orderLines = array();
+ $sqlOL = "SELECT cd.rowid, cd.fk_product, cd.qty, cd.description,";
+ $sqlOL .= " p.ref as product_ref, p.label as product_label,";
+ // Verbaut (origin order/added)
+ $sqlOL .= " COALESCE((SELECT SUM(sp.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
+ $sqlOL .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
+ $sqlOL .= " WHERE sp.fk_commandedet = cd.rowid AND sp.origin IN ('order', 'added')), 0) as qty_delivered,";
+ // Mehraufwand (origin additional)
+ $sqlOL .= " COALESCE((SELECT SUM(sp2.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp2";
+ $sqlOL .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s2 ON s2.rowid = sp2.fk_stundenzettel";
+ $sqlOL .= " WHERE sp2.fk_product = cd.fk_product AND cd.fk_product > 0 AND sp2.origin = 'additional' AND s2.fk_commande = ".((int)$orderId)."), 0) as qty_additional,";
+ // Entfaellt
+ $sqlOL .= " COALESCE((SELECT SUM(sp3.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp3";
+ $sqlOL .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s3 ON s3.rowid = sp3.fk_stundenzettel";
+ $sqlOL .= " WHERE sp3.origin = 'omitted' AND s3.fk_commande = ".((int)$orderId);
+ $sqlOL .= " AND ((sp3.fk_product = cd.fk_product AND cd.fk_product > 0) OR sp3.fk_commandedet = cd.rowid)), 0) as qty_omitted,";
+ // Ruecknahmen
+ $sqlOL .= " COALESCE((SELECT SUM(sp4.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp4";
+ $sqlOL .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s4 ON s4.rowid = sp4.fk_stundenzettel";
+ $sqlOL .= " WHERE sp4.origin = 'returned' AND s4.fk_commande = ".((int)$orderId);
+ $sqlOL .= " AND ((sp4.fk_product = cd.fk_product AND cd.fk_product > 0) OR sp4.fk_commandedet = cd.rowid)), 0) as qty_returned";
+ $sqlOL .= " FROM ".MAIN_DB_PREFIX."commandedet as cd";
+ $sqlOL .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product";
+ $sqlOL .= " WHERE cd.fk_commande = ".((int)$orderId);
+ $sqlOL .= " AND (cd.fk_product > 0 OR ((cd.fk_product IS NULL OR cd.fk_product = 0) AND cd.description IS NOT NULL AND cd.description != ''))";
+ $sqlOL .= " AND (cd.special_code IS NULL OR cd.special_code = 0)";
+ $sqlOL .= " ORDER BY cd.rang";
+
+ $resOL = $db->query($sqlOL);
+ if ($resOL) {
+ while ($obj = $db->fetch_object($resOL)) {
+ $label = '';
+ if ($obj->product_ref) {
+ $label = $obj->product_ref.' - '.$obj->product_label;
+ } else {
+ $label = strip_tags($obj->description ?: '');
+ if (strlen($label) > 80) $label = substr($label, 0, 77).'...';
+ }
+
+ $qtyDelivered = (float)$obj->qty_delivered;
+ $qtyAdditional = (float)$obj->qty_additional;
+ $qtyOmitted = (float)$obj->qty_omitted;
+ $qtyReturned = (float)$obj->qty_returned;
+ $effectiveDelivered = $qtyDelivered - $qtyReturned;
+ // Effektive Gesamtmenge = Original + Mehraufwand - Entfaellt - Ruecknahmen (wie Desktop)
+ $effectiveOrdered = $obj->qty + $qtyAdditional - $qtyOmitted - $qtyReturned;
+ $qtyRemaining = $effectiveOrdered - $effectiveDelivered;
+
+ // Pruefen ob bereits auf dem aktuellen STZ
+ $alreadyOnStz = false;
+ if ($stzFound) {
+ foreach ($stz->products as $sp) {
+ if ($sp->fk_commandedet == $obj->rowid && in_array($sp->origin, array('order', 'added'))) {
+ $alreadyOnStz = true;
+ break;
+ }
+ }
+ }
+
+ $orderLines[] = array(
+ 'id' => (int)$obj->rowid,
+ 'fk_product' => (int)$obj->fk_product,
+ 'ref' => $obj->product_ref ?: '',
+ 'label' => $label,
+ 'description' => strip_tags($obj->description ?: ''),
+ 'qty' => (float)$obj->qty,
+ 'qty_effective' => (float)$effectiveOrdered,
+ 'qty_delivered' => $effectiveDelivered,
+ 'qty_remaining' => $qtyRemaining,
+ 'qty_additional' => $qtyAdditional,
+ 'qty_omitted' => $qtyOmitted,
+ 'qty_returned' => $qtyReturned,
+ 'already_on_stz' => $alreadyOnStz
+ );
+ }
+ }
+ $response['order_lines'] = $orderLines;
+
+ // Mehraufwand-Produkte laden (nicht aus Auftrag, wie Desktop stundenzettel_commande.php)
+ // origin='additional' (Mehraufwand-Bestellung) + origin='added' ohne fk_commandedet (manuell hinzugefuegt)
+ $mehraufwandLines = array();
+
+ // Ruecknahmen laden fuer Zuordnung
+ $returnedProducts = array();
+ $sqlRet = "SELECT sp.fk_product, sp.product_label, sp.description, SUM(sp.qty_done) as qty_returned";
+ $sqlRet .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
+ $sqlRet .= " INNER JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
+ $sqlRet .= " WHERE s.fk_commande = ".((int)$orderId);
+ $sqlRet .= " AND sp.origin = 'returned'";
+ $sqlRet .= " GROUP BY sp.fk_product, sp.product_label, sp.description";
+ $resRet = $db->query($sqlRet);
+ if ($resRet) {
+ while ($objRet = $db->fetch_object($resRet)) {
+ $key = '';
+ if ($objRet->fk_product > 0) {
+ $key = 'prod_'.$objRet->fk_product;
+ } else {
+ $key = 'desc_'.md5(trim(strip_tags($objRet->description)));
+ }
+ $returnedProducts[$key] = (float)$objRet->qty_returned;
+ }
+ }
+
+ // Mehraufwand-Produkte
+ $sqlMA = "SELECT sp.fk_product, sp.product_ref, sp.product_label, sp.description,";
+ $sqlMA .= " SUM(CASE WHEN sp.origin = 'additional' THEN sp.qty_done ELSE 0 END) as qty_additional,";
+ $sqlMA .= " SUM(CASE WHEN sp.origin = 'added' AND (sp.fk_commandedet IS NULL OR sp.fk_commandedet = 0) THEN sp.qty_done ELSE 0 END) as qty_added,";
+ $sqlMA .= " GROUP_CONCAT(DISTINCT s.ref SEPARATOR ', ') as stundenzettel_refs";
+ $sqlMA .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
+ $sqlMA .= " INNER JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
+ $sqlMA .= " WHERE s.fk_commande = ".((int)$orderId);
+ $sqlMA .= " AND (sp.origin = 'additional' OR (sp.origin = 'added' AND (sp.fk_commandedet IS NULL OR sp.fk_commandedet = 0)))";
+ $sqlMA .= " GROUP BY sp.fk_product, sp.product_ref, sp.product_label, sp.description";
+ $sqlMA .= " ORDER BY sp.product_ref, sp.description";
+
+ $resMA = $db->query($sqlMA);
+ if ($resMA) {
+ while ($objMA = $db->fetch_object($resMA)) {
+ $qtyAdditional = (float)$objMA->qty_additional;
+ $qtyAdded = (float)$objMA->qty_added;
+
+ // Ruecknahmen zuordnen
+ $returnKey = ($objMA->fk_product > 0) ? 'prod_'.$objMA->fk_product : 'desc_'.md5(trim(strip_tags($objMA->description)));
+ $qtyReturned = isset($returnedProducts[$returnKey]) ? $returnedProducts[$returnKey] : 0;
+
+ // Beauftragt (Zielmenge) und Verbaut berechnen (wie Desktop)
+ $qtyTarget = (($qtyAdditional > 0) ? $qtyAdditional : $qtyAdded) - $qtyReturned;
+ $qtyDone = $qtyAdded - $qtyReturned;
+ $qtyRemaining = $qtyTarget - $qtyDone;
+ $isDone = ($qtyRemaining <= 0) || ($qtyTarget <= 0 && $qtyReturned > 0);
+
+ // Label zusammenbauen
+ $label = '';
+ if ($objMA->product_ref) {
+ $label = $objMA->product_ref.' - '.$objMA->product_label;
+ } else {
+ $label = strip_tags($objMA->description ?: '');
+ if (strlen($label) > 80) $label = substr($label, 0, 77).'...';
+ }
+
+ $mehraufwandLines[] = array(
+ 'fk_product' => (int)$objMA->fk_product,
+ 'ref' => $objMA->product_ref ?: '',
+ 'label' => $label,
+ 'description' => strip_tags($objMA->description ?: ''),
+ 'qty_target' => (float)$qtyTarget,
+ 'qty_done' => (float)$qtyDone,
+ 'qty_remaining' => (float)$qtyRemaining,
+ 'qty_returned' => (float)$qtyReturned,
+ 'stz_refs' => $objMA->stundenzettel_refs ?: '',
+ 'is_done' => $isDone
+ );
+ }
+ }
+ $response['mehraufwand_lines'] = $mehraufwandLines;
+
+ $response['success'] = true;
+ break;
+
+ // ---- Neuen Stundenzettel erstellen ----
+ case 'create_stundenzettel':
+ if (!$canWrite) {
+ $response['error'] = 'Keine Schreibberechtigung';
+ break;
+ }
+
+ $orderId = GETPOST('order_id', 'int');
+ $date = GETPOST('date', 'alpha');
+
+ if ($orderId <= 0 || empty($date)) {
+ $response['error'] = 'Auftrags-ID und Datum erforderlich';
+ break;
+ }
+
+ // Auftrag pruefen
+ $order = new Commande($db);
+ if ($order->fetch($orderId) <= 0) {
+ $response['error'] = 'Auftrag nicht gefunden';
+ break;
+ }
+
+ // Prüfe ob fuer dieses Datum bereits ein STZ existiert
+ $sqlCheck = "SELECT rowid FROM ".MAIN_DB_PREFIX."stundenzettel";
+ $sqlCheck .= " WHERE fk_commande = ".((int)$orderId);
+ $sqlCheck .= " AND date_stundenzettel = '".$db->escape($date)."'";
+ $sqlCheck .= " AND fk_user_author = ".((int)$user->id);
+ $resCheck = $db->query($sqlCheck);
+ if ($resCheck && $db->num_rows($resCheck) > 0) {
+ $objExist = $db->fetch_object($resCheck);
+ $response['success'] = true;
+ $response['stz_id'] = (int)$objExist->rowid;
+ $response['message'] = 'Stundenzettel fuer dieses Datum existiert bereits';
+ break;
+ }
+
+ $stz = new Stundenzettel($db);
+ $stz->fk_commande = $orderId;
+ $stz->fk_soc = $order->socid;
+ $stz->date_stundenzettel = $db->idate(strtotime($date));
+ $stz->fk_user_author = $user->id;
+
+ $result = $stz->create($user);
+ if ($result > 0) {
+ $response['success'] = true;
+ $response['stz_id'] = (int)$result;
+ } else {
+ $response['error'] = $stz->error ?: 'Fehler beim Erstellen';
+ }
+ break;
+
+ // ---- Leistung hinzufuegen ----
+ case 'add_leistung':
+ $stzId = GETPOST('stz_id', 'int');
+ $date = GETPOST('date', 'alpha');
+ $timeStart = GETPOST('time_start', 'alpha');
+ $timeEnd = GETPOST('time_end', 'alpha');
+ $description = GETPOST('description', 'restricthtml');
+ $fkProduct = GETPOST('fk_product', 'int');
+
+ $stz = new Stundenzettel($db);
+ if ($stz->fetch($stzId) <= 0) {
+ $response['error'] = 'Stundenzettel nicht gefunden';
+ break;
+ }
+
+ if (!canEditStz($stz, $user, $canWriteAll)) {
+ $response['error'] = 'Keine Berechtigung oder nicht im Entwurf';
+ break;
+ }
+
+ $result = $stz->addLeistung($user, $date, $timeStart, $timeEnd, $description, $fkProduct > 0 ? $fkProduct : null);
+ if ($result > 0) {
+ $response['success'] = true;
+ $response['leistung_id'] = (int)$result;
+ } else {
+ $response['error'] = $stz->error ?: 'Fehler beim Hinzufuegen';
+ }
+ break;
+
+ // ---- Leistung aktualisieren ----
+ case 'update_leistung':
+ $stzId = GETPOST('stz_id', 'int');
+ $leistungId = GETPOST('leistung_id', 'int');
+ $date = GETPOST('date', 'alpha');
+ $timeStart = GETPOST('time_start', 'alpha');
+ $timeEnd = GETPOST('time_end', 'alpha');
+ $description = GETPOST('description', 'restricthtml');
+ $fkProduct = GETPOST('fk_product', 'int');
+
+ $stz = new Stundenzettel($db);
+ if ($stz->fetch($stzId) <= 0) {
+ $response['error'] = 'Stundenzettel nicht gefunden';
+ break;
+ }
+
+ if (!canEditStz($stz, $user, $canWriteAll)) {
+ $response['error'] = 'Keine Berechtigung oder nicht im Entwurf';
+ break;
+ }
+
+ $result = $stz->updateLeistung($leistungId, $date, $timeStart, $timeEnd, $description, $fkProduct > 0 ? $fkProduct : null);
+ if ($result > 0) {
+ $response['success'] = true;
+ } else {
+ $response['error'] = $stz->error ?: 'Fehler beim Aktualisieren';
+ }
+ break;
+
+ // ---- Leistung loeschen ----
+ case 'delete_leistung':
+ $stzId = GETPOST('stz_id', 'int');
+ $leistungId = GETPOST('leistung_id', 'int');
+
+ $stz = new Stundenzettel($db);
+ if ($stz->fetch($stzId) <= 0) {
+ $response['error'] = 'Stundenzettel nicht gefunden';
+ break;
+ }
+
+ if (!canEditStz($stz, $user, $canWriteAll)) {
+ $response['error'] = 'Keine Berechtigung oder nicht im Entwurf';
+ break;
+ }
+
+ $result = $stz->deleteLeistung($leistungId);
+ if ($result > 0) {
+ $response['success'] = true;
+ } else {
+ $response['error'] = 'Fehler beim Loeschen';
+ }
+ break;
+
+ // ---- Produktmenge aktualisieren ----
+ case 'update_qty':
+ $stzId = GETPOST('stz_id', 'int');
+ $lineId = GETPOST('line_id', 'int');
+ $qtyDone = GETPOST('qty_done', 'alpha');
+
+ $stz = new Stundenzettel($db);
+ if ($stz->fetch($stzId) <= 0) {
+ $response['error'] = 'Stundenzettel nicht gefunden';
+ break;
+ }
+
+ if (!canEditStz($stz, $user, $canWriteAll)) {
+ $response['error'] = 'Keine Berechtigung oder nicht im Entwurf';
+ break;
+ }
+
+ $result = $stz->updateProductQty($lineId, (float)$qtyDone);
+ if ($result > 0) {
+ $response['success'] = true;
+ } else {
+ $response['error'] = 'Fehler beim Aktualisieren';
+ }
+ break;
+
+ // ---- Produkt hinzufuegen ----
+ case 'add_product':
+ $stzId = GETPOST('stz_id', 'int');
+ $fkProduct = GETPOST('fk_product', 'int');
+ $qty = (float)GETPOST('qty', 'alpha');
+ $description = GETPOST('description', 'restricthtml');
+
+ $stz = new Stundenzettel($db);
+ if ($stz->fetch($stzId) <= 0) {
+ $response['error'] = 'Stundenzettel nicht gefunden';
+ break;
+ }
+
+ if (!canEditStz($stz, $user, $canWriteAll)) {
+ $response['error'] = 'Keine Berechtigung oder nicht im Entwurf';
+ break;
+ }
+
+ if ($fkProduct <= 0 && empty($description)) {
+ $response['error'] = 'Produkt oder Beschreibung erforderlich';
+ break;
+ }
+
+ // Pruefen ob Produkt im Auftrag vorhanden (fk_commandedet finden)
+ $fkCommandedet = 0;
+ if ($fkProduct > 0 && $stz->fk_commande > 0) {
+ $sqlCd = "SELECT cd.rowid FROM ".MAIN_DB_PREFIX."commandedet as cd";
+ $sqlCd .= " WHERE cd.fk_commande = ".((int)$stz->fk_commande);
+ $sqlCd .= " AND cd.fk_product = ".((int)$fkProduct);
+ $sqlCd .= " LIMIT 1";
+ $resCd = $db->query($sqlCd);
+ if ($resCd && $db->num_rows($resCd) > 0) {
+ $objCd = $db->fetch_object($resCd);
+ $fkCommandedet = (int)$objCd->rowid;
+ }
+ }
+
+ // Pruefen ob bereits eine Zeile mit gleichem Produkt existiert -> addieren
+ $stz->fetchProducts();
+ $existingLine = null;
+ foreach ($stz->products as $p) {
+ if ($fkProduct > 0 && $p->fk_product == $fkProduct && in_array($p->origin, array('order', 'added'))) {
+ $existingLine = $p;
+ break;
+ }
+ }
+
+ if ($existingLine) {
+ // Menge addieren
+ $newQty = (float)$existingLine->qty_done + $qty;
+ $result = $stz->updateProductQty($existingLine->rowid, $newQty);
+ } else {
+ $result = $stz->addProduct(
+ $fkProduct > 0 ? $fkProduct : 0,
+ $fkCommandedet > 0 ? $fkCommandedet : null,
+ null,
+ 0,
+ $qty,
+ 'added',
+ $description
+ );
+ }
+
+ if ($result > 0) {
+ $response['success'] = true;
+ } else {
+ $response['error'] = $stz->error ?: 'Fehler beim Hinzufuegen';
+ }
+ break;
+
+ // ---- Produkt loeschen ----
+ case 'delete_product':
+ $stzId = GETPOST('stz_id', 'int');
+ $lineId = GETPOST('line_id', 'int');
+
+ $stz = new Stundenzettel($db);
+ if ($stz->fetch($stzId) <= 0) {
+ $response['error'] = 'Stundenzettel nicht gefunden';
+ break;
+ }
+
+ if (!canEditStz($stz, $user, $canWriteAll)) {
+ $response['error'] = 'Keine Berechtigung oder nicht im Entwurf';
+ break;
+ }
+
+ $result = $stz->deleteProduct($lineId);
+ if ($result > 0) {
+ $response['success'] = true;
+ } else {
+ $response['error'] = 'Fehler beim Loeschen';
+ }
+ break;
+
+ // ---- Mehraufwand hinzufuegen ----
+ case 'add_mehraufwand':
+ $stzId = GETPOST('stz_id', 'int');
+ $fkProduct = GETPOST('fk_product', 'int');
+ $qty = (float)GETPOST('qty', 'alpha');
+ $description = GETPOST('description', 'restricthtml');
+ $reason = GETPOST('reason', 'restricthtml');
+
+ $stz = new Stundenzettel($db);
+ if ($stz->fetch($stzId) <= 0) {
+ $response['error'] = 'Stundenzettel nicht gefunden';
+ break;
+ }
+
+ if (!canEditStz($stz, $user, $canWriteAll)) {
+ $response['error'] = 'Keine Berechtigung oder nicht im Entwurf';
+ break;
+ }
+
+ $desc = $reason ?: $description;
+ $result = $stz->addProduct(
+ $fkProduct > 0 ? $fkProduct : 0,
+ null, null,
+ 0, $qty,
+ 'additional',
+ $desc
+ );
+
+ if ($result > 0) {
+ $response['success'] = true;
+ } else {
+ $response['error'] = $stz->error ?: 'Fehler beim Hinzufuegen';
+ }
+ break;
+
+ // ---- Mehraufwand loeschen ----
+ case 'delete_mehraufwand':
+ $stzId = GETPOST('stz_id', 'int');
+ $lineId = GETPOST('line_id', 'int');
+
+ $stz = new Stundenzettel($db);
+ if ($stz->fetch($stzId) <= 0) {
+ $response['error'] = 'Stundenzettel nicht gefunden';
+ break;
+ }
+
+ if (!canEditStz($stz, $user, $canWriteAll)) {
+ $response['error'] = 'Keine Berechtigung oder nicht im Entwurf';
+ break;
+ }
+
+ $result = $stz->deleteProduct($lineId);
+ if ($result > 0) {
+ $response['success'] = true;
+ } else {
+ $response['error'] = 'Fehler beim Loeschen';
+ }
+ break;
+
+ // ---- Entfaellt hinzufuegen ----
+ case 'add_entfaellt':
+ $stzId = GETPOST('stz_id', 'int');
+ $source = GETPOST('source', 'alphanohtml'); // commandedet_ID oder mehraufwand_SPROWID
+ $qty = (float)GETPOST('qty', 'alpha');
+ $reason = GETPOST('reason', 'restricthtml');
+
+ $stz = new Stundenzettel($db);
+ if ($stz->fetch($stzId) <= 0) {
+ $response['error'] = 'Stundenzettel nicht gefunden';
+ break;
+ }
+
+ if (!canEditStz($stz, $user, $canWriteAll)) {
+ $response['error'] = 'Keine Berechtigung oder nicht im Entwurf';
+ break;
+ }
+
+ if (empty($source)) {
+ $response['error'] = 'Quelle erforderlich';
+ break;
+ }
+
+ // Source parsen: "cd_123" (Auftragszeile) oder "ma_456" (Mehraufwand)
+ $parts = explode('_', $source);
+ $sourceType = $parts[0];
+ $sourceId = isset($parts[1]) ? (int)$parts[1] : 0;
+
+ if ($sourceType === 'cd' && $sourceId > 0) {
+ // Auftragsposition
+ $sqlCd = "SELECT cd.rowid, cd.fk_product, cd.description, p.ref, p.label";
+ $sqlCd .= " FROM ".MAIN_DB_PREFIX."commandedet as cd";
+ $sqlCd .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product";
+ $sqlCd .= " WHERE cd.rowid = ".((int)$sourceId);
+ $resCd = $db->query($sqlCd);
+ if ($resCd && $db->num_rows($resCd) > 0) {
+ $objCd = $db->fetch_object($resCd);
+ $result = $stz->addProduct(
+ $objCd->fk_product > 0 ? (int)$objCd->fk_product : 0,
+ (int)$objCd->rowid,
+ null, 0, $qty, 'omitted',
+ $reason ?: '',
+ $objCd->label ?: strip_tags($objCd->description)
+ );
+ if ($result > 0) {
+ $response['success'] = true;
+ } else {
+ $response['error'] = $stz->error ?: 'Fehler';
+ }
+ } else {
+ $response['error'] = 'Auftragsposition nicht gefunden';
+ }
+ } elseif ($sourceType === 'ma' && $sourceId > 0) {
+ // Mehraufwand-Zeile
+ $sqlMa = "SELECT sp.* FROM ".MAIN_DB_PREFIX."stundenzettel_product as sp";
+ $sqlMa .= " WHERE sp.rowid = ".((int)$sourceId);
+ $sqlMa .= " AND sp.fk_stundenzettel = ".((int)$stz->id);
+ $sqlMa .= " AND sp.origin = 'additional'";
+ $resMa = $db->query($sqlMa);
+ if ($resMa && $db->num_rows($resMa) > 0) {
+ $objMa = $db->fetch_object($resMa);
+ // Mehraufwand-Menge reduzieren
+ $newMaQty = (float)$objMa->qty_done - $qty;
+ if ($newMaQty > 0) {
+ $stz->updateProductQty($sourceId, $newMaQty);
+ } else {
+ $stz->deleteProduct($sourceId);
+ }
+ // Entfaellt-Zeile anlegen
+ $result = $stz->addProduct(
+ $objMa->fk_product > 0 ? (int)$objMa->fk_product : 0,
+ $objMa->fk_commandedet > 0 ? (int)$objMa->fk_commandedet : null,
+ null, 0, $qty, 'omitted',
+ $reason ?: '',
+ $objMa->product_label
+ );
+ $response['success'] = ($result > 0);
+ } else {
+ $response['error'] = 'Mehraufwand nicht gefunden';
+ }
+ } else {
+ $response['error'] = 'Ungueltige Quelle';
+ }
+ break;
+
+ // ---- Entfaellt loeschen ----
+ case 'delete_entfaellt':
+ $stzId = GETPOST('stz_id', 'int');
+ $lineId = GETPOST('line_id', 'int');
+
+ $stz = new Stundenzettel($db);
+ if ($stz->fetch($stzId) <= 0) {
+ $response['error'] = 'Stundenzettel nicht gefunden';
+ break;
+ }
+
+ if (!canEditStz($stz, $user, $canWriteAll)) {
+ $response['error'] = 'Keine Berechtigung oder nicht im Entwurf';
+ break;
+ }
+
+ $result = $stz->deleteProduct($lineId);
+ if ($result > 0) {
+ $response['success'] = true;
+ } else {
+ $response['error'] = 'Fehler beim Loeschen';
+ }
+ break;
+
+ // ---- Ruecknahme hinzufuegen ----
+ case 'add_ruecknahme':
+ $stzId = GETPOST('stz_id', 'int');
+ $source = GETPOST('source', 'alphanohtml');
+ $qty = (float)GETPOST('qty', 'alpha');
+ $reason = GETPOST('reason', 'restricthtml');
+
+ $stz = new Stundenzettel($db);
+ if ($stz->fetch($stzId) <= 0) {
+ $response['error'] = 'Stundenzettel nicht gefunden';
+ break;
+ }
+
+ if (!canEditStz($stz, $user, $canWriteAll)) {
+ $response['error'] = 'Keine Berechtigung oder nicht im Entwurf';
+ break;
+ }
+
+ if (empty($source)) {
+ $response['error'] = 'Quelle erforderlich';
+ break;
+ }
+
+ // Source: "cd_123" (Auftragszeile) oder "sp_456" (Stundenzettel-Produkt)
+ $parts = explode('_', $source);
+ $sourceType = $parts[0];
+ $sourceId = isset($parts[1]) ? (int)$parts[1] : 0;
+
+ if ($sourceType === 'cd' && $sourceId > 0) {
+ // Auftragsposition - Ruecknahme
+ $sqlCd = "SELECT cd.rowid, cd.fk_product, cd.description, p.ref, p.label";
+ $sqlCd .= " FROM ".MAIN_DB_PREFIX."commandedet as cd";
+ $sqlCd .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product";
+ $sqlCd .= " WHERE cd.rowid = ".((int)$sourceId);
+ $resCd = $db->query($sqlCd);
+ if ($resCd && $db->num_rows($resCd) > 0) {
+ $objCd = $db->fetch_object($resCd);
+ $result = $stz->addProduct(
+ $objCd->fk_product > 0 ? (int)$objCd->fk_product : 0,
+ (int)$objCd->rowid,
+ null, 0, $qty, 'returned',
+ $reason ?: '',
+ $objCd->label ?: strip_tags($objCd->description)
+ );
+ if ($result > 0) {
+ $response['success'] = true;
+ } else {
+ $response['error'] = $stz->error ?: 'Fehler';
+ }
+ } else {
+ $response['error'] = 'Auftragsposition nicht gefunden';
+ }
+ } elseif ($sourceType === 'sp' && $sourceId > 0) {
+ // Stundenzettel-Produkt (added ohne commandedet)
+ $sqlSp = "SELECT sp.* FROM ".MAIN_DB_PREFIX."stundenzettel_product as sp";
+ $sqlSp .= " WHERE sp.rowid = ".((int)$sourceId);
+ $resSp = $db->query($sqlSp);
+ if ($resSp && $db->num_rows($resSp) > 0) {
+ $objSp = $db->fetch_object($resSp);
+ $result = $stz->addProduct(
+ $objSp->fk_product > 0 ? (int)$objSp->fk_product : 0,
+ $objSp->fk_commandedet > 0 ? (int)$objSp->fk_commandedet : null,
+ null, 0, $qty, 'returned',
+ $reason ?: '',
+ $objSp->product_label
+ );
+ if ($result > 0) {
+ $response['success'] = true;
+ } else {
+ $response['error'] = $stz->error ?: 'Fehler';
+ }
+ } else {
+ $response['error'] = 'Produkt nicht gefunden';
+ }
+ } else {
+ $response['error'] = 'Ungueltige Quelle';
+ }
+ break;
+
+ // ---- Ruecknahme loeschen ----
+ case 'delete_ruecknahme':
+ $stzId = GETPOST('stz_id', 'int');
+ $lineId = GETPOST('line_id', 'int');
+
+ $stz = new Stundenzettel($db);
+ if ($stz->fetch($stzId) <= 0) {
+ $response['error'] = 'Stundenzettel nicht gefunden';
+ break;
+ }
+
+ if (!canEditStz($stz, $user, $canWriteAll)) {
+ $response['error'] = 'Keine Berechtigung oder nicht im Entwurf';
+ break;
+ }
+
+ $result = $stz->deleteProduct($lineId);
+ if ($result > 0) {
+ $response['success'] = true;
+ } else {
+ $response['error'] = 'Fehler beim Loeschen';
+ }
+ break;
+
+ // ---- Notiz hinzufuegen ----
+ case 'add_note':
+ $stzId = GETPOST('stz_id', 'int');
+ $noteText = GETPOST('note', 'restricthtml');
+
+ $stz = new Stundenzettel($db);
+ if ($stz->fetch($stzId) <= 0) {
+ $response['error'] = 'Stundenzettel nicht gefunden';
+ break;
+ }
+
+ if (!canEditStz($stz, $user, $canWriteAll)) {
+ $response['error'] = 'Keine Berechtigung oder nicht im Entwurf';
+ break;
+ }
+
+ $result = $stz->addNote($user, $noteText);
+ if ($result > 0) {
+ $response['success'] = true;
+ } else {
+ $response['error'] = 'Fehler beim Hinzufuegen';
+ }
+ break;
+
+ // ---- Notiz togglen ----
+ case 'toggle_note':
+ $stzId = GETPOST('stz_id', 'int');
+ $noteId = GETPOST('note_id', 'int');
+ $checked = GETPOST('checked', 'int');
+
+ $stz = new Stundenzettel($db);
+ if ($stz->fetch($stzId) <= 0) {
+ $response['error'] = 'Stundenzettel nicht gefunden';
+ break;
+ }
+
+ $result = $stz->updateNoteStatus($noteId, $checked);
+ if ($result > 0) {
+ $response['success'] = true;
+ } else {
+ $response['error'] = 'Fehler beim Aktualisieren';
+ }
+ break;
+
+ // ---- Notiz loeschen ----
+ case 'delete_note':
+ $stzId = GETPOST('stz_id', 'int');
+ $noteId = GETPOST('note_id', 'int');
+
+ $stz = new Stundenzettel($db);
+ if ($stz->fetch($stzId) <= 0) {
+ $response['error'] = 'Stundenzettel nicht gefunden';
+ break;
+ }
+
+ if (!canEditStz($stz, $user, $canWriteAll)) {
+ $response['error'] = 'Keine Berechtigung oder nicht im Entwurf';
+ break;
+ }
+
+ $result = $stz->deleteNote($noteId);
+ if ($result > 0) {
+ $response['success'] = true;
+ } else {
+ $response['error'] = 'Fehler beim Loeschen';
+ }
+ break;
+
+ // ---- Produktsuche ----
+ case 'search_products':
+ $term = GETPOST('term', 'alphanohtml');
+ if (strlen($term) < 2) {
+ $response['error'] = 'Mindestens 2 Zeichen';
+ break;
+ }
+
+ $sql = "SELECT p.rowid as id, p.ref, p.label";
+ $sql .= " FROM ".MAIN_DB_PREFIX."product as p";
+ $sql .= " WHERE (p.ref LIKE '%".$db->escape($term)."%' OR p.label LIKE '%".$db->escape($term)."%')";
+ $sql .= " AND p.entity IN (".getEntity('product').")";
+ $sql .= " AND p.tosell = 1"; // Nur verkaufbare Produkte
+ $sql .= " ORDER BY p.ref ASC";
+ $sql .= " LIMIT 20";
+
+ $resql = $db->query($sql);
+ $products = array();
+ if ($resql) {
+ while ($obj = $db->fetch_object($resql)) {
+ $products[] = array(
+ 'id' => (int)$obj->id,
+ 'ref' => $obj->ref,
+ 'label' => $obj->label
+ );
+ }
+ }
+ $response['success'] = true;
+ $response['products'] = $products;
+ break;
+
+ // ---- Entfaellt-Optionen laden ----
+ case 'get_entfaellt_options':
+ $stzId = GETPOST('stz_id', 'int');
+ $orderId = GETPOST('order_id', 'int');
+
+ $options = array();
+
+ // Auftragspositionen mit verfuegbarer Menge
+ $sql = "SELECT cd.rowid, cd.fk_product, cd.qty, cd.description,";
+ $sql .= " p.ref, p.label,";
+ $sql .= " COALESCE((SELECT SUM(sp.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
+ $sql .= " INNER JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
+ $sql .= " WHERE s.fk_commande = ".((int)$orderId);
+ $sql .= " AND (sp.fk_commandedet = cd.rowid OR (sp.fk_product = cd.fk_product AND cd.fk_product > 0))";
+ $sql .= " AND sp.origin IN ('order','added')), 0) as qty_used,";
+ $sql .= " COALESCE((SELECT SUM(sp2.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp2";
+ $sql .= " INNER JOIN ".MAIN_DB_PREFIX."stundenzettel s2 ON s2.rowid = sp2.fk_stundenzettel";
+ $sql .= " WHERE s2.fk_commande = ".((int)$orderId);
+ $sql .= " AND (sp2.fk_commandedet = cd.rowid OR (sp2.fk_product = cd.fk_product AND cd.fk_product > 0))";
+ $sql .= " AND sp2.origin = 'omitted'), 0) as qty_omitted";
+ $sql .= " FROM ".MAIN_DB_PREFIX."commandedet as cd";
+ $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product";
+ $sql .= " WHERE cd.fk_commande = ".((int)$orderId);
+ $sql .= " ORDER BY cd.rang";
+
+ $resql = $db->query($sql);
+ if ($resql) {
+ while ($obj = $db->fetch_object($resql)) {
+ $available = (float)$obj->qty - (float)$obj->qty_used - (float)$obj->qty_omitted;
+ if ($available > 0) {
+ $label = $obj->label ?: strip_tags($obj->description);
+ $options[] = array(
+ 'value' => 'cd_'.$obj->rowid,
+ 'label' => ($obj->ref ? $obj->ref.' - ' : '').$label,
+ 'max_qty' => $available
+ );
+ }
+ }
+ }
+
+ // Mehraufwand-Zeilen
+ if ($stzId > 0) {
+ $sqlMa = "SELECT sp.rowid, sp.fk_product, sp.product_ref, sp.product_label, sp.description, sp.qty_done";
+ $sqlMa .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product as sp";
+ $sqlMa .= " WHERE sp.fk_stundenzettel = ".((int)$stzId);
+ $sqlMa .= " AND sp.origin = 'additional'";
+ $sqlMa .= " AND sp.qty_done > 0";
+ $resMa = $db->query($sqlMa);
+ if ($resMa) {
+ while ($obj = $db->fetch_object($resMa)) {
+ $label = $obj->product_label ?: strip_tags($obj->description);
+ $options[] = array(
+ 'value' => 'ma_'.$obj->rowid,
+ 'label' => '[Mehraufwand] '.($obj->product_ref ? $obj->product_ref.' - ' : '').$label,
+ 'max_qty' => (float)$obj->qty_done
+ );
+ }
+ }
+ }
+
+ $response['success'] = true;
+ $response['options'] = $options;
+ break;
+
+ // ---- Ruecknahme-Optionen laden ----
+ case 'get_ruecknahme_options':
+ $stzId = GETPOST('stz_id', 'int');
+ $orderId = GETPOST('order_id', 'int');
+
+ $options = array();
+
+ // Verbaute Auftragspositionen
+ $sql = "SELECT cd.rowid, cd.fk_product, cd.description,";
+ $sql .= " p.ref, p.label,";
+ $sql .= " COALESCE((SELECT SUM(sp.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
+ $sql .= " INNER JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
+ $sql .= " WHERE s.fk_commande = ".((int)$orderId);
+ $sql .= " AND (sp.fk_commandedet = cd.rowid OR (sp.fk_product = cd.fk_product AND cd.fk_product > 0))";
+ $sql .= " AND sp.origin IN ('order','added')), 0) as qty_delivered,";
+ $sql .= " COALESCE((SELECT SUM(sp2.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp2";
+ $sql .= " INNER JOIN ".MAIN_DB_PREFIX."stundenzettel s2 ON s2.rowid = sp2.fk_stundenzettel";
+ $sql .= " WHERE s2.fk_commande = ".((int)$orderId);
+ $sql .= " AND (sp2.fk_commandedet = cd.rowid OR (sp2.fk_product = cd.fk_product AND cd.fk_product > 0))";
+ $sql .= " AND sp2.origin = 'returned'), 0) as qty_returned";
+ $sql .= " FROM ".MAIN_DB_PREFIX."commandedet as cd";
+ $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product";
+ $sql .= " WHERE cd.fk_commande = ".((int)$orderId);
+ $sql .= " ORDER BY cd.rang";
+
+ $resql = $db->query($sql);
+ if ($resql) {
+ while ($obj = $db->fetch_object($resql)) {
+ $available = (float)$obj->qty_delivered - (float)$obj->qty_returned;
+ if ($available > 0) {
+ $label = $obj->label ?: strip_tags($obj->description);
+ $options[] = array(
+ 'value' => 'cd_'.$obj->rowid,
+ 'label' => ($obj->ref ? $obj->ref.' - ' : '').$label,
+ 'max_qty' => $available
+ );
+ }
+ }
+ }
+
+ // Verbaute Produkte ohne Auftragszeile (manuell hinzugefuegt)
+ if ($stzId > 0) {
+ $sqlSp = "SELECT sp.rowid, sp.fk_product, sp.product_ref, sp.product_label, sp.description, sp.qty_done";
+ $sqlSp .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product as sp";
+ $sqlSp .= " WHERE sp.fk_stundenzettel = ".((int)$stzId);
+ $sqlSp .= " AND sp.origin = 'added'";
+ $sqlSp .= " AND (sp.fk_commandedet IS NULL OR sp.fk_commandedet = 0)";
+ $sqlSp .= " AND sp.qty_done > 0";
+ $resSp = $db->query($sqlSp);
+ if ($resSp) {
+ while ($obj = $db->fetch_object($resSp)) {
+ $label = $obj->product_label ?: strip_tags($obj->description);
+ $options[] = array(
+ 'value' => 'sp_'.$obj->rowid,
+ 'label' => '[Hinzugefuegt] '.($obj->product_ref ? $obj->product_ref.' - ' : '').$label,
+ 'max_qty' => (float)$obj->qty_done
+ );
+ }
+ }
+ }
+
+ $response['success'] = true;
+ $response['options'] = $options;
+ break;
+
+ // ---- Dienstleistungen laden ----
+ case 'get_services':
+ $sql = "SELECT p.rowid as id, p.ref, p.label";
+ $sql .= " FROM ".MAIN_DB_PREFIX."product as p";
+ $sql .= " WHERE p.fk_product_type = 1"; // Typ 1 = Dienstleistung
+ $sql .= " AND p.tosell = 1";
+ $sql .= " AND p.entity IN (".getEntity('product').")";
+ $sql .= " ORDER BY p.ref ASC";
+ $sql .= " LIMIT 50";
+
+ $resql = $db->query($sql);
+ $services = array();
+ if ($resql) {
+ while ($obj = $db->fetch_object($resql)) {
+ $services[] = array(
+ 'id' => (int)$obj->id,
+ 'ref' => $obj->ref,
+ 'label' => $obj->label
+ );
+ }
+ }
+ $response['success'] = true;
+ $response['services'] = $services;
+ break;
+
+ // ---- Auftragspositionen in STZ uebernehmen ----
+ case 'transfer_order_products':
+ if (!$canWrite) {
+ $response['error'] = 'Keine Schreibberechtigung';
+ break;
+ }
+
+ $stzId = GETPOST('stz_id', 'int');
+ $lineIds = GETPOST('line_ids', 'alpha'); // Kommaseparierte commandedet IDs
+
+ $stz = new Stundenzettel($db);
+ if ($stz->fetch($stzId) <= 0) {
+ $response['error'] = 'Stundenzettel nicht gefunden';
+ break;
+ }
+ if (!canEditStz($stz, $user, $canWriteAll)) {
+ $response['error'] = 'Keine Berechtigung oder nicht im Entwurf';
+ break;
+ }
+
+ $ids = array_filter(array_map('intval', explode(',', $lineIds)));
+ $added = 0;
+ foreach ($ids as $lineId) {
+ if ($lineId <= 0) continue;
+
+ // Auftragszeile laden
+ $sqlLine = "SELECT cd.rowid, cd.fk_product, cd.qty, cd.description";
+ $sqlLine .= " FROM ".MAIN_DB_PREFIX."commandedet as cd";
+ $sqlLine .= " WHERE cd.rowid = ".((int)$lineId);
+ $resLine = $db->query($sqlLine);
+ if (!$resLine || !($objLine = $db->fetch_object($resLine))) continue;
+
+ // Pruefen ob schon auf diesem STZ
+ $sqlCheck = "SELECT rowid FROM ".MAIN_DB_PREFIX."stundenzettel_product";
+ $sqlCheck .= " WHERE fk_stundenzettel = ".((int)$stzId);
+ $sqlCheck .= " AND fk_commandedet = ".((int)$lineId);
+ $sqlCheck .= " AND origin IN ('order', 'added')";
+ $resCheck = $db->query($sqlCheck);
+ if ($resCheck && $db->num_rows($resCheck) > 0) continue; // Bereits vorhanden
+
+ $stz->addProduct(
+ $objLine->fk_product,
+ $objLine->rowid, // fk_commandedet
+ null,
+ $objLine->qty, // qty_original
+ 0, // qty_done
+ 'order',
+ $objLine->description
+ );
+ $added++;
+ }
+
+ // Mehraufwand-Produkte uebernehmen (als origin='added' ohne fk_commandedet)
+ $maProductsJson = GETPOST('ma_products', 'restricthtml');
+ if (!empty($maProductsJson)) {
+ $maProducts = json_decode($maProductsJson, true);
+ if (is_array($maProducts)) {
+ foreach ($maProducts as $ma) {
+ $fkProduct = isset($ma['fk_product']) ? (int)$ma['fk_product'] : 0;
+ $description = isset($ma['description']) ? $ma['description'] : '';
+ $qty = isset($ma['qty']) ? (float)$ma['qty'] : 0;
+
+ // Pruefen ob dieses Mehraufwand-Produkt schon auf dem STZ ist
+ $sqlCheckMA = "SELECT rowid FROM ".MAIN_DB_PREFIX."stundenzettel_product";
+ $sqlCheckMA .= " WHERE fk_stundenzettel = ".((int)$stzId);
+ $sqlCheckMA .= " AND origin = 'added'";
+ $sqlCheckMA .= " AND (fk_commandedet IS NULL OR fk_commandedet = 0)";
+ if ($fkProduct > 0) {
+ $sqlCheckMA .= " AND fk_product = ".((int)$fkProduct);
+ } else {
+ $sqlCheckMA .= " AND (fk_product IS NULL OR fk_product = 0)";
+ $sqlCheckMA .= " AND description = '".$db->escape($description)."'";
+ }
+ $resCheckMA = $db->query($sqlCheckMA);
+ if ($resCheckMA && $db->num_rows($resCheckMA) > 0) continue; // Bereits vorhanden
+
+ // Produkt-Label laden
+ $productLabel = '';
+ if ($fkProduct > 0) {
+ $sqlProd = "SELECT ref, label FROM ".MAIN_DB_PREFIX."product WHERE rowid = ".((int)$fkProduct);
+ $resProd = $db->query($sqlProd);
+ if ($resProd && ($objProd = $db->fetch_object($resProd))) {
+ $productLabel = $objProd->label;
+ }
+ }
+
+ $stz->addProduct(
+ $fkProduct > 0 ? $fkProduct : null,
+ null, // fk_commandedet (kein Auftragsbezug)
+ null,
+ $qty, // qty_original = Zielmenge
+ 0, // qty_done
+ 'added',
+ $description ?: $productLabel
+ );
+ $added++;
+ }
+ }
+ }
+
+ $response['success'] = true;
+ $response['added'] = $added;
+ break;
+
+ default:
+ $response['error'] = 'Unbekannte Aktion: '.$action;
+}
+
+echo json_encode($response);
+$db->close();
diff --git a/ajax/pwa_auth.php b/ajax/pwa_auth.php
new file mode 100644
index 0000000..7410a75
--- /dev/null
+++ b/ajax/pwa_auth.php
@@ -0,0 +1,144 @@
+
+ *
+ * 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();
diff --git a/card.php b/card.php
old mode 100644
new mode 100755
diff --git a/class/stundenzettel.class.php b/class/stundenzettel.class.php
old mode 100644
new mode 100755
diff --git a/css/pwa.css b/css/pwa.css
new file mode 100644
index 0000000..e8a9f49
--- /dev/null
+++ b/css/pwa.css
@@ -0,0 +1,1200 @@
+/**
+ * Stundenzettel PWA - Mobile-First Dark Theme
+ * Version 1.0
+ */
+
+/* === CSS-Variablen === */
+:root {
+ --colorbackbody: #1d1e20;
+ --colorbackcard: #2a2b2d;
+ --colorbacktitle: #3b3c3e;
+ --colorbackline: #38393d;
+ --colorbackinput: rgb(70, 70, 70);
+ --colortext: rgb(220, 220, 220);
+ --colortextmuted: rgb(160, 160, 160);
+ --colortextlink: #4390dc;
+ --colorborder: #3a3b3d;
+ --success: #25a580;
+ --danger: #c0392b;
+ --warning: #bc9526;
+ --info: #2980b9;
+ --badge-order: #2980b9;
+ --badge-added: #6c757d;
+ --badge-additional: #bc9526;
+ --badge-omitted: #6c757d;
+ --badge-returned: #c0392b;
+}
+
+/* === Reset & Basis === */
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ -webkit-tap-highlight-color: transparent;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
+ background: var(--colorbackbody);
+ color: var(--colortext);
+ min-height: 100vh;
+ min-height: 100dvh;
+ overflow-x: hidden;
+ font-size: 15px;
+ line-height: 1.4;
+}
+
+.app {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+ min-height: 100dvh;
+}
+
+/* === Screens === */
+.screen {
+ display: none;
+ flex: 1;
+ flex-direction: column;
+}
+.screen.active {
+ display: flex;
+}
+
+/* === Login-Screen === */
+.login-screen {
+ justify-content: center;
+ align-items: center;
+ padding: 20px;
+}
+.login-container {
+ width: 100%;
+ max-width: 360px;
+ text-align: center;
+}
+.login-logo {
+ width: 80px;
+ height: 80px;
+ margin: 0 auto 16px;
+ color: var(--primary, #4390dc);
+}
+.login-logo svg {
+ width: 100%;
+ height: 100%;
+}
+.login-title {
+ font-size: 24px;
+ font-weight: 600;
+ margin-bottom: 4px;
+}
+.login-subtitle {
+ font-size: 14px;
+ color: var(--colortextmuted);
+ margin-bottom: 32px;
+}
+.login-form {
+ text-align: left;
+}
+.form-group {
+ margin-bottom: 16px;
+}
+.form-group label {
+ display: block;
+ font-size: 13px;
+ color: var(--colortextmuted);
+ margin-bottom: 6px;
+}
+.form-group input {
+ width: 100%;
+ padding: 12px 14px;
+ background: var(--colorbackinput);
+ border: 1px solid var(--colorborder);
+ border-radius: 8px;
+ color: var(--colortext);
+ font-size: 16px;
+ min-height: 48px;
+ outline: none;
+ transition: border-color 0.2s;
+}
+.form-group input:focus {
+ border-color: var(--primary, #4390dc);
+}
+.error-text {
+ color: var(--danger);
+ font-size: 13px;
+ margin-top: 12px;
+ text-align: center;
+ min-height: 20px;
+}
+
+/* === Buttons === */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 12px 20px;
+ border: none;
+ border-radius: 8px;
+ font-size: 15px;
+ font-weight: 500;
+ cursor: pointer;
+ min-height: 48px;
+ transition: opacity 0.2s, transform 0.1s;
+ text-decoration: none;
+}
+.btn:active {
+ transform: scale(0.97);
+ opacity: 0.85;
+}
+.btn-primary {
+ background: var(--primary, #4390dc);
+ color: #fff;
+ width: 100%;
+}
+.btn-success {
+ background: var(--success);
+ color: #fff;
+}
+.btn-danger {
+ background: var(--danger);
+ color: #fff;
+}
+.btn-ghost {
+ background: transparent;
+ color: var(--colortextlink);
+ border: 1px solid var(--colorborder);
+}
+.btn-small {
+ padding: 8px 14px;
+ min-height: 36px;
+ font-size: 13px;
+}
+.btn-icon {
+ width: 44px;
+ height: 44px;
+ padding: 0;
+ border-radius: 50%;
+ font-size: 18px;
+}
+.btn:disabled {
+ opacity: 0.5;
+ pointer-events: none;
+}
+
+/* === Search-Screen === */
+.search-screen {
+ flex-direction: column;
+}
+.search-header {
+ padding: 12px 16px;
+ background: var(--colorbackcard);
+ border-bottom: 1px solid var(--colorborder);
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ position: sticky;
+ top: 0;
+ z-index: 10;
+}
+.search-header .user-name {
+ font-size: 13px;
+ color: var(--colortextmuted);
+ white-space: nowrap;
+}
+.search-input-wrap {
+ flex: 1;
+ position: relative;
+}
+.search-input-wrap input {
+ width: 100%;
+ padding: 10px 14px 10px 38px;
+ background: var(--colorbackinput);
+ border: 1px solid var(--colorborder);
+ border-radius: 8px;
+ color: var(--colortext);
+ font-size: 16px;
+ min-height: 44px;
+ outline: none;
+}
+.search-input-wrap input:focus {
+ border-color: var(--primary, #4390dc);
+}
+.search-input-wrap .search-icon {
+ position: absolute;
+ left: 12px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--colortextmuted);
+ font-size: 16px;
+ pointer-events: none;
+}
+.search-results {
+ flex: 1;
+ overflow-y: auto;
+ padding: 8px;
+ -webkit-overflow-scrolling: touch;
+}
+.search-empty {
+ text-align: center;
+ color: var(--colortextmuted);
+ padding: 40px 20px;
+ font-size: 14px;
+}
+
+/* === Kunden- und Auftrags-Cards === */
+.customer-card {
+ background: var(--colorbackcard);
+ border-radius: 10px;
+ margin-bottom: 8px;
+ overflow: hidden;
+}
+.customer-header {
+ padding: 14px 16px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ cursor: pointer;
+}
+.customer-header:active {
+ background: var(--colorbackline);
+}
+.customer-name {
+ font-weight: 600;
+ font-size: 15px;
+}
+.customer-badge {
+ font-size: 12px;
+ color: var(--colortextmuted);
+ background: var(--colorbacktitle);
+ padding: 3px 8px;
+ border-radius: 12px;
+}
+.customer-orders {
+ display: none;
+ border-top: 1px solid var(--colorborder);
+}
+.customer-orders.open {
+ display: block;
+}
+.order-item {
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--colorborder);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+.order-item:last-child {
+ border-bottom: none;
+}
+.order-item:active {
+ background: var(--colorbackline);
+}
+.order-ref {
+ font-weight: 500;
+ font-size: 14px;
+}
+.order-client-ref {
+ font-size: 13px;
+ color: var(--colortext);
+ margin-top: 2px;
+ opacity: 0.85;
+}
+.order-date {
+ font-size: 12px;
+ color: var(--colortextmuted);
+ margin-top: 2px;
+}
+.order-stz-badge {
+ font-size: 11px;
+ padding: 3px 8px;
+ border-radius: 10px;
+ background: var(--success);
+ color: #fff;
+ white-space: nowrap;
+}
+
+/* === Main-Screen mit Swipe === */
+.main-screen {
+ flex-direction: column;
+ overflow: hidden;
+}
+
+/* Tab-Bar */
+.tab-bar {
+ display: flex;
+ background: var(--colorbackcard);
+ border-bottom: 1px solid var(--colorborder);
+ position: relative;
+ flex-shrink: 0;
+}
+.tab-bar .back-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 44px;
+ color: var(--colortextlink);
+ border-right: 1px solid var(--colorborder);
+ cursor: pointer;
+ font-size: 18px;
+ flex-shrink: 0;
+}
+.tab-bar .back-btn:active {
+ background: var(--colorbackline);
+}
+.tab-items {
+ display: flex;
+ flex: 1;
+ overflow-x: auto;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+.tab-items::-webkit-scrollbar {
+ display: none;
+}
+.tab-item {
+ flex: 1;
+ min-width: 0;
+ padding: 12px 8px;
+ text-align: center;
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--colortextmuted);
+ cursor: pointer;
+ white-space: nowrap;
+ border-bottom: 2px solid transparent;
+ transition: color 0.2s, border-color 0.2s;
+}
+.tab-item:active {
+ background: var(--colorbackline);
+}
+.tab-item.active {
+ color: var(--primary, #4390dc);
+ border-bottom-color: var(--primary, #4390dc);
+}
+
+/* Swipe-Container */
+.swipe-viewport {
+ flex: 1;
+ overflow: hidden;
+ position: relative;
+}
+.swipe-container {
+ display: flex;
+ width: 400%;
+ height: 100%;
+ will-change: transform;
+}
+.swipe-container.animating {
+ transition: transform 300ms ease-out;
+}
+.swipe-panel {
+ width: 25%;
+ height: 100%;
+ overflow-y: auto;
+ overflow-x: hidden;
+ -webkit-overflow-scrolling: touch;
+ padding: 8px;
+}
+
+/* === Info-Header (Auftrag + STZ Info) === */
+.info-header {
+ background: var(--colorbackcard);
+ border-radius: 10px;
+ padding: 12px 14px;
+ margin-bottom: 8px;
+}
+.info-header-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+.info-header-row + .info-header-row {
+ margin-top: 6px;
+}
+.info-label {
+ font-size: 12px;
+ color: var(--colortextmuted);
+}
+.info-value {
+ font-size: 14px;
+ font-weight: 500;
+}
+
+/* === Badges === */
+.badge {
+ display: inline-block;
+ font-size: 11px;
+ font-weight: 500;
+ padding: 2px 8px;
+ border-radius: 10px;
+ white-space: nowrap;
+}
+.badge-order { background: var(--badge-order); color: #fff; }
+.badge-added { background: var(--badge-added); color: #fff; }
+.badge-additional { background: var(--badge-additional); color: #fff; }
+.badge-omitted { background: var(--badge-omitted); color: #fff; }
+.badge-returned { background: var(--badge-returned); color: #fff; }
+.badge-draft { background: var(--warning); color: #fff; }
+.badge-validated { background: var(--success); color: #fff; }
+.badge-invoiced { background: var(--info); color: #fff; }
+.badge-canceled { background: var(--danger); color: #fff; }
+
+/* Status-Badges */
+.status-badge { font-size: 11px; padding: 3px 8px; border-radius: 10px; }
+.status-open { background: var(--danger); color: #fff; }
+.status-partial { background: var(--warning); color: #fff; }
+.status-done { background: var(--success); color: #fff; }
+
+/* === Produkt-Cards === */
+.product-card {
+ background: var(--colorbackcard);
+ border-radius: 10px;
+ padding: 12px 14px;
+ margin-bottom: 6px;
+}
+.product-card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 8px;
+}
+.product-name {
+ font-weight: 600;
+ font-size: 14px;
+ flex: 1;
+ min-width: 0;
+ word-break: break-word;
+}
+.product-ref {
+ font-size: 12px;
+ color: var(--colortextmuted);
+ margin-top: 2px;
+}
+.product-desc {
+ font-size: 13px;
+ color: var(--colortextmuted);
+ margin-top: 4px;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+.product-qty-row {
+ display: flex;
+ align-items: center;
+ margin-top: 10px;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+.qty-label {
+ font-size: 12px;
+ color: var(--colortextmuted);
+}
+.qty-value {
+ font-weight: 600;
+ font-size: 14px;
+}
+.qty-controls {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ margin-left: auto;
+}
+.qty-btn {
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ border: 1px solid var(--colorborder);
+ background: var(--colorbacktitle);
+ color: var(--colortext);
+ font-size: 18px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+}
+.qty-btn:active {
+ background: var(--colorbackline);
+ transform: scale(0.92);
+}
+.qty-display {
+ min-width: 40px;
+ text-align: center;
+ font-weight: 600;
+ font-size: 16px;
+}
+
+/* === Accordion-Sections === */
+.accordion-section {
+ margin-bottom: 6px;
+}
+.accordion-header {
+ background: var(--colorbackcard);
+ border-radius: 10px;
+ padding: 12px 14px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ cursor: pointer;
+}
+.accordion-header:active {
+ background: var(--colorbackline);
+}
+.accordion-header .section-title {
+ font-weight: 600;
+ font-size: 14px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.accordion-header .chevron {
+ transition: transform 0.2s;
+ color: var(--colortextmuted);
+}
+.accordion-header.open .chevron {
+ transform: rotate(180deg);
+}
+.accordion-body {
+ display: none;
+ padding-top: 4px;
+}
+.accordion-body.open {
+ display: block;
+}
+.section-count {
+ font-size: 12px;
+ padding: 2px 7px;
+ border-radius: 10px;
+ background: var(--colorbacktitle);
+ color: var(--colortextmuted);
+}
+
+/* === Leistungen-Cards === */
+.leistung-card {
+ background: var(--colorbackcard);
+ border-radius: 10px;
+ padding: 12px 14px;
+ margin-bottom: 6px;
+}
+.leistung-time {
+ font-size: 18px;
+ font-weight: 600;
+}
+.leistung-duration {
+ font-size: 13px;
+ color: var(--colortextmuted);
+ margin-left: 8px;
+}
+.leistung-service {
+ font-size: 13px;
+ color: var(--primary, #4390dc);
+ margin-top: 4px;
+}
+.leistung-desc {
+ font-size: 13px;
+ color: var(--colortextmuted);
+ margin-top: 4px;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+.leistung-actions {
+ display: flex;
+ gap: 8px;
+ margin-top: 8px;
+ justify-content: flex-end;
+}
+.leistung-total {
+ background: var(--colorbackcard);
+ border-radius: 10px;
+ padding: 12px 14px;
+ margin-bottom: 8px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+.leistung-total-label {
+ font-size: 14px;
+ color: var(--colortextmuted);
+}
+.leistung-total-value {
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--success);
+}
+
+/* === Merkzettel === */
+.note-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 14px;
+ background: var(--colorbackcard);
+ border-radius: 10px;
+ margin-bottom: 4px;
+}
+.note-checkbox {
+ width: 24px;
+ height: 24px;
+ flex-shrink: 0;
+ cursor: pointer;
+ color: var(--colortextmuted);
+ font-size: 20px;
+}
+.note-checkbox.checked {
+ color: var(--success);
+}
+.note-text {
+ flex: 1;
+ font-size: 14px;
+ min-width: 0;
+ word-break: break-word;
+}
+.note-text.checked {
+ text-decoration: line-through;
+ color: var(--colortextmuted);
+}
+.note-delete {
+ color: var(--colortextmuted);
+ cursor: pointer;
+ font-size: 16px;
+ padding: 4px;
+}
+.note-add {
+ display: flex;
+ gap: 8px;
+ margin-top: 4px;
+}
+.note-add input {
+ flex: 1;
+ padding: 10px 14px;
+ background: var(--colorbackinput);
+ border: 1px solid var(--colorborder);
+ border-radius: 8px;
+ color: var(--colortext);
+ font-size: 14px;
+ min-height: 44px;
+ outline: none;
+}
+
+/* === Tracking-Cards === */
+.tracking-card {
+ background: var(--colorbackcard);
+ border-radius: 10px;
+ padding: 12px 14px;
+ margin-bottom: 6px;
+}
+.tracking-card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+.tracking-qty {
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--colortext);
+ flex-shrink: 0;
+}
+.tracking-total {
+ text-align: center;
+ padding: 10px 14px;
+ margin-top: 4px;
+ font-size: 13px;
+ color: var(--colortextmuted);
+ background: var(--colorbackcard);
+ border-radius: 10px;
+}
+.text-warning { color: var(--warning); }
+.text-success { color: var(--success); }
+
+/* Leistungen Mini-Cards (Lieferauflistung Detail) */
+.leistung-card-mini {
+ background: var(--colorbackcard);
+ border-radius: 8px;
+ padding: 8px 12px;
+ margin-bottom: 4px;
+}
+.leistung-mini-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+}
+.leistung-mini-time {
+ font-size: 12px;
+ color: var(--colortextmuted);
+ margin-top: 2px;
+}
+.leistung-mini-desc {
+ font-size: 12px;
+ color: var(--colortextmuted);
+ margin-top: 2px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.badge-muted {
+ background: var(--colorbacktitle);
+ color: var(--colortextmuted);
+ font-size: 11px;
+ padding: 2px 6px;
+ border-radius: 4px;
+}
+
+/* Filter-Bar */
+.filter-bar {
+ display: flex;
+ gap: 6px;
+ margin: 8px 0;
+ padding: 0 2px;
+}
+.filter-btn {
+ flex: 1;
+ padding: 7px 0;
+ border: 1px solid var(--colorborder);
+ border-radius: 8px;
+ background: var(--colorbackcard);
+ color: var(--colortextmuted);
+ font-size: 13px;
+ font-weight: 500;
+ text-align: center;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+.filter-btn.active {
+ background: var(--primary, #4390dc);
+ color: #fff;
+ border-color: var(--primary, #4390dc);
+}
+.filter-btn:active {
+ opacity: 0.8;
+}
+
+/* Produktliste Zahlenzeile */
+.order-line-numbers {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 12px;
+ font-size: 12px;
+ color: var(--colortextmuted);
+ margin-top: 6px;
+ padding-top: 6px;
+ border-top: 1px solid var(--colorborder);
+}
+/* Kleine Info-Badges fuer Mehraufwand/Entfaellt/Ruecknahme */
+.badge-info-small {
+ background: #28a745;
+ color: #fff;
+ font-size: 0.75em;
+ padding: 1px 4px;
+ border-radius: 3px;
+}
+.badge-danger-small {
+ background: #dc3545;
+ color: #fff;
+ font-size: 0.75em;
+ padding: 1px 4px;
+ border-radius: 3px;
+}
+.badge-open {
+ background: var(--colortextmuted);
+ color: #fff;
+ font-size: 11px;
+ padding: 2px 8px;
+ border-radius: 4px;
+}
+.badge-success {
+ background: var(--success);
+ color: #fff;
+ font-size: 11px;
+ padding: 2px 8px;
+ border-radius: 4px;
+}
+.badge-warning {
+ background: var(--warning);
+ color: #fff;
+ font-size: 11px;
+ padding: 2px 8px;
+ border-radius: 4px;
+}
+
+/* === STZ-Liste Cards === */
+.stz-card {
+ background: var(--colorbackcard);
+ border-radius: 10px;
+ padding: 12px 14px;
+ margin-bottom: 6px;
+ cursor: pointer;
+ border: 2px solid transparent;
+ transition: border-color 0.2s;
+}
+.stz-card:active {
+ background: var(--colorbackline);
+}
+.stz-card.active-stz {
+ border-color: var(--primary, #4390dc);
+}
+.stz-card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+.stz-ref {
+ font-weight: 600;
+ font-size: 15px;
+}
+.stz-date {
+ font-size: 13px;
+ color: var(--colortextmuted);
+ margin-top: 2px;
+}
+.stz-info {
+ font-size: 12px;
+ color: var(--colortextmuted);
+ margin-top: 4px;
+}
+
+/* === Auftrags-Produktliste (Uebernahme) === */
+.order-line-card {
+ background: var(--colorbackcard);
+ border-radius: 10px;
+ padding: 12px 14px;
+ margin-bottom: 6px;
+}
+.order-line-card.on-stz {
+ opacity: 0.5;
+}
+.order-line-header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.order-line-check {
+ width: 22px;
+ height: 22px;
+ flex-shrink: 0;
+ accent-color: var(--primary, #4390dc);
+}
+.order-line-check-done {
+ width: 22px;
+ height: 22px;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--success);
+ font-size: 16px;
+ font-weight: bold;
+}
+.order-line-info {
+ flex: 1;
+ min-width: 0;
+}
+.order-line-status {
+ font-size: 11px;
+ color: var(--success);
+ margin-top: 4px;
+ padding-left: 32px;
+}
+.transfer-actions {
+ margin-top: 12px;
+ padding: 0 4px;
+}
+.order-line-select-all {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 14px;
+ color: var(--colortext);
+ cursor: pointer;
+}
+.order-line-select-all input {
+ width: 20px;
+ height: 20px;
+ accent-color: var(--primary, #4390dc);
+}
+
+/* === FAB (Floating Action Button) === */
+.fab {
+ position: fixed;
+ bottom: 24px;
+ right: 20px;
+ width: 56px;
+ height: 56px;
+ border-radius: 50%;
+ background: var(--primary, #4390dc);
+ color: #fff;
+ border: none;
+ font-size: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
+ cursor: pointer;
+ z-index: 50;
+ transition: transform 0.2s;
+}
+.fab:active {
+ transform: scale(0.9);
+}
+
+/* === Bottom-Sheet (Dialog) === */
+.bottom-sheet-overlay {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.6);
+ z-index: 100;
+}
+.bottom-sheet-overlay.open {
+ display: block;
+}
+.bottom-sheet {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ max-height: 85vh;
+ background: var(--colorbackcard);
+ border-radius: 16px 16px 0 0;
+ z-index: 101;
+ transform: translateY(100%);
+ transition: transform 300ms ease-out;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+.bottom-sheet.open {
+ transform: translateY(0);
+}
+.bottom-sheet-handle {
+ width: 36px;
+ height: 4px;
+ background: var(--colorborder);
+ border-radius: 2px;
+ margin: 10px auto 0;
+ flex-shrink: 0;
+}
+.bottom-sheet-header {
+ padding: 12px 16px 8px;
+ font-weight: 600;
+ font-size: 16px;
+ flex-shrink: 0;
+}
+.bottom-sheet-body {
+ padding: 8px 16px 16px;
+ overflow-y: auto;
+ flex: 1;
+}
+.bottom-sheet-footer {
+ padding: 12px 16px;
+ border-top: 1px solid var(--colorborder);
+ flex-shrink: 0;
+}
+
+/* === Toast === */
+.toast-container {
+ position: fixed;
+ top: 16px;
+ left: 16px;
+ right: 16px;
+ z-index: 200;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ pointer-events: none;
+}
+.toast {
+ padding: 12px 16px;
+ border-radius: 10px;
+ font-size: 14px;
+ font-weight: 500;
+ pointer-events: auto;
+ animation: toastIn 0.3s ease-out;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+}
+.toast-success { background: var(--success); color: #fff; }
+.toast-error { background: var(--danger); color: #fff; }
+.toast-info { background: var(--info); color: #fff; }
+
+@keyframes toastIn {
+ from { transform: translateY(-20px); opacity: 0; }
+ to { transform: translateY(0); opacity: 1; }
+}
+@keyframes toastOut {
+ from { transform: translateY(0); opacity: 1; }
+ to { transform: translateY(-20px); opacity: 0; }
+}
+
+/* === Loading-Overlay === */
+.loading-overlay {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ z-index: 150;
+ align-items: center;
+ justify-content: center;
+}
+.loading-overlay.active {
+ display: flex;
+}
+.loading-spinner {
+ width: 40px;
+ height: 40px;
+ border: 3px solid var(--colorborder);
+ border-top-color: var(--primary, #4390dc);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* === Utility === */
+.text-muted { color: var(--colortextmuted); }
+.text-success { color: var(--success); }
+.text-danger { color: var(--danger); }
+.text-warning { color: var(--warning); }
+.text-center { text-align: center; }
+.mt-8 { margin-top: 8px; }
+.mt-12 { margin-top: 12px; }
+.mb-8 { margin-bottom: 8px; }
+.gap-8 { gap: 8px; }
+.flex { display: flex; }
+.flex-col { flex-direction: column; }
+.items-center { align-items: center; }
+.justify-between { justify-content: space-between; }
+.w-full { width: 100%; }
+.hidden { display: none !important; }
+
+/* === Section-Header === */
+.section-header {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--colortextmuted);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ padding: 12px 4px 6px;
+}
+.section-header.section-mehraufwand {
+ border-left: 3px solid var(--warning);
+ padding-left: 8px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.mehraufwand-card {
+ border-left: 3px solid var(--warning);
+}
+
+/* === Confirm-Dialog === */
+.confirm-dialog {
+ position: fixed;
+ top: 0; left: 0; right: 0; bottom: 0;
+ background: rgba(0, 0, 0, 0.6);
+ z-index: 160;
+ display: none;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+}
+.confirm-dialog.active {
+ display: flex;
+}
+.confirm-box {
+ background: var(--colorbackcard);
+ border-radius: 14px;
+ padding: 20px;
+ max-width: 320px;
+ width: 100%;
+}
+.confirm-title {
+ font-weight: 600;
+ font-size: 16px;
+ margin-bottom: 8px;
+}
+.confirm-text {
+ font-size: 14px;
+ color: var(--colortextmuted);
+ margin-bottom: 20px;
+}
+.confirm-actions {
+ display: flex;
+ gap: 10px;
+}
+.confirm-actions .btn {
+ flex: 1;
+}
+
+/* === Leer-Zustand === */
+.empty-state {
+ text-align: center;
+ padding: 40px 20px;
+ color: var(--colortextmuted);
+}
+.empty-state-icon {
+ font-size: 48px;
+ margin-bottom: 12px;
+ opacity: 0.4;
+}
+.empty-state-text {
+ font-size: 14px;
+}
+
+/* Freigabe-Hinweis */
+.released-hint {
+ text-align: center;
+ padding: 20px 16px;
+ margin: 8px 0;
+ background: var(--colorbackcard);
+ border-radius: 10px;
+ border-left: 3px solid var(--warning);
+}
+.released-hint-icon {
+ font-size: 32px;
+ margin-bottom: 8px;
+}
+.released-hint-text {
+ font-size: 14px;
+ color: var(--warning);
+ font-weight: 500;
+}
+.released-hint-small {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ text-align: left;
+ padding: 12px 14px;
+}
+.released-hint-small .released-hint-icon {
+ font-size: 20px;
+ margin-bottom: 0;
+}
+.released-hint-small .released-hint-text {
+ flex: 1;
+ font-size: 13px;
+}
+.released-hint-small .btn {
+ width: 100%;
+}
+
+/* Safe-Area fuer Geraete mit Notch */
+@supports (padding-bottom: env(safe-area-inset-bottom)) {
+ .fab {
+ bottom: calc(24px + env(safe-area-inset-bottom));
+ }
+ .bottom-sheet-footer {
+ padding-bottom: calc(12px + env(safe-area-inset-bottom));
+ }
+}
diff --git a/debug_netto.php b/debug_netto.php
old mode 100644
new mode 100755
diff --git a/img/icon-192.png b/img/icon-192.png
new file mode 100644
index 0000000..eca4f5b
Binary files /dev/null and b/img/icon-192.png differ
diff --git a/img/icon-512.png b/img/icon-512.png
new file mode 100644
index 0000000..ab29bd7
Binary files /dev/null and b/img/icon-512.png differ
diff --git a/index.php b/index.php
old mode 100644
new mode 100755
diff --git a/js/pwa.js b/js/pwa.js
new file mode 100644
index 0000000..84d817f
--- /dev/null
+++ b/js/pwa.js
@@ -0,0 +1,2087 @@
+/**
+ * Stundenzettel PWA - Haupt-App-Logik
+ * Version 1.0
+ *
+ * Screens: login -> search -> main (mit 4 Swipe-Panels)
+ * Panels: 0=Alle STZ, 1=Stundenzettel (card.php), 2=Produktliste (Auftrags-Uebernahme), 3=Lieferauflistung
+ */
+(function($) {
+ 'use strict';
+
+ var App = {
+ // Auth-State
+ auth: {
+ token: null,
+ user: null
+ },
+
+ // App-State
+ state: {
+ screen: 'login',
+ orderId: null,
+ orderRef: null,
+ customerName: null,
+ stzId: null,
+ activePanel: 2, // Produktliste als Standard
+ isDragging: false,
+ canWrite: false,
+ canEditStz: false,
+ productFilter: 'open' // Filter: open/done/all (wie Desktop)
+ },
+
+ // Daten
+ data: {
+ order: null,
+ stz: null,
+ products: [],
+ leistungen: [],
+ notes: [],
+ tracking: [],
+ stzList: [],
+ orderLines: [],
+ mehraufwandLines: [],
+ services: []
+ },
+
+ // Swipe
+ swipe: {
+ startX: 0,
+ startY: 0,
+ currentX: 0,
+ isDragging: false,
+ startTime: 0,
+ panelWidth: 0
+ },
+
+ // ============================================================
+ // INITIALISIERUNG
+ // ============================================================
+
+ init: function() {
+ var self = this;
+
+ // Token aus localStorage pruefen
+ var savedToken = localStorage.getItem('stz_pwa_token');
+ if (savedToken) {
+ self.auth.token = savedToken;
+ self.showLoading();
+ self.apiAuth('verify', {token: savedToken}).then(function(res) {
+ self.hideLoading();
+ if (res.success) {
+ self.auth.user = res.user;
+ // Gespeicherten State wiederherstellen (nach Reload)
+ var savedState = self.restoreState();
+ if (savedState && savedState.orderId) {
+ self.state.activePanel = savedState.activePanel || 2;
+ self.loadOrder(savedState.orderId, savedState.stzId);
+ } else {
+ self.showScreen('search');
+ }
+ } else {
+ localStorage.removeItem('stz_pwa_token');
+ self.showScreen('login');
+ }
+ }).catch(function() {
+ self.hideLoading();
+ self.showScreen('login');
+ });
+ } else {
+ self.showScreen('login');
+ }
+
+ self.bindEvents();
+ },
+
+ // State in localStorage speichern (fuer Reload)
+ saveState: function() {
+ if (this.state.orderId) {
+ localStorage.setItem('stz_pwa_state', JSON.stringify({
+ orderId: this.state.orderId,
+ stzId: this.state.stzId,
+ activePanel: this.state.activePanel
+ }));
+ }
+ },
+
+ restoreState: function() {
+ try {
+ var saved = localStorage.getItem('stz_pwa_state');
+ return saved ? JSON.parse(saved) : null;
+ } catch(e) {
+ return null;
+ }
+ },
+
+ // ============================================================
+ // EVENT-BINDING
+ // ============================================================
+
+ bindEvents: function() {
+ var self = this;
+
+ // Login-Form
+ $('#login-form').on('submit', function(e) {
+ e.preventDefault();
+ self.handleLogin();
+ });
+
+ // Logout
+ $('#btn-logout').on('click', function() {
+ self.handleLogout();
+ });
+
+ // Suche
+ var searchTimer = null;
+ $('#search-input').on('input', function() {
+ var term = $(this).val().trim();
+ clearTimeout(searchTimer);
+ if (term.length >= 2) {
+ searchTimer = setTimeout(function() {
+ self.searchCustomers(term);
+ }, 300);
+ } else {
+ $('#search-results').html('Kundenname eingeben um Aufträge zu finden
');
+ }
+ });
+
+ // Tab-Klicks
+ $('.tab-item').on('click', function() {
+ var panel = parseInt($(this).data('panel'));
+ self.setPanel(panel);
+ });
+
+ // Zurueck-Button
+ $('#btn-back').on('click', function() {
+ self.showScreen('search');
+ });
+
+ // FAB
+ $('#fab-add').on('click', function() {
+ self.handleFabClick();
+ });
+
+ // Bottom-Sheet Overlay
+ $('#bottom-sheet-overlay').on('click', function() {
+ self.closeBottomSheet();
+ });
+
+ // Confirm-Dialog Buttons
+ $('#confirm-cancel').on('click', function() {
+ self.closeConfirm();
+ });
+
+ // Swipe-Events
+ self.initSwipe();
+ },
+
+ // ============================================================
+ // AUTH
+ // ============================================================
+
+ handleLogin: function() {
+ var self = this;
+ var username = $('#login-user').val().trim();
+ var password = $('#login-pass').val();
+
+ if (!username || !password) return;
+
+ $('#login-error').text('');
+ self.showLoading();
+
+ self.apiAuth('login', {username: username, password: password}).then(function(res) {
+ self.hideLoading();
+ if (res.success) {
+ self.auth.token = res.token;
+ self.auth.user = res.user;
+ localStorage.setItem('stz_pwa_token', res.token);
+ $('#login-user').val('');
+ $('#login-pass').val('');
+ self.showScreen('search');
+ } else {
+ $('#login-error').text(res.error || 'Login fehlgeschlagen');
+ }
+ }).catch(function() {
+ self.hideLoading();
+ $('#login-error').text('Verbindungsfehler');
+ });
+ },
+
+ handleLogout: function() {
+ this.auth.token = null;
+ this.auth.user = null;
+ localStorage.removeItem('stz_pwa_token');
+ localStorage.removeItem('stz_pwa_state');
+ this.state.orderId = null;
+ this.state.stzId = null;
+ this.data = {order: null, stz: null, products: [], leistungen: [], notes: [], tracking: [], stzList: [], orderLines: [], mehraufwandLines: [], services: [], leistungenSummary: [], leistungenAll: []};
+ this.showScreen('login');
+ },
+
+ // ============================================================
+ // API-HELPER
+ // ============================================================
+
+ apiAuth: function(action, data) {
+ data = data || {};
+ data.action = action;
+ return $.ajax({
+ url: window.STZ_CONFIG.authUrl,
+ method: 'POST',
+ data: data,
+ dataType: 'json',
+ timeout: 15000
+ });
+ },
+
+ api: function(action, data) {
+ var self = this;
+ data = data || {};
+ data.action = action;
+ data.token = self.auth.token;
+ return $.ajax({
+ url: window.STZ_CONFIG.apiUrl,
+ method: 'POST',
+ data: data,
+ dataType: 'json',
+ timeout: 30000
+ }).fail(function(xhr) {
+ if (xhr.status === 401) {
+ self.showToast('Sitzung abgelaufen', 'error');
+ self.handleLogout();
+ }
+ });
+ },
+
+ // ============================================================
+ // SCREEN-MANAGEMENT
+ // ============================================================
+
+ showScreen: function(name) {
+ $('.screen').removeClass('active');
+ $('#screen-' + name).addClass('active');
+ this.state.screen = name;
+
+ if (name === 'search') {
+ $('#search-input').focus();
+ }
+ if (name === 'main') {
+ this.updatePanelWidth();
+ this.setPanel(this.state.activePanel, false);
+ this.updateFab();
+ } else {
+ $('#fab-add').addClass('hidden');
+ }
+ },
+
+ // ============================================================
+ // TOASTS
+ // ============================================================
+
+ showToast: function(msg, type) {
+ type = type || 'info';
+ var $toast = $('' + this.escHtml(msg) + '
');
+ $('#toast-container').append($toast);
+ setTimeout(function() {
+ $toast.css('animation', 'toastOut 0.3s ease-out forwards');
+ setTimeout(function() { $toast.remove(); }, 300);
+ }, 3000);
+ },
+
+ // ============================================================
+ // LOADING
+ // ============================================================
+
+ showLoading: function() {
+ $('#loading-overlay').addClass('active');
+ },
+
+ hideLoading: function() {
+ $('#loading-overlay').removeClass('active');
+ },
+
+ // ============================================================
+ // CONFIRM-DIALOG
+ // ============================================================
+
+ showConfirm: function(title, text, okText, okClass) {
+ var self = this;
+ $('#confirm-title').text(title);
+ $('#confirm-text').text(text);
+ $('#confirm-ok').text(okText || 'OK').attr('class', 'btn ' + (okClass || 'btn-danger'));
+ $('#confirm-dialog').addClass('active');
+
+ return new Promise(function(resolve) {
+ self._confirmResolve = resolve;
+ $('#confirm-ok').off('click').on('click', function() {
+ self.closeConfirm();
+ resolve(true);
+ });
+ $('#confirm-cancel').off('click').on('click', function() {
+ self.closeConfirm();
+ resolve(false);
+ });
+ });
+ },
+
+ closeConfirm: function() {
+ $('#confirm-dialog').removeClass('active');
+ },
+
+ // ============================================================
+ // BOTTOM-SHEET
+ // ============================================================
+
+ openBottomSheet: function(title, bodyHtml, footerHtml) {
+ $('#bottom-sheet-header').html(this.escHtml(title));
+ $('#bottom-sheet-body').html(bodyHtml);
+ $('#bottom-sheet-footer').html(footerHtml || '');
+ $('#bottom-sheet-overlay').addClass('open');
+ // Kleiner Delay fuer die Animation
+ setTimeout(function() {
+ $('#bottom-sheet').addClass('open');
+ }, 10);
+ },
+
+ closeBottomSheet: function() {
+ $('#bottom-sheet').removeClass('open');
+ setTimeout(function() {
+ $('#bottom-sheet-overlay').removeClass('open');
+ $('#bottom-sheet-body').html('');
+ $('#bottom-sheet-footer').html('');
+ }, 300);
+ },
+
+ // ============================================================
+ // SUCHE
+ // ============================================================
+
+ searchCustomers: function(term) {
+ var self = this;
+ self.api('search_customers', {term: term}).then(function(res) {
+ if (res.success && res.customers) {
+ self.renderCustomerResults(res.customers);
+ } else {
+ $('#search-results').html('' + self.escHtml(res.error || 'Keine Ergebnisse') + '
');
+ }
+ }).catch(function() {
+ $('#search-results').html('');
+ });
+ },
+
+ renderCustomerResults: function(customers) {
+ var self = this;
+ if (!customers.length) {
+ $('#search-results').html('');
+ return;
+ }
+
+ var html = '';
+ customers.forEach(function(c) {
+ html += '';
+ html += '';
+ html += '
';
+ html += '
';
+ });
+
+ $('#search-results').html(html);
+
+ // Klick auf Kunde -> Auftraege laden
+ $('.customer-header').on('click', function() {
+ var $card = $(this).closest('.customer-card');
+ var customerId = $card.data('customer-id');
+ var $orders = $card.find('.customer-orders');
+
+ // Toggle
+ if ($orders.hasClass('open')) {
+ $orders.removeClass('open');
+ return;
+ }
+
+ // Andere schliessen
+ $('.customer-orders').removeClass('open');
+
+ // Auftraege laden
+ self.loadCustomerOrders(customerId, $orders);
+ });
+ },
+
+ loadCustomerOrders: function(customerId, $container) {
+ var self = this;
+ $container.html('Laden...
');
+ $container.addClass('open');
+
+ self.api('get_customer_orders', {customer_id: customerId}).then(function(res) {
+ if (res.success && res.orders) {
+ var html = '';
+ if (!res.orders.length) {
+ html = 'Keine Aufträge
';
+ } else {
+ res.orders.forEach(function(o) {
+ html += '';
+ html += '
';
+ html += '
' + self.escHtml(o.ref) + '
';
+ if (o.ref_client) {
+ html += '
' + self.escHtml(o.ref_client) + '
';
+ }
+ html += '
' + self.escHtml(o.date) + '
';
+ html += '
';
+ if (o.has_draft_stz) {
+ html += '
Offener STZ ';
+ }
+ html += '
';
+ });
+ }
+ $container.html(html);
+
+ // Klick auf Auftrag
+ $container.find('.order-item').on('click', function() {
+ var orderId = $(this).data('order-id');
+ self.loadOrder(orderId);
+ });
+ }
+ });
+ },
+
+ // ============================================================
+ // AUFTRAG LADEN
+ // ============================================================
+
+ loadOrder: function(orderId, stzId, callback) {
+ var self = this;
+ self.showLoading();
+
+ var params = {order_id: orderId};
+ if (stzId) params.stz_id = stzId;
+
+ self.api('get_order_context', params).then(function(res) {
+ self.hideLoading();
+ if (res.success) {
+ self.state.orderId = orderId;
+ self.state.orderRef = res.order.ref;
+ self.state.customerName = res.order.customer_name;
+ self.state.canWrite = res.can_write;
+ self.state.canEditStz = res.can_edit_stz;
+
+ if (res.stz) {
+ self.state.stzId = res.stz.id;
+ self.data.stz = res.stz;
+ } else {
+ self.state.stzId = null;
+ self.data.stz = null;
+ }
+
+ self.data.order = res.order;
+ self.data.products = res.products || [];
+ self.data.leistungen = res.leistungen || [];
+ self.data.notes = res.notes || [];
+ self.data.tracking = res.tracking || [];
+ self.data.stzList = res.stz_list || [];
+ self.data.orderLines = res.order_lines || [];
+ self.data.mehraufwandLines = res.mehraufwand_lines || [];
+ self.data.leistungenSummary = res.leistungen_summary || [];
+ self.data.leistungenAll = res.leistungen_all || [];
+
+ // Alle Panels rendern
+ self.renderAllPanels();
+ self.showScreen('main');
+ self.saveState();
+
+ // Callback ausfuehren (z.B. Panel-Wechsel)
+ if (typeof callback === 'function') {
+ callback();
+ }
+
+ // Wenn kein STZ existiert, direkt erstellen anbieten
+ if (!res.stz && !stzId) {
+ self.showCreateStzDialog();
+ }
+ } else {
+ self.showToast(res.error || 'Fehler beim Laden', 'error');
+ }
+ }).catch(function() {
+ self.hideLoading();
+ self.showToast('Verbindungsfehler', 'error');
+ });
+ },
+
+ // Daten neu laden (nach Aenderungen)
+ reloadData: function() {
+ if (this.state.orderId) {
+ this.loadOrder(this.state.orderId, this.state.stzId);
+ }
+ },
+
+ // ============================================================
+ // SWIPE-ENGINE
+ // ============================================================
+
+ initSwipe: function() {
+ var self = this;
+ var container = document.getElementById('swipe-container');
+ if (!container) return;
+
+ container.addEventListener('touchstart', function(e) {
+ if (!$('#screen-main').hasClass('active')) return;
+ var touch = e.touches[0];
+ self.swipe.startX = touch.clientX;
+ self.swipe.startY = touch.clientY;
+ self.swipe.currentX = 0;
+ self.swipe.isDragging = false;
+ self.swipe.startTime = Date.now();
+ self.swipe.panelWidth = container.parentElement.offsetWidth;
+ container.classList.remove('animating');
+ }, {passive: true});
+
+ container.addEventListener('touchmove', function(e) {
+ if (!$('#screen-main').hasClass('active')) return;
+ var touch = e.touches[0];
+ var diffX = touch.clientX - self.swipe.startX;
+ var diffY = touch.clientY - self.swipe.startY;
+
+ // Vertikales Scrollen hat Vorrang
+ if (!self.swipe.isDragging && Math.abs(diffY) > Math.abs(diffX) && Math.abs(diffY) > 10) {
+ return;
+ }
+
+ if (Math.abs(diffX) > 10) {
+ self.swipe.isDragging = true;
+ }
+
+ if (self.swipe.isDragging) {
+ self.swipe.currentX = diffX;
+ var baseOffset = -(self.state.activePanel * 25);
+ var dragPercent = (diffX / self.swipe.panelWidth) * 25;
+
+ // Grenzen: Nicht ueber Panel 0 oder 3 hinaus
+ var newOffset = baseOffset + dragPercent;
+ if (newOffset > 0) newOffset = newOffset * 0.3; // Resistance
+ if (newOffset < -75) newOffset = -75 + (newOffset + 75) * 0.3;
+
+ container.style.transform = 'translateX(' + newOffset + '%)';
+ }
+ }, {passive: true});
+
+ container.addEventListener('touchend', function(e) {
+ if (!self.swipe.isDragging) return;
+ self.swipe.isDragging = false;
+
+ var elapsed = Date.now() - self.swipe.startTime;
+ var velocity = Math.abs(self.swipe.currentX) / elapsed;
+ var threshold = self.swipe.panelWidth * 0.25;
+
+ var newPanel = self.state.activePanel;
+
+ // Schneller Swipe oder weiter als 25% gezogen
+ if (self.swipe.currentX > threshold || (velocity > 0.5 && self.swipe.currentX > 30)) {
+ newPanel = Math.max(0, self.state.activePanel - 1);
+ } else if (self.swipe.currentX < -threshold || (velocity > 0.5 && self.swipe.currentX < -30)) {
+ newPanel = Math.min(3, self.state.activePanel + 1);
+ }
+
+ self.setPanel(newPanel, true);
+ }, {passive: true});
+ },
+
+ setPanel: function(index, animate) {
+ var container = document.getElementById('swipe-container');
+ if (!container) return;
+
+ if (animate !== false) {
+ container.classList.add('animating');
+ setTimeout(function() {
+ container.classList.remove('animating');
+ }, 350);
+ }
+
+ container.style.transform = 'translateX(' + -(index * 25) + '%)';
+ this.state.activePanel = index;
+
+ // Tabs aktualisieren
+ $('.tab-item').removeClass('active');
+ $('.tab-item[data-panel="' + index + '"]').addClass('active');
+
+ // FAB aktualisieren
+ this.updateFab();
+
+ // State speichern
+ this.saveState();
+
+ // Haptic Feedback
+ if (navigator.vibrate && animate !== false) {
+ navigator.vibrate(10);
+ }
+ },
+
+ updatePanelWidth: function() {
+ var viewport = document.querySelector('.swipe-viewport');
+ if (viewport) {
+ this.swipe.panelWidth = viewport.offsetWidth;
+ }
+ },
+
+ // ============================================================
+ // FAB (Floating Action Button)
+ // ============================================================
+
+ updateFab: function() {
+ var $fab = $('#fab-add');
+ var stz = this.data.stz;
+ var isDraft = stz && stz.status == 0;
+
+ // FAB auf Panel 0 (neuen STZ anlegen) und Panel 1 (Leistung hinzufuegen, nur Draft)
+ if (this.state.activePanel === 0 && this.state.canWrite) {
+ $fab.removeClass('hidden');
+ } else if (this.state.activePanel === 1 && isDraft && this.state.canEditStz) {
+ $fab.removeClass('hidden');
+ } else {
+ $fab.addClass('hidden');
+ }
+ },
+
+ handleFabClick: function() {
+ switch (this.state.activePanel) {
+ case 0:
+ this.showCreateStzDialog();
+ break;
+ case 1:
+ this.showAddLeistungDialog();
+ break;
+ }
+ },
+
+ // ============================================================
+ // PANEL-RENDERING
+ // ============================================================
+
+ renderAllPanels: function() {
+ this.renderPanelStzList();
+ this.renderPanelStundenzettel();
+ this.renderPanelProducts();
+ this.renderPanelTracking();
+ },
+
+ // ---- Panel 0: Alle Stundenzettel ----
+ renderPanelStzList: function() {
+ var self = this;
+ var html = '';
+
+ // Header
+ html += '';
+
+ // Freigabe-Hinweis wenn alle STZ freigegeben
+ if (self.allStzReleased()) {
+ html += self.renderReleasedHint();
+ }
+
+ html += '';
+
+ if (!self.data.stzList.length) {
+ html += '';
+ html += '
📋
';
+ html += '
Noch keine Stundenzettel vorhanden
';
+ html += '
';
+ } else {
+ self.data.stzList.forEach(function(s) {
+ var isActive = self.state.stzId && s.id == self.state.stzId;
+ html += '';
+ html += '';
+ html += '
' + self.escHtml(s.date) + '
';
+ html += '
' + s.leistung_count + ' Leistungen, ' + self.escHtml(s.total_hours || '0h') + ' | ' + s.product_count + ' Produkte
';
+ html += '
';
+ });
+ }
+
+ $('#panel-stzlist').html(html);
+
+ // Klick auf STZ-Card -> wechseln und zu Panel 1 navigieren
+ $('#panel-stzlist').find('.stz-card').on('click', function() {
+ var stzId = $(this).data('stz-id');
+ if (stzId != self.state.stzId) {
+ self.loadOrder(self.state.orderId, stzId, function() {
+ self.setPanel(1);
+ });
+ } else {
+ // Gleicher STZ - nur zu Panel 1 wechseln
+ self.setPanel(1);
+ }
+ });
+
+ // Neuen STZ anlegen Button
+ $('#panel-stzlist').find('.btn-create-new-stz').on('click', function() {
+ self.showCreateStzDialog();
+ });
+ },
+
+ // ---- Panel 1: Stundenzettel (= card.php: Leistungen + Produkte + Mehraufwand + Entfaellt + Ruecknahme + Merkzettel) ----
+ renderPanelStundenzettel: function() {
+ var self = this;
+ var stz = self.data.stz;
+ var $panel = $('#panel-stundenzettel');
+ var html = '';
+
+ if (!stz) {
+ html += '';
+ html += '
📋
';
+ html += '
Kein Stundenzettel ausgewählt
';
+ html += '
Wähle im Tab "Alle STZ" einen Stundenzettel oder erstelle einen neuen.
';
+ html += '
';
+ $panel.html(html);
+ return;
+ }
+
+ var isDraft = stz.status == 0;
+ var canWrite = self.state.canEditStz; // STZ-Panel: Nur editierbar wenn Draft + Berechtigung
+
+ // Header
+ html += '';
+
+ // Hinweis wenn STZ freigegeben
+ if (!isDraft) {
+ html += '';
+ html += '🔒 ';
+ html += 'Dieser Stundenzettel ist freigegeben – keine Änderungen möglich. ';
+ if (self.state.canWrite) {
+ html += 'Neuen STZ anlegen ';
+ }
+ html += '
';
+ }
+
+ // ---- LEISTUNGEN (Accordion) ----
+ var totalMinutes = 0;
+ self.data.leistungen.forEach(function(l) { totalMinutes += (l.duration_minutes || 0); });
+ var hasLeistungen = self.data.leistungen.length > 0;
+
+ if (isDraft || hasLeistungen) {
+ html += '';
+ html += 'Gesamt ';
+ html += '' + self.formatDuration(totalMinutes) + ' ';
+ html += '
';
+
+ // Accordion: Leistungen
+ html += '';
+ html += '';
+
+ html += '
';
+ if (!hasLeistungen) {
+ html += '
Noch keine Leistungen erfasst
';
+ } else {
+ self.data.leistungen.forEach(function(l) {
+ html += '
';
+ html += '
';
+ html += '' + self.escHtml(l.time_start) + ' – ' + self.escHtml(l.time_end) + ' ';
+ html += '' + self.formatDuration(l.duration_minutes) + ' ';
+ html += '
';
+ if (l.service_name) {
+ html += '
' + self.escHtml(l.service_name) + '
';
+ }
+ if (l.description) {
+ html += '
' + self.escHtml(l.description) + '
';
+ }
+ if (isDraft && canWrite) {
+ html += '
';
+ html += 'Bearbeiten ';
+ html += '🗑 ';
+ html += '
';
+ }
+ html += '
';
+ });
+ }
+ html += '
'; // accordion-body
+
+ // Button AUSSERHALB accordion-body - immer sichtbar
+ if (isDraft && canWrite) {
+ html += '
+ Leistung hinzufügen ';
+ }
+ html += '
'; // accordion-section
+ }
+
+ // ---- PRODUKTE (Verbaut: origin='order' + 'added') ----
+ var verbaut = self.data.products.filter(function(p) { return p.origin === 'order' || p.origin === 'added'; });
+ var mehraufwand = self.data.products.filter(function(p) { return p.origin === 'additional'; });
+ var entfaellt = self.data.products.filter(function(p) { return p.origin === 'omitted'; });
+ var ruecknahmen = self.data.products.filter(function(p) { return p.origin === 'returned'; });
+
+ if (isDraft || verbaut.length) {
+ html += '';
+
+ if (!verbaut.length) {
+ html += 'Noch keine Produkte verbaut
';
+ } else {
+ verbaut.forEach(function(p) {
+ html += self.renderProductCard(p, isDraft, canWrite);
+ });
+ }
+
+ // Produkt hinzufuegen Button
+ if (isDraft && canWrite) {
+ html += '+ Produkt hinzufügen ';
+ }
+ }
+
+ // ---- MEHRAUFWAND (nur anzeigen wenn Inhalt oder Entwurf) ----
+ if (isDraft || mehraufwand.length) {
+ html += self.renderAccordion('mehraufwand', 'Mehraufwand', mehraufwand, 'additional', isDraft, canWrite);
+ }
+
+ // ---- ENTFAELLT ----
+ if (isDraft || entfaellt.length) {
+ html += self.renderAccordion('entfaellt', 'Entfällt', entfaellt, 'omitted', isDraft, canWrite);
+ }
+
+ // ---- RUECKNAHMEN ----
+ if (isDraft || ruecknahmen.length) {
+ html += self.renderAccordion('ruecknahmen', 'Rücknahmen', ruecknahmen, 'returned', isDraft, canWrite);
+ }
+
+ // ---- MERKZETTEL (Accordion, eingeklappt wenn leer) ----
+ if (isDraft || self.data.notes.length) {
+ var merkzettelOpen = self.data.notes.length > 0;
+ html += '';
+ html += '';
+ html += '
';
+
+ if (!self.data.notes.length) {
+ html += '
';
+ } else {
+ self.data.notes.forEach(function(n) {
+ var checked = n.checked == 1;
+ html += '
';
+ html += '';
+ html += checked ? '☑' : '☐';
+ html += ' ';
+ html += '' + self.escHtml(n.note) + ' ';
+ if (isDraft && canWrite) {
+ html += '✕ ';
+ }
+ html += '
';
+ });
+ }
+ html += '
'; // accordion-body
+
+ // Notiz-Input AUSSERHALB accordion-body - immer sichtbar
+ if (isDraft && canWrite) {
+ html += '
';
+ html += ' ';
+ html += '+ ';
+ html += '
';
+ }
+ html += '
'; // accordion-section
+ }
+
+ $panel.html(html);
+
+ // ---- Event-Listener ----
+ // Leistungen
+ $panel.find('.btn-edit-leistung').on('click', function(e) {
+ e.stopPropagation();
+ self.showEditLeistungDialog($(this).data('id'));
+ });
+ $panel.find('.btn-delete-leistung').on('click', function(e) {
+ e.stopPropagation();
+ self.deleteLeistung($(this).data('id'));
+ });
+ $panel.find('.btn-add-leistung').on('click', function() {
+ self.showAddLeistungDialog();
+ });
+
+ // Produkte +/-
+ $panel.find('.btn-qty-minus').on('click', function(e) {
+ e.stopPropagation();
+ var qty = parseFloat($(this).data('qty'));
+ if (qty > 0) self.updateQty($(this).data('id'), qty - 1);
+ });
+ $panel.find('.btn-qty-plus').on('click', function(e) {
+ e.stopPropagation();
+ var id = $(this).data('id');
+ var qty = parseFloat($(this).data('qty'));
+ var max = parseFloat($(this).data('max'));
+ var newQty = qty + 1;
+ 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 {
+ self.updateQty(id, newQty);
+ }
+ });
+
+ // Produkt hinzufuegen
+ $panel.find('#btn-add-product-inline').on('click', function() {
+ self.showAddProductDialog();
+ });
+
+ // Accordion
+ $panel.find('.accordion-header').on('click', function() {
+ var targetId = $(this).data('target');
+ $(this).toggleClass('open');
+ $('#' + targetId).toggleClass('open');
+ });
+
+ // Delete-Buttons in Accordions
+ $panel.find('.btn-delete-line').on('click', function(e) {
+ e.stopPropagation();
+ var id = $(this).data('id');
+ var origin = $(this).data('origin');
+ self.showConfirm('L\u00f6schen?', 'Diesen Eintrag wirklich l\u00f6schen?', 'L\u00f6schen').then(function(ok) {
+ if (ok) self.deleteLine(id, origin);
+ });
+ });
+
+ // Section-Add Buttons
+ $panel.find('.btn-add-section').on('click', function() {
+ var section = $(this).data('section');
+ switch (section) {
+ case 'mehraufwand': self.showAddMehraufwandDialog(); break;
+ case 'entfaellt': self.showAddEntfaelltDialog(); break;
+ case 'ruecknahmen': self.showAddRuecknahmeDialog(); break;
+ }
+ });
+
+ // Merkzettel
+ $panel.find('.note-checkbox').on('click', function() {
+ self.toggleNote($(this).data('id'), $(this).data('checked'));
+ });
+ $panel.find('.note-delete').on('click', function() {
+ self.deleteNote($(this).data('id'));
+ });
+ $('#btn-add-note').on('click', function() { self.addNote(); });
+ $('#note-input').on('keypress', function(e) { if (e.which === 13) self.addNote(); });
+
+ // Neuen STZ anlegen (bei freigegebenem STZ)
+ $panel.find('.btn-create-new-stz').on('click', function() {
+ self.showCreateStzDialog();
+ });
+ },
+
+ // ---- Panel 2: Produktliste (= stundenzettel_commande.php: Auftragspositionen zum Uebernehmen) ----
+ // Pruefen ob ein Draft-STZ existiert
+ hasDraftStz: function() {
+ for (var i = 0; i < this.data.stzList.length; i++) {
+ if (this.data.stzList[i].status == 0) return true;
+ }
+ return false;
+ },
+
+ // Pruefen ob alle STZ freigegeben (status >= 1) sind
+ allStzReleased: function() {
+ if (!this.data.stzList.length) return false;
+ for (var i = 0; i < this.data.stzList.length; i++) {
+ if (this.data.stzList[i].status == 0) return false;
+ }
+ return true;
+ },
+
+ // Wiederverwendbarer Hinweis-Block: Alle STZ freigegeben + Neuen anlegen
+ renderReleasedHint: function() {
+ var html = '';
+ html += '';
+ html += '
🔒
';
+ html += '
Alle Stundenzettel sind freigegeben.
';
+ html += '
Neuen Stundenzettel anlegen ';
+ html += '
';
+ return html;
+ },
+
+ renderPanelProducts: function() {
+ var self = this;
+ var stz = self.data.stz;
+ var $panel = $('#panel-products');
+ var html = '';
+
+ // Info-Header
+ html += '';
+
+ // STZ-Hinweis: Neuen anlegen wenn keiner da oder alle freigegeben
+ if (!stz || (stz && stz.status != 0)) {
+ if (self.allStzReleased()) {
+ html += self.renderReleasedHint();
+ } else if (!stz) {
+ html += '';
+ html += '
Kein Stundenzettel vorhanden
';
+ html += '
Neuen Stundenzettel anlegen ';
+ html += '
';
+ }
+ }
+
+ if (!self.data.orderLines.length) {
+ html += '';
+ html += '
📦
';
+ html += '
Keine Produkte im Auftrag
';
+ html += '
';
+ $panel.html(html);
+
+ // Event-Listener fuer STZ-Anlegen Button
+ $panel.find('.btn-create-new-stz').on('click', function() {
+ self.showCreateStzDialog();
+ });
+ return;
+ }
+
+ var canWrite = self.state.canWrite;
+ var hasSelectable = false;
+ var activeFilter = self.state.productFilter || 'open';
+
+ // Filter-Buttons (wie Desktop: Offen/Erledigt/Alle)
+ html += '';
+ html += 'Offen ';
+ html += 'Erledigt ';
+ html += 'Alle ';
+ html += '
';
+
+ html += '';
+
+ var visibleCount = 0;
+ self.data.orderLines.forEach(function(line) {
+ var isOnStz = line.already_on_stz;
+ var remaining = line.qty_remaining || 0;
+ var delivered = line.qty_delivered || 0;
+ var qtyEffective = line.qty_effective || line.qty;
+ var qtyAdditional = line.qty_additional || 0;
+ var qtyOmitted = line.qty_omitted || 0;
+ var qtyReturned = line.qty_returned || 0;
+ var isDone = (remaining <= 0);
+ var isPartial = delivered > 0 && remaining > 0;
+
+ // Filter anwenden
+ if (self.state.productFilter === 'open' && isDone) return;
+ if (self.state.productFilter === 'done' && !isDone) return;
+ visibleCount++;
+
+ html += '';
+ html += '';
+
+ // Zahlenzeile: Beauftragt / Verbaut / Verbleibend (wie Desktop)
+ html += '
';
+ // Beauftragt = effektive Menge mit Aenderungs-Badges (wie Desktop)
+ html += 'Beauftragt: ' + self.formatQty(qtyEffective) + ' ';
+ if (qtyAdditional > 0) html += ' +' + self.formatQty(qtyAdditional) + ' ';
+ if (qtyOmitted > 0) html += ' -' + self.formatQty(qtyOmitted) + ' ';
+ if (qtyReturned > 0) html += ' -' + self.formatQty(qtyReturned) + ' ';
+ html += ' ';
+ // Verbaut
+ html += 'Verbaut: ' + self.formatQty(delivered) + ' ';
+ // Verbleibend
+ html += 'Verbleibend: ' + self.formatQty(remaining) + ' ';
+ html += '
';
+
+ if (isOnStz) {
+ html += '
Auf aktuellem Stundenzettel
';
+ }
+ html += '
';
+ });
+
+ // Hinweis wenn Filter keine Ergebnisse liefert
+ if (visibleCount === 0) {
+ if (activeFilter === 'open') {
+ html += '';
+ } else if (activeFilter === 'done') {
+ html += 'Noch keine Produkte erledigt
';
+ }
+ }
+
+ // Mehraufwand-Sektion (Produkte nicht aus Auftrag, wie Desktop)
+ var mehraufwand = self.data.mehraufwandLines || [];
+ if (mehraufwand.length) {
+ var filteredMA = mehraufwand.filter(function(ma) {
+ if (self.state.productFilter === 'open' && ma.is_done) return false;
+ if (self.state.productFilter === 'done' && !ma.is_done) return false;
+ return true;
+ });
+
+ if (filteredMA.length) {
+ html += '';
+ html += '⚠ Mehraufwand';
+ html += ' ' + filteredMA.length + ' ';
+ html += '
';
+
+ filteredMA.forEach(function(ma) {
+ var isDone = ma.is_done;
+ var remaining = ma.qty_remaining || 0;
+ var done = ma.qty_done || 0;
+ var target = ma.qty_target || 0;
+ var returned = ma.qty_returned || 0;
+
+ html += '';
+ html += '';
+
+ // Zahlenzeile: Beauftragt / Verbaut / Verbleibend
+ html += '
';
+ html += 'Beauftragt: ' + self.formatQty(target) + ' ';
+ if (returned > 0) html += ' -' + self.formatQty(returned) + ' ';
+ html += ' ';
+ html += 'Verbaut: ' + self.formatQty(done) + ' ';
+ if (returned > 0) html += ' -' + self.formatQty(returned) + ' ';
+ html += ' ';
+ html += 'Verbleibend: ' + self.formatQty(remaining) + ' ';
+ html += '
';
+
+ // STZ-Referenzen
+ if (ma.stz_refs) {
+ html += '
' + self.escHtml(ma.stz_refs) + '
';
+ }
+ html += '
';
+ });
+ }
+ }
+
+ // Uebernehmen-Button (immer zeigen wenn Schreibrecht - auch ohne Auswahl wird STZ erstellt)
+ if (canWrite) {
+ html += '';
+ if (hasSelectable) {
+ html += ' Alle auswählen ';
+ }
+ html += 'Übernehmen in Stundenzettel ';
+ html += '
';
+ }
+
+ $panel.html(html);
+
+ // Event-Listener
+ $('#select-all-lines').on('change', function() {
+ var checked = $(this).is(':checked');
+ $panel.find('.order-line-check').prop('checked', checked);
+ });
+
+ $('#btn-transfer-products').on('click', function() {
+ // Auftragspositionen (commandedet IDs)
+ var selectedLines = [];
+ $panel.find('.order-line-check:checked').not('.ma-line-check').each(function() {
+ selectedLines.push($(this).data('line-id'));
+ });
+ // Mehraufwand-Produkte
+ var selectedMA = [];
+ $panel.find('.ma-line-check:checked').each(function() {
+ selectedMA.push({
+ fk_product: $(this).data('fk-product') || 0,
+ description: $(this).data('description') || '',
+ qty: $(this).data('qty') || 0
+ });
+ });
+ if (!selectedLines.length && !selectedMA.length) {
+ // Keine Produkte ausgewaehlt - nur STZ erstellen
+ self.transferProducts([], []);
+ } else {
+ self.transferProducts(selectedLines, selectedMA);
+ }
+ });
+
+ // Filter-Buttons
+ $panel.find('.filter-btn').on('click', function() {
+ var filter = $(this).data('filter');
+ self.state.productFilter = filter;
+ self.renderPanelProducts();
+ });
+
+ // Neuen STZ anlegen Button
+ $panel.find('.btn-create-new-stz').on('click', function() {
+ self.showCreateStzDialog();
+ });
+ },
+
+ renderProductCard: function(p, isDraft, canWrite) {
+ var self = this;
+ var html = '';
+
+ html += '';
+ html += '';
+
+ html += '
';
+ if (p.qty_original > 0) {
+ html += '
Auftrag: ' + self.formatQty(p.qty_original) + ' ';
+ html += '
| ';
+ }
+ html += '
Verbaut: ';
+
+ if (isDraft && canWrite) {
+ html += '
';
+ html += '− ';
+ html += '' + self.formatQty(p.qty_done) + ' ';
+ html += '+ ';
+ html += '
';
+ } else {
+ html += '
' + self.formatQty(p.qty_done) + ' ';
+ }
+ html += '
';
+ html += '
';
+
+ return html;
+ },
+
+ renderAccordion: function(id, title, items, origin, isDraft, canWrite) {
+ var self = this;
+ var html = '';
+
+ html += '';
+ html += '';
+
+ html += '
';
+
+ if (!items.length) {
+ html += '
';
+ } else {
+ items.forEach(function(p) {
+ html += '
';
+ html += '';
+ html += '
';
+ html += 'Menge: ' + self.formatQty(p.qty_done) + ' ';
+ html += '
';
+ if (p.description && p.label) {
+ html += '
' + self.escHtml(p.description) + '
';
+ }
+ html += '
';
+ });
+ }
+
+ html += '
'; // accordion-body
+
+ // Hinzufuegen-Button AUSSERHALB accordion-body - immer sichtbar
+ if (isDraft && canWrite) {
+ html += '
';
+ html += '+ ' + title + ' hinzufügen ';
+ }
+
+ html += '
'; // accordion-section
+
+ return html;
+ },
+
+ // Transfer-Funktion: Auftragspositionen in STZ uebernehmen
+ // Wenn kein Draft-STZ existiert, wird automatisch ein neuer erstellt (wie Desktop)
+ transferProducts: function(lineIds, maProducts) {
+ var self = this;
+ var stz = self.data.stz;
+ var hasDraft = stz && stz.status == 0;
+ maProducts = maProducts || [];
+ var hasAny = lineIds.length || maProducts.length;
+
+ if (hasDraft) {
+ if (hasAny) {
+ // Draft vorhanden + Produkte ausgewaehlt - direkt uebernehmen
+ self._doTransfer(lineIds, maProducts);
+ } else {
+ // Draft vorhanden aber keine Produkte - zum STZ wechseln
+ self.setPanel(1);
+ self.showToast('Stundenzettel ge\u00f6ffnet', 'success');
+ }
+ } else {
+ // Kein Draft - neuen STZ anlegen
+ self.showLoading();
+ var today = new Date().toISOString().substr(0, 10);
+ self.api('create_stundenzettel', {
+ order_id: self.state.orderId,
+ date: today
+ }).then(function(res) {
+ if (res.success) {
+ self.state.stzId = res.stz_id;
+ if (hasAny) {
+ // Produkte ausgewaehlt - uebernehmen
+ self._doTransfer(lineIds, maProducts);
+ } else {
+ // Keine Produkte - nur STZ erstellt, neu laden
+ self.hideLoading();
+ self.showToast('Stundenzettel erstellt', 'success');
+ self.loadOrder(self.state.orderId, res.stz_id);
+ }
+ } else {
+ self.hideLoading();
+ self.showToast(res.error || 'Fehler beim Erstellen', 'error');
+ }
+ }).catch(function() {
+ self.hideLoading();
+ self.showToast('Verbindungsfehler', 'error');
+ });
+ }
+ },
+
+ _doTransfer: function(lineIds, maProducts) {
+ var self = this;
+ self.showLoading();
+ var params = {
+ stz_id: self.state.stzId,
+ line_ids: lineIds.join(',')
+ };
+ if (maProducts && maProducts.length) {
+ params.ma_products = JSON.stringify(maProducts);
+ }
+ self.api('transfer_order_products', params).then(function(res) {
+ self.hideLoading();
+ if (res.success) {
+ self.showToast(res.added + ' Produkte \u00fcbernommen', 'success');
+ self.reloadData();
+ } else {
+ self.showToast(res.error || 'Fehler', 'error');
+ }
+ }).catch(function() {
+ self.hideLoading();
+ self.showToast('Verbindungsfehler', 'error');
+ });
+ },
+
+ // ---- Panel 3: Lieferauflistung (wie Desktop: Produkte + Stunden) ----
+ renderPanelTracking: function() {
+ var self = this;
+ var html = '';
+
+ html += '';
+
+ // ---- PRODUKTE ----
+ html += '';
+
+ if (!self.data.tracking.length) {
+ html += '';
+ html += '
🚚
';
+ html += '
Keine Produkte im Auftrag
';
+ html += '
';
+ } else {
+ var totalDelivered = 0;
+
+ self.data.tracking.forEach(function(t) {
+ totalDelivered += t.qty_delivered;
+
+ html += '';
+ html += '';
+ html += '
';
+ });
+
+ // Summenzeile
+ html += '';
+ html += 'Gesamt verbaut: ' + self.formatQty(totalDelivered);
+ html += '
';
+ }
+
+ // ---- ARBEITSSTUNDEN ----
+ var leistSummary = self.data.leistungenSummary || [];
+ var leistAll = self.data.leistungenAll || [];
+
+ if (leistSummary.length || leistAll.length) {
+ html += '';
+
+ if (leistSummary.length) {
+ // Gesamtstunden berechnen
+ var totalMinAll = 0;
+ leistSummary.forEach(function(ls) { totalMinAll += ls.total_minutes; });
+ var totalH = Math.floor(totalMinAll / 60);
+ var totalM = totalMinAll % 60;
+
+ html += '';
+ html += 'Gesamt ';
+ html += '' + totalH + ':' + (totalM < 10 ? '0' : '') + totalM + ' h ';
+ html += '
';
+
+ // Pro Leistungsposition
+ leistSummary.forEach(function(ls) {
+ html += '';
+ html += '';
+ html += '
' + ls.entry_count + ' Eintr\u00e4ge
';
+ html += '
';
+ });
+ }
+
+ // Einzelne Leistungen (aufklappbar)
+ if (leistAll.length) {
+ html += '';
+ html += '';
+ html += '
';
+
+ leistAll.forEach(function(l) {
+ html += '
';
+ html += '';
+ if (l.time_start && l.time_end) {
+ html += '
' + self.escHtml(l.time_start) + ' - ' + self.escHtml(l.time_end);
+ if (l.service) html += ' · ' + self.escHtml(l.service);
+ html += '
';
+ }
+ if (l.description) {
+ html += '
' + self.escHtml(l.description) + '
';
+ }
+ html += '
';
+ });
+
+ html += '
';
+ html += '
';
+ }
+ }
+
+ $('#panel-tracking').html(html);
+
+ // Accordion
+ $('#panel-tracking').find('.accordion-header').on('click', function() {
+ var targetId = $(this).data('target');
+ $(this).toggleClass('open');
+ $('#' + targetId).toggleClass('open');
+ });
+ },
+
+ // ============================================================
+ // AKTIONEN: Leistungen
+ // ============================================================
+
+ showAddLeistungDialog: function() {
+ var self = this;
+ var stz = self.data.stz;
+ if (!stz) return;
+
+ var html = '';
+ html += 'Datum ';
+ html += '
';
+ html += '';
+ html += 'Beschreibung (optional) ';
+ html += '
';
+
+ var footer = 'Leistung hinzufügen ';
+
+ self.openBottomSheet('Leistung hinzuf\u00fcgen', html, footer);
+
+ $('#dlg-leistung-save').on('click', function() {
+ self.saveLeistung();
+ });
+ },
+
+ showEditLeistungDialog: function(id) {
+ var self = this;
+ var leistung = self.data.leistungen.find(function(l) { return l.id == id; });
+ if (!leistung) return;
+
+ var html = '';
+ html += 'Datum ';
+ html += '
';
+ html += '';
+ html += 'Beschreibung ';
+ html += '
';
+ html += ' ';
+
+ var footer = 'Speichern ';
+
+ self.openBottomSheet('Leistung bearbeiten', html, footer);
+
+ $('#dlg-leistung-save').on('click', function() {
+ self.saveLeistung(id);
+ });
+ },
+
+ saveLeistung: function(editId) {
+ var self = this;
+ var data = {
+ stz_id: self.state.stzId,
+ date: $('#dlg-leistung-date').val(),
+ time_start: $('#dlg-leistung-start').val(),
+ time_end: $('#dlg-leistung-end').val(),
+ description: $('#dlg-leistung-desc').val()
+ };
+
+ if (!data.date || !data.time_start || !data.time_end) {
+ self.showToast('Bitte alle Zeitfelder ausf\u00fcllen', 'error');
+ return;
+ }
+ if (data.time_start >= data.time_end) {
+ self.showToast('Endzeit muss nach Startzeit liegen', 'error');
+ return;
+ }
+
+ var action = editId ? 'update_leistung' : 'add_leistung';
+ if (editId) data.leistung_id = editId;
+
+ self.showLoading();
+ self.api(action, data).then(function(res) {
+ self.hideLoading();
+ if (res.success) {
+ self.closeBottomSheet();
+ self.showToast(editId ? 'Leistung aktualisiert' : 'Leistung hinzugef\u00fcgt', 'success');
+ self.reloadData();
+ } else {
+ self.showToast(res.error || 'Fehler', 'error');
+ }
+ }).catch(function() {
+ self.hideLoading();
+ self.showToast('Verbindungsfehler', 'error');
+ });
+ },
+
+ deleteLeistung: function(id) {
+ var self = this;
+ self.showConfirm('Leistung l\u00f6schen?', 'Diese Leistung wirklich l\u00f6schen?', 'L\u00f6schen').then(function(ok) {
+ if (!ok) return;
+ self.showLoading();
+ self.api('delete_leistung', {leistung_id: id, stz_id: self.state.stzId}).then(function(res) {
+ self.hideLoading();
+ if (res.success) {
+ self.showToast('Leistung gel\u00f6scht', 'success');
+ self.reloadData();
+ } else {
+ self.showToast(res.error || 'Fehler', 'error');
+ }
+ });
+ });
+ },
+
+ // ============================================================
+ // AKTIONEN: Notizen
+ // ============================================================
+
+ addNote: function() {
+ var self = this;
+ var text = $('#note-input').val().trim();
+ if (!text) return;
+
+ self.api('add_note', {stz_id: self.state.stzId, note: text}).then(function(res) {
+ if (res.success) {
+ $('#note-input').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) {
+ if (res.success) {
+ self.reloadData();
+ }
+ });
+ },
+
+ deleteNote: function(id) {
+ var self = this;
+ self.api('delete_note', {note_id: id, stz_id: self.state.stzId}).then(function(res) {
+ if (res.success) {
+ self.reloadData();
+ }
+ });
+ },
+
+ // ============================================================
+ // AKTIONEN: Produkte
+ // ============================================================
+
+ updateQty: function(lineId, newQty) {
+ var self = this;
+ self.api('update_qty', {line_id: lineId, qty_done: newQty, stz_id: self.state.stzId}).then(function(res) {
+ if (res.success) {
+ self.reloadData();
+ } else {
+ self.showToast(res.error || 'Fehler', 'error');
+ }
+ });
+ },
+
+ deleteLine: function(lineId, origin) {
+ var self = this;
+ var actionMap = {
+ 'additional': 'delete_mehraufwand',
+ 'omitted': 'delete_entfaellt',
+ 'returned': 'delete_ruecknahme'
+ };
+ var action = actionMap[origin] || 'delete_product';
+
+ self.showLoading();
+ self.api(action, {line_id: lineId, stz_id: self.state.stzId}).then(function(res) {
+ self.hideLoading();
+ if (res.success) {
+ self.showToast('Gel\u00f6scht', 'success');
+ self.reloadData();
+ } else {
+ self.showToast(res.error || 'Fehler', 'error');
+ }
+ });
+ },
+
+ showAddProductDialog: function() {
+ var self = this;
+ var html = '';
+ html += 'Produkt suchen ';
+ html += '
';
+ html += '
';
+ html += 'Oder Freitext ';
+ html += '
';
+ html += 'Menge ';
+ html += '
';
+ html += ' ';
+
+ var footer = 'Produkt hinzufügen ';
+
+ self.openBottomSheet('Produkt hinzuf\u00fcgen', html, footer);
+
+ // Produktsuche
+ var timer = null;
+ $('#dlg-product-search').on('input', function() {
+ var term = $(this).val().trim();
+ clearTimeout(timer);
+ if (term.length >= 2) {
+ timer = setTimeout(function() {
+ self.searchProducts(term);
+ }, 300);
+ } else {
+ $('#dlg-product-results').html('');
+ }
+ });
+
+ $('#dlg-product-save').on('click', function() {
+ self.saveProduct();
+ });
+ },
+
+ searchProducts: function(term) {
+ var self = this;
+ self.api('search_products', {term: term}).then(function(res) {
+ if (res.success && res.products) {
+ var html = '';
+ res.products.forEach(function(p) {
+ html += '';
+ html += '
' + self.escHtml(p.label) + '
';
+ html += '
' + self.escHtml(p.ref) + '
';
+ html += '
';
+ });
+ $('#dlg-product-results').html(html);
+
+ $('#dlg-product-results .product-card').on('click', function() {
+ var pid = $(this).data('product-id');
+ var name = $(this).find('.product-name').text();
+ $('#dlg-product-id').val(pid);
+ $('#dlg-product-search').val(name);
+ $('#dlg-product-freetext').val('');
+ $('#dlg-product-results').html('');
+ });
+ }
+ });
+ },
+
+ saveProduct: function() {
+ var self = this;
+ var productId = $('#dlg-product-id').val();
+ var freetext = $('#dlg-product-freetext').val().trim();
+ var qty = parseFloat($('#dlg-product-qty').val()) || 0;
+
+ if (!productId && !freetext) {
+ self.showToast('Produkt oder Freitext angeben', 'error');
+ return;
+ }
+ if (qty <= 0) {
+ self.showToast('Menge muss gr\u00f6\u00dfer als 0 sein', 'error');
+ return;
+ }
+
+ var data = {stz_id: self.state.stzId, qty: qty};
+ if (productId) {
+ data.fk_product = productId;
+ } else {
+ data.description = freetext;
+ }
+
+ self.showLoading();
+ self.api('add_product', data).then(function(res) {
+ self.hideLoading();
+ if (res.success) {
+ self.closeBottomSheet();
+ self.showToast('Produkt hinzugef\u00fcgt', 'success');
+ self.reloadData();
+ } else {
+ self.showToast(res.error || 'Fehler', 'error');
+ }
+ });
+ },
+
+ // ============================================================
+ // AKTIONEN: Mehraufwand/Entfaellt/Ruecknahme Dialoge
+ // ============================================================
+
+ showAddMehraufwandDialog: function() {
+ var self = this;
+ var html = '';
+ html += 'Produkt suchen ';
+ html += '
';
+ html += '
';
+ html += 'Oder Freitext ';
+ html += '
';
+ html += 'Menge ';
+ html += '
';
+ html += 'Grund (optional) ';
+ html += '
';
+ html += ' ';
+
+ var footer = 'Mehraufwand hinzufügen ';
+
+ self.openBottomSheet('Mehraufwand hinzuf\u00fcgen', html, footer);
+
+ var timer = null;
+ $('#dlg-ma-search').on('input', function() {
+ var term = $(this).val().trim();
+ clearTimeout(timer);
+ if (term.length >= 2) {
+ timer = setTimeout(function() {
+ self.api('search_products', {term: term}).then(function(res) {
+ if (res.success && res.products) {
+ var rHtml = '';
+ res.products.forEach(function(p) {
+ rHtml += '';
+ rHtml += '
' + self.escHtml(p.label) + '
';
+ rHtml += '
' + self.escHtml(p.ref) + '
';
+ rHtml += '
';
+ });
+ $('#dlg-ma-results').html(rHtml);
+ $('#dlg-ma-results .product-card').on('click', function() {
+ $('#dlg-ma-product-id').val($(this).data('product-id'));
+ $('#dlg-ma-search').val($(this).find('.product-name').text());
+ $('#dlg-ma-freetext').val('');
+ $('#dlg-ma-results').html('');
+ });
+ }
+ });
+ }, 300);
+ }
+ });
+
+ $('#dlg-ma-save').on('click', function() {
+ var productId = $('#dlg-ma-product-id').val();
+ var freetext = $('#dlg-ma-freetext').val().trim();
+ var qty = parseFloat($('#dlg-ma-qty').val()) || 0;
+ var reason = $('#dlg-ma-reason').val().trim();
+
+ if (!productId && !freetext) { self.showToast('Produkt oder Freitext angeben', 'error'); return; }
+ if (qty <= 0) { self.showToast('Menge angeben', 'error'); return; }
+
+ var data = {stz_id: self.state.stzId, qty: qty, reason: reason};
+ if (productId) data.fk_product = productId;
+ else data.description = freetext;
+
+ self.showLoading();
+ self.api('add_mehraufwand', data).then(function(res) {
+ self.hideLoading();
+ if (res.success) {
+ self.closeBottomSheet();
+ self.showToast('Mehraufwand hinzugef\u00fcgt', 'success');
+ self.reloadData();
+ } else {
+ self.showToast(res.error || 'Fehler', 'error');
+ }
+ });
+ });
+ },
+
+ showAddEntfaelltDialog: function() {
+ var self = this;
+
+ // Optionen vom Server laden
+ self.showLoading();
+ self.api('get_entfaellt_options', {stz_id: self.state.stzId, order_id: self.state.orderId}).then(function(res) {
+ self.hideLoading();
+ if (!res.success) { self.showToast(res.error || 'Fehler', 'error'); return; }
+
+ var html = '';
+ html += 'Produkt ';
+ html += '';
+ html += '-- Bitte wählen -- ';
+ if (res.options && res.options.length) {
+ res.options.forEach(function(o) {
+ html += '' + self.escHtml(o.label) + ' (max. ' + self.formatQty(o.max_qty) + ') ';
+ });
+ }
+ html += '
';
+ html += 'Menge ';
+ html += '
';
+ html += 'Grund (optional) ';
+ html += '
';
+
+ var footer = 'Entfällt hinzufügen ';
+
+ self.openBottomSheet('Entf\u00e4llt hinzuf\u00fcgen', html, footer);
+
+ $('#dlg-ent-product').on('change', function() {
+ var maxQty = $(this).find(':selected').data('max') || 1;
+ $('#dlg-ent-qty').attr('max', maxQty).val(Math.min(parseFloat($('#dlg-ent-qty').val()) || 1, maxQty));
+ });
+
+ $('#dlg-ent-save').on('click', function() {
+ var source = $('#dlg-ent-product').val();
+ var qty = parseFloat($('#dlg-ent-qty').val()) || 0;
+ var reason = $('#dlg-ent-reason').val().trim();
+
+ if (!source) { self.showToast('Produkt w\u00e4hlen', 'error'); return; }
+ if (qty <= 0) { self.showToast('Menge angeben', 'error'); return; }
+
+ self.showLoading();
+ self.api('add_entfaellt', {stz_id: self.state.stzId, source: source, qty: qty, reason: reason}).then(function(r) {
+ self.hideLoading();
+ if (r.success) {
+ self.closeBottomSheet();
+ self.showToast('Entf\u00e4llt hinzugef\u00fcgt', 'success');
+ self.reloadData();
+ } else {
+ self.showToast(r.error || 'Fehler', 'error');
+ }
+ });
+ });
+ });
+ },
+
+ showAddRuecknahmeDialog: function() {
+ var self = this;
+
+ self.showLoading();
+ self.api('get_ruecknahme_options', {stz_id: self.state.stzId, order_id: self.state.orderId}).then(function(res) {
+ self.hideLoading();
+ if (!res.success) { self.showToast(res.error || 'Fehler', 'error'); return; }
+
+ var html = '';
+ html += 'Verbautes Produkt ';
+ html += '';
+ html += '-- Bitte wählen -- ';
+ if (res.options && res.options.length) {
+ res.options.forEach(function(o) {
+ html += '' + self.escHtml(o.label) + ' (max. ' + self.formatQty(o.max_qty) + ') ';
+ });
+ }
+ html += '
';
+ html += 'Menge ';
+ html += '
';
+ html += 'Grund (optional) ';
+ html += '
';
+
+ var footer = 'Rücknahme hinzufügen ';
+
+ self.openBottomSheet('R\u00fccknahme hinzuf\u00fcgen', html, footer);
+
+ $('#dlg-rn-product').on('change', function() {
+ var maxQty = $(this).find(':selected').data('max') || 1;
+ $('#dlg-rn-qty').attr('max', maxQty).val(Math.min(parseFloat($('#dlg-rn-qty').val()) || 1, maxQty));
+ });
+
+ $('#dlg-rn-save').on('click', function() {
+ var source = $('#dlg-rn-product').val();
+ var qty = parseFloat($('#dlg-rn-qty').val()) || 0;
+ var reason = $('#dlg-rn-reason').val().trim();
+
+ if (!source) { self.showToast('Produkt w\u00e4hlen', 'error'); return; }
+ if (qty <= 0) { self.showToast('Menge angeben', 'error'); return; }
+
+ self.showLoading();
+ self.api('add_ruecknahme', {stz_id: self.state.stzId, source: source, qty: qty, reason: reason}).then(function(r) {
+ self.hideLoading();
+ if (r.success) {
+ self.closeBottomSheet();
+ self.showToast('R\u00fccknahme hinzugef\u00fcgt', 'success');
+ self.reloadData();
+ } else {
+ self.showToast(r.error || 'Fehler', 'error');
+ }
+ });
+ });
+ });
+ },
+
+ // ============================================================
+ // AKTIONEN: Neuen STZ erstellen
+ // ============================================================
+
+ showCreateStzDialog: function() {
+ var self = this;
+ var today = new Date().toISOString().split('T')[0];
+
+ var html = '';
+ html += 'Datum ';
+ html += '
';
+
+ var footer = 'Stundenzettel anlegen ';
+
+ self.openBottomSheet('Neuen Stundenzettel anlegen', html, footer);
+
+ $('#dlg-stz-save').on('click', function() {
+ var date = $('#dlg-stz-date').val();
+ if (!date) { self.showToast('Datum angeben', 'error'); return; }
+
+ self.showLoading();
+ self.api('create_stundenzettel', {order_id: self.state.orderId, date: date}).then(function(res) {
+ self.hideLoading();
+ if (res.success) {
+ self.closeBottomSheet();
+ self.showToast('Stundenzettel erstellt', 'success');
+ self.loadOrder(self.state.orderId, res.stz_id);
+ } else {
+ self.showToast(res.error || 'Fehler', 'error');
+ }
+ });
+ });
+ },
+
+ // ============================================================
+ // HELPER
+ // ============================================================
+
+ escHtml: function(str) {
+ if (!str) return '';
+ var div = document.createElement('div');
+ div.appendChild(document.createTextNode(str));
+ return div.innerHTML;
+ },
+
+ formatQty: function(qty) {
+ qty = parseFloat(qty) || 0;
+ if (qty === Math.floor(qty)) {
+ return qty.toLocaleString('de-DE', {maximumFractionDigits: 0});
+ }
+ return qty.toLocaleString('de-DE', {minimumFractionDigits: 1, maximumFractionDigits: 2});
+ },
+
+ formatDuration: function(minutes) {
+ minutes = parseInt(minutes) || 0;
+ var h = Math.floor(minutes / 60);
+ var m = minutes % 60;
+ if (h > 0 && m > 0) return h + 'h ' + m + 'min';
+ if (h > 0) return h + 'h';
+ return m + 'min';
+ },
+
+ getStatusClass: function(status) {
+ switch (parseInt(status)) {
+ case 0: return 'draft';
+ case 1: return 'validated';
+ case 2: return 'invoiced';
+ case 9: return 'canceled';
+ default: return 'draft';
+ }
+ },
+
+ getOriginLabel: function(origin) {
+ switch (origin) {
+ case 'order': return 'Auftrag';
+ case 'added': return 'Hinzugef\u00fcgt';
+ case 'additional': return 'Mehraufwand';
+ case 'omitted': return 'Entf\u00e4llt';
+ case 'returned': return 'R\u00fccknahme';
+ default: return origin;
+ }
+ }
+ };
+
+ // App starten wenn DOM bereit
+ $(document).ready(function() {
+ App.init();
+ });
+
+})(jQuery);
diff --git a/langs/de_DE/stundenzettel.lang b/langs/de_DE/stundenzettel.lang
old mode 100644
new mode 100755
index 6f08335..fda7257
--- a/langs/de_DE/stundenzettel.lang
+++ b/langs/de_DE/stundenzettel.lang
@@ -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
diff --git a/lib/stundenzettel.lib.php b/lib/stundenzettel.lib.php
old mode 100644
new mode 100755
diff --git a/list.php b/list.php
old mode 100644
new mode 100755
diff --git a/manifest.json b/manifest.json
new file mode 100644
index 0000000..db98c58
--- /dev/null
+++ b/manifest.json
@@ -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"
+ }
+ ]
+}
diff --git a/pwa.php b/pwa.php
new file mode 100644
index 0000000..08ce0f3
--- /dev/null
+++ b/pwa.php
@@ -0,0 +1,174 @@
+
+ *
+ * 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');
+
+?>
+
+
+
+
+
+
+
+
+ Stundenzettel
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Abbrechen
+ OK
+
+
+
+
+
+
+
+
+
+
+
+
+
Stundenzettel
+
Mobile Zeiterfassung
+
+
+
+
+
+
+
+
+
+
+
Kundenname eingeben um Aufträge zu finden
+
+
+
+
+
+
+
+
+
←
+
+
Alle STZ
+
Stundenzettel
+
Produktliste
+
Lieferauflistung
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sql/dolibarr_allversions.sql b/sql/dolibarr_allversions.sql
old mode 100644
new mode 100755
diff --git a/sql/llx_product_services.sql b/sql/llx_product_services.sql
old mode 100644
new mode 100755
diff --git a/sql/llx_stundenzettel.key.sql b/sql/llx_stundenzettel.key.sql
old mode 100644
new mode 100755
diff --git a/sql/llx_stundenzettel.sql b/sql/llx_stundenzettel.sql
old mode 100644
new mode 100755
diff --git a/sql/llx_stundenzettel_leistung.key.sql b/sql/llx_stundenzettel_leistung.key.sql
old mode 100644
new mode 100755
diff --git a/sql/llx_stundenzettel_leistung.sql b/sql/llx_stundenzettel_leistung.sql
old mode 100644
new mode 100755
diff --git a/sql/llx_stundenzettel_note.sql b/sql/llx_stundenzettel_note.sql
old mode 100644
new mode 100755
diff --git a/sql/llx_stundenzettel_product.key.sql b/sql/llx_stundenzettel_product.key.sql
old mode 100644
new mode 100755
diff --git a/sql/llx_stundenzettel_product.sql b/sql/llx_stundenzettel_product.sql
old mode 100644
new mode 100755
diff --git a/sql/llx_stundenzettel_tracking.key.sql b/sql/llx_stundenzettel_tracking.key.sql
old mode 100644
new mode 100755
diff --git a/sql/llx_stundenzettel_tracking.sql b/sql/llx_stundenzettel_tracking.sql
old mode 100644
new mode 100755
diff --git a/sql/update_1.2.0.sql b/sql/update_1.2.0.sql
old mode 100644
new mode 100755
diff --git a/stundenzettel_commande.php b/stundenzettel_commande.php
index 701f245..fee2a7c 100755
--- a/stundenzettel_commande.php
+++ b/stundenzettel_commande.php
@@ -1865,12 +1865,12 @@ if ($tab == 'products') {
print '';
- // 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 '';
- if (!$isDone && !$isAlreadyOnStz) {
+ if (!$isAlreadyOnStz) {
print ' ';
- } elseif ($isAlreadyOnStz) {
+ } else {
print ' ';
}
print ' ';
@@ -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 '';
print '';
print ''.$langs->trans("Product").' ';
- print ''.$langs->trans("QtyOrdered").' ';
print ''.$langs->trans("QtyDelivered").' ';
- print ''.$langs->trans("QtyRemaining").' ';
- print ''.$langs->trans("Status").' ';
print ' ';
// Live-Berechnung aus allen Stundenzetteln (inkl. Entwürfe)
@@ -2585,22 +2580,7 @@ if ($tab == 'tracking') {
}
print '';
- // Bestellt (mit Badges für Mehraufwand/Entfällt/Rücknahmen)
- print '';
- print ''.formatQty($effective_ordered).' ';
- if ($qty_additional > 0) {
- print ' +'.formatQty($qty_additional).' ';
- }
- if ($qty_omitted > 0) {
- print ' -'.formatQty($qty_omitted).' ';
- }
- if ($qty_returned > 0) {
- print ' -'.formatQty($qty_returned).' ';
- }
- print ' ';
- $total_ordered += $effective_ordered;
-
- // Geliefert/Erfasst (mit Rücknahme-Hinweis)
+ // Verbaut (mit Rücknahme-Hinweis)
print '';
print formatQty($effective_delivered);
if ($qty_returned > 0) {
@@ -2609,38 +2589,12 @@ if ($tab == 'tracking') {
print ' ';
$total_delivered += $effective_delivered;
- // Verbleibend
- print '';
- if ($qty_remaining > 0) {
- print ''.formatQty($qty_remaining).' ';
- } elseif ($qty_remaining == 0) {
- print '0 ';
- } else {
- print ''.formatQty($qty_remaining).' ';
- }
- print ' ';
- $total_remaining += $qty_remaining;
-
- // Status
- print '';
- if ($effective_ordered <= 0 && $qty_returned > 0) {
- // Alles zurückgenommen - Erledigt
- print ''.$langs->trans("TrackingDone").' ';
- } elseif ($qty_remaining <= 0) {
- print ''.$langs->trans("TrackingDone").' ';
- } elseif ($effective_delivered > 0) {
- print ''.$langs->trans("TrackingPartial").' ';
- } else {
- print ''.$langs->trans("TrackingOpen").' ';
- }
- print ' ';
-
print '';
// Detail-Zeile (standardmäßig eingeklappt)
if ($hasDetails) {
print '';
- print '';
+ print ' ';
// Sub-Tabelle für Details
print '';
@@ -2707,14 +2661,11 @@ if ($tab == 'tracking') {
// Summenzeile
print '';
print ''.$langs->trans("Total").' ';
- print ''.formatQty($total_ordered).' ';
print ''.formatQty($total_delivered).' ';
- print ''.formatQty($total_remaining).' ';
- print ' ';
print ' ';
} else {
- print ''.$langs->trans("NoRecordFound").' ';
+ print ''.$langs->trans("NoRecordFound").' ';
}
}
@@ -2771,7 +2722,7 @@ if ($tab == 'tracking') {
if ($resqlMehr && $db->num_rows($resqlMehr) > 0) {
// Separator-Zeile für Mehraufwand
print '';
- print ''.$langs->trans("Mehraufwand").' ('.$langs->trans("MehraufwandDesc").') ';
+ print ''.$langs->trans("Mehraufwand").' ('.$langs->trans("MehraufwandDesc").') ';
print ' ';
while ($objMehr = $db->fetch_object($resqlMehr)) {
@@ -2835,16 +2786,7 @@ if ($tab == 'tracking') {
}
print '';
- // Bestellt (Mehraufwand-Menge, MINUS Rücknahmen)
- print '';
- print ''.formatQty($qty_ordered_mehr).' ';
- if ($qtyReturned > 0) {
- print ' -'.formatQty($qtyReturned).' ';
- }
- print ' ';
- $total_ordered += $qty_ordered_mehr;
-
- // Geliefert (mit Rücknahme-Hinweis wenn vorhanden)
+ // Verbaut (mit Rücknahme-Hinweis wenn vorhanden)
print '';
print formatQty($qty_delivered_mehr);
if ($qtyReturned > 0) {
@@ -2853,38 +2795,12 @@ if ($tab == 'tracking') {
print ' ';
$total_delivered += $qty_delivered_mehr;
- // Verbleibend
- print '';
- if ($qty_remaining_mehr > 0) {
- print ''.formatQty($qty_remaining_mehr).' ';
- } elseif ($qty_remaining_mehr == 0) {
- print '0 ';
- } else {
- print ''.formatQty($qty_remaining_mehr).' ';
- }
- print ' ';
- $total_remaining += $qty_remaining_mehr;
-
- // Status
- print '';
- if ($qty_ordered_mehr <= 0 && $qtyReturned > 0) {
- // Alles zurückgenommen - Erledigt
- print ''.$langs->trans("TrackingDone").' ';
- } elseif ($qty_remaining_mehr <= 0) {
- print ''.$langs->trans("TrackingDone").' ';
- } elseif ($qty_delivered_mehr > 0) {
- print ''.$langs->trans("TrackingPartial").' ';
- } else {
- print ''.$langs->trans("TrackingOpen").' ';
- }
- print ' ';
-
print '';
// Detail-Zeile für Mehraufwand
if ($hasDetailsMehr) {
print '';
- print '';
+ print ' ';
print '';
print '';
print ''.$langs->trans("Total").' ('.$langs->trans("Mehraufwand").' '.$langs->trans("incl").') ';
- print ''.formatQty($total_ordered).' ';
print ''.formatQty($total_delivered).' ';
- print ''.formatQty($total_remaining).' ';
- print ' ';
print ' ';
}
diff --git a/sw.js b/sw.js
new file mode 100644
index 0000000..aafff51
--- /dev/null
+++ b/sw.js
@@ -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