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 0000000..eca4f5b Binary files /dev/null and b/img/icon-192.png differ diff --git a/img/icon-512.png b/img/icon-512.png new file mode 100644 index 0000000..ab29bd7 Binary files /dev/null and b/img/icon-512.png differ diff --git a/index.php b/index.php old mode 100644 new mode 100755 diff --git a/js/pwa.js b/js/pwa.js new file mode 100644 index 0000000..84d817f --- /dev/null +++ b/js/pwa.js @@ -0,0 +1,2087 @@ +/** + * Stundenzettel PWA - Haupt-App-Logik + * Version 1.0 + * + * Screens: login -> search -> main (mit 4 Swipe-Panels) + * Panels: 0=Alle STZ, 1=Stundenzettel (card.php), 2=Produktliste (Auftrags-Uebernahme), 3=Lieferauflistung + */ +(function($) { + 'use strict'; + + var App = { + // Auth-State + auth: { + token: null, + user: null + }, + + // App-State + state: { + screen: 'login', + orderId: null, + orderRef: null, + customerName: null, + stzId: null, + activePanel: 2, // Produktliste als Standard + isDragging: false, + canWrite: false, + canEditStz: false, + productFilter: 'open' // Filter: open/done/all (wie Desktop) + }, + + // Daten + data: { + order: null, + stz: null, + products: [], + leistungen: [], + notes: [], + tracking: [], + stzList: [], + orderLines: [], + mehraufwandLines: [], + services: [] + }, + + // Swipe + swipe: { + startX: 0, + startY: 0, + currentX: 0, + isDragging: false, + startTime: 0, + panelWidth: 0 + }, + + // ============================================================ + // INITIALISIERUNG + // ============================================================ + + init: function() { + var self = this; + + // Token aus localStorage pruefen + var savedToken = localStorage.getItem('stz_pwa_token'); + if (savedToken) { + self.auth.token = savedToken; + self.showLoading(); + self.apiAuth('verify', {token: savedToken}).then(function(res) { + self.hideLoading(); + if (res.success) { + self.auth.user = res.user; + // Gespeicherten State wiederherstellen (nach Reload) + var savedState = self.restoreState(); + if (savedState && savedState.orderId) { + self.state.activePanel = savedState.activePanel || 2; + self.loadOrder(savedState.orderId, savedState.stzId); + } else { + self.showScreen('search'); + } + } else { + localStorage.removeItem('stz_pwa_token'); + self.showScreen('login'); + } + }).catch(function() { + self.hideLoading(); + self.showScreen('login'); + }); + } else { + self.showScreen('login'); + } + + self.bindEvents(); + }, + + // State in localStorage speichern (fuer Reload) + saveState: function() { + if (this.state.orderId) { + localStorage.setItem('stz_pwa_state', JSON.stringify({ + orderId: this.state.orderId, + stzId: this.state.stzId, + activePanel: this.state.activePanel + })); + } + }, + + restoreState: function() { + try { + var saved = localStorage.getItem('stz_pwa_state'); + return saved ? JSON.parse(saved) : null; + } catch(e) { + return null; + } + }, + + // ============================================================ + // EVENT-BINDING + // ============================================================ + + bindEvents: function() { + var self = this; + + // Login-Form + $('#login-form').on('submit', function(e) { + e.preventDefault(); + self.handleLogin(); + }); + + // Logout + $('#btn-logout').on('click', function() { + self.handleLogout(); + }); + + // Suche + var searchTimer = null; + $('#search-input').on('input', function() { + var term = $(this).val().trim(); + clearTimeout(searchTimer); + if (term.length >= 2) { + searchTimer = setTimeout(function() { + self.searchCustomers(term); + }, 300); + } else { + $('#search-results').html('

Kundenname eingeben um Aufträge zu finden

'); + } + }); + + // Tab-Klicks + $('.tab-item').on('click', function() { + var panel = parseInt($(this).data('panel')); + self.setPanel(panel); + }); + + // Zurueck-Button + $('#btn-back').on('click', function() { + self.showScreen('search'); + }); + + // FAB + $('#fab-add').on('click', function() { + self.handleFabClick(); + }); + + // Bottom-Sheet Overlay + $('#bottom-sheet-overlay').on('click', function() { + self.closeBottomSheet(); + }); + + // Confirm-Dialog Buttons + $('#confirm-cancel').on('click', function() { + self.closeConfirm(); + }); + + // Swipe-Events + self.initSwipe(); + }, + + // ============================================================ + // AUTH + // ============================================================ + + handleLogin: function() { + var self = this; + var username = $('#login-user').val().trim(); + var password = $('#login-pass').val(); + + if (!username || !password) return; + + $('#login-error').text(''); + self.showLoading(); + + self.apiAuth('login', {username: username, password: password}).then(function(res) { + self.hideLoading(); + if (res.success) { + self.auth.token = res.token; + self.auth.user = res.user; + localStorage.setItem('stz_pwa_token', res.token); + $('#login-user').val(''); + $('#login-pass').val(''); + self.showScreen('search'); + } else { + $('#login-error').text(res.error || 'Login fehlgeschlagen'); + } + }).catch(function() { + self.hideLoading(); + $('#login-error').text('Verbindungsfehler'); + }); + }, + + handleLogout: function() { + this.auth.token = null; + this.auth.user = null; + localStorage.removeItem('stz_pwa_token'); + localStorage.removeItem('stz_pwa_state'); + this.state.orderId = null; + this.state.stzId = null; + this.data = {order: null, stz: null, products: [], leistungen: [], notes: [], tracking: [], stzList: [], orderLines: [], mehraufwandLines: [], services: [], leistungenSummary: [], leistungenAll: []}; + this.showScreen('login'); + }, + + // ============================================================ + // API-HELPER + // ============================================================ + + apiAuth: function(action, data) { + data = data || {}; + data.action = action; + return $.ajax({ + url: window.STZ_CONFIG.authUrl, + method: 'POST', + data: data, + dataType: 'json', + timeout: 15000 + }); + }, + + api: function(action, data) { + var self = this; + data = data || {}; + data.action = action; + data.token = self.auth.token; + return $.ajax({ + url: window.STZ_CONFIG.apiUrl, + method: 'POST', + data: data, + dataType: 'json', + timeout: 30000 + }).fail(function(xhr) { + if (xhr.status === 401) { + self.showToast('Sitzung abgelaufen', 'error'); + self.handleLogout(); + } + }); + }, + + // ============================================================ + // SCREEN-MANAGEMENT + // ============================================================ + + showScreen: function(name) { + $('.screen').removeClass('active'); + $('#screen-' + name).addClass('active'); + this.state.screen = name; + + if (name === 'search') { + $('#search-input').focus(); + } + if (name === 'main') { + this.updatePanelWidth(); + this.setPanel(this.state.activePanel, false); + this.updateFab(); + } else { + $('#fab-add').addClass('hidden'); + } + }, + + // ============================================================ + // TOASTS + // ============================================================ + + showToast: function(msg, type) { + type = type || 'info'; + var $toast = $('
' + this.escHtml(msg) + '
'); + $('#toast-container').append($toast); + setTimeout(function() { + $toast.css('animation', 'toastOut 0.3s ease-out forwards'); + setTimeout(function() { $toast.remove(); }, 300); + }, 3000); + }, + + // ============================================================ + // LOADING + // ============================================================ + + showLoading: function() { + $('#loading-overlay').addClass('active'); + }, + + hideLoading: function() { + $('#loading-overlay').removeClass('active'); + }, + + // ============================================================ + // CONFIRM-DIALOG + // ============================================================ + + showConfirm: function(title, text, okText, okClass) { + var self = this; + $('#confirm-title').text(title); + $('#confirm-text').text(text); + $('#confirm-ok').text(okText || 'OK').attr('class', 'btn ' + (okClass || 'btn-danger')); + $('#confirm-dialog').addClass('active'); + + return new Promise(function(resolve) { + self._confirmResolve = resolve; + $('#confirm-ok').off('click').on('click', function() { + self.closeConfirm(); + resolve(true); + }); + $('#confirm-cancel').off('click').on('click', function() { + self.closeConfirm(); + resolve(false); + }); + }); + }, + + closeConfirm: function() { + $('#confirm-dialog').removeClass('active'); + }, + + // ============================================================ + // BOTTOM-SHEET + // ============================================================ + + openBottomSheet: function(title, bodyHtml, footerHtml) { + $('#bottom-sheet-header').html(this.escHtml(title)); + $('#bottom-sheet-body').html(bodyHtml); + $('#bottom-sheet-footer').html(footerHtml || ''); + $('#bottom-sheet-overlay').addClass('open'); + // Kleiner Delay fuer die Animation + setTimeout(function() { + $('#bottom-sheet').addClass('open'); + }, 10); + }, + + closeBottomSheet: function() { + $('#bottom-sheet').removeClass('open'); + setTimeout(function() { + $('#bottom-sheet-overlay').removeClass('open'); + $('#bottom-sheet-body').html(''); + $('#bottom-sheet-footer').html(''); + }, 300); + }, + + // ============================================================ + // SUCHE + // ============================================================ + + searchCustomers: function(term) { + var self = this; + self.api('search_customers', {term: term}).then(function(res) { + if (res.success && res.customers) { + self.renderCustomerResults(res.customers); + } else { + $('#search-results').html('

' + self.escHtml(res.error || 'Keine Ergebnisse') + '

'); + } + }).catch(function() { + $('#search-results').html('

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 '