From 292db5d40cdddb8e56c3734bba9b88e5caae8e14 Mon Sep 17 00:00:00 2001 From: data Date: Fri, 27 Feb 2026 21:21:14 +0100 Subject: [PATCH] Version 2.0.0: PWA Mobile App + Produktliste-Verbesserungen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PWA (neue Dateien): - Vollständige Progressive Web App mit Token-basierter Auth - 4 Swipe-Panels: Alle STZ, Stundenzettel, Produktliste, Lieferauflistung - Kundensuche, Leistungen-Accordion, Mehraufwand-Sektion - Produkt-Übernahme aus Auftrag + Mehraufwand in STZ - Service Worker, Manifest, App-Icons für Installation Desktop-Änderungen: - Produktliste: Checkboxen immer sichtbar (außer bereits auf STZ) - Lieferauflistung: Vereinfachte Ansicht (nur Verbaut-Spalte) - Admin: PWA-Link in Einstellungen - Sprachdatei: PWA-Übersetzungen Co-Authored-By: Claude Opus 4.6 --- admin/setup.php | 22 + ajax/add_leistung.php | 0 ajax/add_product.php | 0 ajax/pwa_api.php | 1564 ++++++++++++++++++ ajax/pwa_auth.php | 144 ++ card.php | 0 class/stundenzettel.class.php | 0 css/pwa.css | 1200 ++++++++++++++ debug_netto.php | 0 img/icon-192.png | Bin 0 -> 9181 bytes img/icon-512.png | Bin 0 -> 34166 bytes index.php | 0 js/pwa.js | 2087 ++++++++++++++++++++++++ langs/de_DE/stundenzettel.lang | 6 + lib/stundenzettel.lib.php | 0 list.php | 0 manifest.json | 26 + pwa.php | 174 ++ sql/dolibarr_allversions.sql | 0 sql/llx_product_services.sql | 0 sql/llx_stundenzettel.key.sql | 0 sql/llx_stundenzettel.sql | 0 sql/llx_stundenzettel_leistung.key.sql | 0 sql/llx_stundenzettel_leistung.sql | 0 sql/llx_stundenzettel_note.sql | 0 sql/llx_stundenzettel_product.key.sql | 0 sql/llx_stundenzettel_product.sql | 0 sql/llx_stundenzettel_tracking.key.sql | 0 sql/llx_stundenzettel_tracking.sql | 0 sql/update_1.2.0.sql | 0 stundenzettel_commande.php | 105 +- sw.js | 30 + 32 files changed, 5262 insertions(+), 96 deletions(-) mode change 100644 => 100755 admin/setup.php mode change 100644 => 100755 ajax/add_leistung.php mode change 100644 => 100755 ajax/add_product.php create mode 100644 ajax/pwa_api.php create mode 100644 ajax/pwa_auth.php mode change 100644 => 100755 card.php mode change 100644 => 100755 class/stundenzettel.class.php create mode 100644 css/pwa.css mode change 100644 => 100755 debug_netto.php create mode 100644 img/icon-192.png create mode 100644 img/icon-512.png mode change 100644 => 100755 index.php create mode 100644 js/pwa.js mode change 100644 => 100755 langs/de_DE/stundenzettel.lang mode change 100644 => 100755 lib/stundenzettel.lib.php mode change 100644 => 100755 list.php create mode 100644 manifest.json create mode 100644 pwa.php mode change 100644 => 100755 sql/dolibarr_allversions.sql mode change 100644 => 100755 sql/llx_product_services.sql mode change 100644 => 100755 sql/llx_stundenzettel.key.sql mode change 100644 => 100755 sql/llx_stundenzettel.sql mode change 100644 => 100755 sql/llx_stundenzettel_leistung.key.sql mode change 100644 => 100755 sql/llx_stundenzettel_leistung.sql mode change 100644 => 100755 sql/llx_stundenzettel_note.sql mode change 100644 => 100755 sql/llx_stundenzettel_product.key.sql mode change 100644 => 100755 sql/llx_stundenzettel_product.sql mode change 100644 => 100755 sql/llx_stundenzettel_tracking.key.sql mode change 100644 => 100755 sql/llx_stundenzettel_tracking.sql mode change 100644 => 100755 sql/update_1.2.0.sql create mode 100644 sw.js 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 ''; +print ''; +print ''; +print ''; + +$pwaUrl = dol_buildpath('/stundenzettel/pwa.php', 2); +print ''; +print ''; +print ''; +print ''; +print ''; + +print '
'.$langs->trans("PWAMobileApp").''.$langs->trans("Value").'
'.$langs->trans("PWADescription").'
'.$langs->trans("PWAInstallHint").'
'; +print ''; +print $langs->trans("PWALink").' ↗'; +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 0000000000000000000000000000000000000000..eca4f5b3b59f68748dfcf7018b8f7f7919db7743 GIT binary patch literal 9181 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 literal 0 HcmV?d00001 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('

Verbindungsfehler

'); + }); + }, + + renderCustomerResults: function(customers) { + var self = this; + if (!customers.length) { + $('#search-results').html('

Keine Kunden gefunden

'); + return; + } + + var html = ''; + customers.forEach(function(c) { + html += '
'; + html += '
'; + html += '' + self.escHtml(c.name) + ''; + html += '' + c.order_count + ' Aufträge'; + 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 += '
'; + html += '
'; + html += '' + self.escHtml(self.state.orderRef || '') + ''; + html += '' + self.escHtml(self.state.customerName || '') + ''; + html += '
'; + + // Freigabe-Hinweis wenn alle STZ freigegeben + if (self.allStzReleased()) { + html += self.renderReleasedHint(); + } + + html += '
Stundenzettel für diesen Auftrag
'; + + 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.ref) + ''; + html += '' + self.escHtml(s.status_label) + ''; + 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 += '
'; + html += '
'; + html += '' + self.escHtml(stz.ref) + ''; + html += '' + self.escHtml(stz.status_label) + ''; + html += '
'; + html += '
'; + html += '' + self.escHtml(stz.date) + ' · ' + self.escHtml(self.state.customerName || '') + ''; + html += '
'; + + // Hinweis wenn STZ freigegeben + if (!isDraft) { + html += '
'; + html += '🔒'; + html += 'Dieser Stundenzettel ist freigegeben – keine Änderungen möglich.'; + if (self.state.canWrite) { + html += ''; + } + 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 += '
'; + html += 'Leistungen'; + if (hasLeistungen) { + html += '' + self.data.leistungen.length + ''; + } + html += '
'; + 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 += ''; + html += ''; + html += '
'; + } + html += '
'; + }); + } + html += '
'; // accordion-body + + // Button AUSSERHALB accordion-body - immer sichtbar + if (isDraft && canWrite) { + html += ''; + } + 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 += '
Verbaute Produkte
'; + + 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 += ''; + } + } + + // ---- 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 += '
'; + html += 'Merkzettel'; + if (self.data.notes.length) { + html += '' + self.data.notes.length + ''; + } + html += '
'; + html += ''; + html += '
'; + html += '
'; + + if (!self.data.notes.length) { + html += '
Keine Notizen
'; + } 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 += ''; + html += '
'; + return html; + }, + + renderPanelProducts: function() { + var self = this; + var stz = self.data.stz; + var $panel = $('#panel-products'); + var html = ''; + + // Info-Header + html += '
'; + html += '
'; + html += '' + self.escHtml(self.state.orderRef || '') + ''; + html += 'Produktliste · ' + self.escHtml(self.state.customerName || '') + ''; + 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 += ''; + 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 += ''; + html += ''; + html += ''; + html += '
'; + + html += '
Auftragspositionen
'; + + 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 += '
'; + + // Checkbox immer zeigen ausser wenn bereits auf aktuellem STZ + if (canWrite && !isOnStz) { + html += ''; + hasSelectable = true; + } else if (isOnStz) { + html += ''; + } + + html += '
'; + html += '
' + self.escHtml(line.label || line.description || 'Unbekannt') + '
'; + html += '
'; + + // Status-Badge + if (isDone) { + html += 'Erledigt'; + } else if (isPartial) { + html += 'Teilweise'; + } else { + html += 'Offen'; + } + 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 += '
Alle Produkte erledigt
'; + } 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 += '
'; + + // Checkbox fuer Uebernahme in STZ + if (canWrite) { + html += ''; + hasSelectable = true; + } + + html += '
'; + html += '
' + self.escHtml(ma.label || ma.description || 'Unbekannt') + '
'; + html += '
'; + html += 'Mehraufwand'; + 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 += ''; + } + html += ''; + 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 += '
'; + html += '
' + self.escHtml(p.label || p.description || 'Unbekannt') + '
'; + if (p.ref) { + html += '
' + self.escHtml(p.ref) + '
'; + } + if (!p.ref && p.description && p.label) { + html += '
' + self.escHtml(p.description) + '
'; + } + html += '
'; + html += '' + self.getOriginLabel(p.origin) + ''; + 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 += '
'; + html += '' + title + ''; + if (items.length) { + html += '' + items.length + ''; + } + html += '
'; + html += ''; + html += '
'; + + html += '
'; + + if (!items.length) { + html += '
Keine Einträge
'; + } else { + items.forEach(function(p) { + html += '
'; + html += '
'; + html += '
' + self.escHtml(p.label || p.description || 'Unbekannt') + '
'; + if (isDraft && canWrite) { + 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 += '
'; // 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 += '
'; + html += '
'; + html += '' + self.escHtml(self.state.orderRef || '') + ''; + html += 'Lieferauflistung · ' + self.escHtml(self.state.customerName || '') + ''; + html += '
'; + + // ---- PRODUKTE ---- + html += '
Lieferfortschritt
'; + + 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 += '
' + self.escHtml(t.label || '') + '
'; + html += '' + self.formatQty(t.qty_delivered) + ''; + 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 += '
Arbeitsstunden
'; + + 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 += '
' + self.escHtml(ls.service_label) + '
'; + html += '' + self.escHtml(ls.total_hours) + ''; + html += '
'; + html += '
' + ls.entry_count + ' Eintr\u00e4ge
'; + html += '
'; + }); + } + + // Einzelne Leistungen (aufklappbar) + if (leistAll.length) { + html += '
'; + html += '
'; + html += '
'; + html += 'Leistungen pro Stundenzettel'; + html += '' + leistAll.length + ''; + html += '
'; + html += ''; + html += '
'; + html += '
'; + + leistAll.forEach(function(l) { + html += '
'; + html += '
'; + html += '' + self.escHtml(l.stz_ref) + ''; + html += '' + self.escHtml(l.date) + ''; + html += '' + self.escHtml(l.duration) + ''; + 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 += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + + var footer = ''; + + 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 += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += ''; + + var footer = ''; + + 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 += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += ''; + + var footer = ''; + + 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 += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += ''; + + var footer = ''; + + 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 += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + + var footer = ''; + + 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 += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + + var footer = ''; + + 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 += '
'; + html += '
'; + + var footer = ''; + + 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 + + + + + + + +
+ + +
+ + +
+
+
+ + +
+
+
+
+
+ + +
+
+
+ + +
+
+
+
+
+ +
+ + + + + + + + +
+ +
+
+
+
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 ''; - print ''; print ''; - print ''; - print ''; 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 ''; - $total_ordered += $effective_ordered; - - // Geliefert/Erfasst (mit Rücknahme-Hinweis) + // Verbaut (mit Rücknahme-Hinweis) print ''; $total_delivered += $effective_delivered; - // Verbleibend - print ''; - $total_remaining += $qty_remaining; - - // Status - print ''; - print ''; // Detail-Zeile (standardmäßig eingeklappt) if ($hasDetails) { print ''; - print '
'.$langs->trans("Product").''.$langs->trans("QtyOrdered").''.$langs->trans("QtyDelivered").''.$langs->trans("QtyRemaining").''.$langs->trans("Status").'
'; - 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 ''; print formatQty($effective_delivered); if ($qty_returned > 0) { @@ -2609,38 +2589,12 @@ if ($tab == 'tracking') { print ''; - if ($qty_remaining > 0) { - print ''.formatQty($qty_remaining).''; - } elseif ($qty_remaining == 0) { - print '0'; - } else { - print ''.formatQty($qty_remaining).''; - } - 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 '