diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e660fd9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +bin/ diff --git a/ChangeLog.md b/ChangeLog.md index aae1690..b281a43 100755 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,5 +1,33 @@ # CHANGELOG MODULE HANDYBARCODESCANNER FOR [DOLIBARR ERP CRM](https://www.dolibarr.org) +## 5.0 + +### Bestellmodus - Neue Features +- **Produktsuche**: Lupe-Button neben Scan-Button für manuelle Produktsuche +- **Freitext-Positionen**: Plus-Button für freie Artikelbeschreibungen ohne Produktstamm +- **Bestellübersicht per Swipe**: Nach links swipen öffnet Bestellungen-Modal +- **Horizontale Bestellliste**: Alle offenen Bestellungen horizontal scrollbar +- **Direkt-Bestellungen hervorgehoben**: Bestellungen mit "-Direkt" Suffix fett dargestellt +- **Auto-Öffnung**: Zuletzt bearbeitete Bestellung wird automatisch geöffnet +- **Bestellzeilen bearbeiten**: Produktname + Menge in Zeilen, Klick öffnet Bearbeitungsdialog +- **Zeilen-Dialog**: Lagerbestand anzeigen, Menge ändern, Position löschen + +### Barcode-Druck +- **Rechts-Swipen**: Öffnet Produktsuche für Barcode-Druck +- **Print-Modal**: Zeigt Code128-Barcode mit Produktreferenz +- **24mm Label-Format**: Optimiert für Brother P-touch und ähnliche Etikettendrucker +- **JsBarcode Integration**: Barcode-Generierung direkt im Browser + +### Scanner-Verbesserungen +- **Dialog-Pausierung**: Scanner pausiert automatisch bei geöffneten Dialogen +- **Auto-Fortsetzung**: Scanner läuft weiter wenn alle Dialoge geschlossen +- **Stopp-Button Fix**: Zuverlässiges Stoppen des Scanners jederzeit möglich + +### UI/UX +- **Kompakterer Scan-Button**: Mehr Platz für Tool-Buttons +- **Swipe-Hinweis**: Visueller Hinweis "← Bestellungen" im Order-Mode +- **Service Worker v5.3**: Aktualisiertes Caching mit JsBarcode-Library + ## 4.7 - PWA-Link auf der Scanner-Seite angezeigt (korrekter externer Hostname statt interner IP) diff --git a/README.md b/README.md index af47cb8..eb62fb7 100755 --- a/README.md +++ b/README.md @@ -7,12 +7,15 @@ Mobiler Barcode-Scanner für Dolibarr - optimiert für die Verwendung auf Smartp Das Modul bietet drei Modi für die mobile Barcode-Erfassung: ### 1. Bestellmodus (Order) -- Produkt per Barcode scannen +- Produkt per Barcode scannen oder manuell suchen (Lupe-Button) - Alle verfügbaren Lieferanten mit Einkaufspreisen werden angezeigt - Günstigster Lieferant ist vorausgewählt - Produkt wird zu einer lieferantenspezifischen Entwurfsbestellung hinzugefügt -- Bestellungen werden automatisch als "Direktbestellung-[Lieferantenname]" erstellt +- Bestellungen werden automatisch als "YYMMDD-Direkt" erstellt (z.B. "260223-Direkt") - Falls kein Lieferant zugewiesen: Manuelle Auswahl aller verfügbaren Lieferanten +- **Freitext-Positionen**: Plus-Button für Artikel ohne Produktstamm +- **Bestellübersicht**: Nach links swipen zeigt alle offenen Bestellungen +- **Zeilen bearbeiten**: Klick auf Bestellzeile öffnet Dialog zum Ändern/Löschen ### 2. Shop-Modus - Produkt per Barcode scannen @@ -25,6 +28,12 @@ Das Modul bietet drei Modi für die mobile Barcode-Erfassung: - Neuen Bestand eingeben und mit Bestätigungsdialog speichern - Lagerbewegungen werden korrekt protokolliert +### 4. Barcode-Druck +- Nach rechts swipen öffnet Produktsuche für Barcode-Druck +- Produkt auswählen → Barcode-Vorschau wird angezeigt +- Code128-Format, optimiert für 24mm Etikettendrucker (Brother P-touch etc.) +- Drucken direkt vom Smartphone über Browser-Druckfunktion + ## Barcode-Unterstützung Das Modul sucht Barcodes in folgender Reihenfolge: @@ -104,9 +113,13 @@ Die Modi (Bestellen/Shop/Inventur) können per Tab gewechselt werden, ohne dass ### Im mobilen Browser / PWA 1. QR-Code von der Admin-Seite scannen oder URL direkt eingeben 2. Kamerazugriff erlauben (HTTPS erforderlich!) -3. Gewünschten Modus wählen und "Scan starten" tippen +3. Gewünschten Modus wählen und "Scannen" tippen 4. Barcode vor die Kamera halten +### Gesten-Steuerung +- **Nach links swipen**: Bestellübersicht öffnen (Order-Mode) +- **Nach rechts swipen**: Barcode-Druck (Produktsuche öffnet sich) + ## Technische Details ### Dateistruktur @@ -137,6 +150,7 @@ handybarcodescanner/ ### Verwendete Bibliotheken - [QuaggaJS](https://github.com/ericblade/quagga2) - Browser-basierte Barcode-Erkennung +- [JsBarcode](https://github.com/lindell/JsBarcode) - Barcode-Generierung für Druck ## Changelog diff --git a/ajax/addfreetextline.php b/ajax/addfreetextline.php new file mode 100644 index 0000000..bc71beb --- /dev/null +++ b/ajax/addfreetextline.php @@ -0,0 +1,144 @@ + + * + * AJAX: Add free text line to supplier order + */ + +if (!defined('NOTOKENRENEWAL')) { + define('NOTOKENRENEWAL', '1'); +} +if (!defined('NOREQUIREMENU')) { + define('NOREQUIREMENU', '1'); +} +if (!defined('NOREQUIREHTML')) { + define('NOREQUIREHTML', '1'); +} +if (!defined('NOREQUIREAJAX')) { + define('NOREQUIREAJAX', '1'); +} + +// Load Dolibarr environment +$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(['success' => false, 'error' => 'Failed to load Dolibarr'])); +} + +require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.commande.class.php'; +require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php'; + +header('Content-Type: application/json; charset=utf-8'); + +// Security check +if (!$user->hasRight('fournisseur', 'commande', 'creer') && !$user->hasRight('supplier_order', 'creer')) { + echo json_encode(['success' => false, 'error' => 'Access denied']); + exit; +} + +$supplierId = GETPOSTINT('supplier_id'); +$description = GETPOST('description', 'alphanohtml'); +$qty = GETPOSTFLOAT('qty'); +$price = GETPOSTFLOAT('price'); + +if (empty($supplierId) || empty($description) || $qty <= 0) { + echo json_encode(['success' => false, 'error' => 'Missing parameters (supplier_id, description, qty required)']); + exit; +} + +// Load supplier +$supplier = new Societe($db); +if ($supplier->fetch($supplierId) <= 0) { + echo json_encode(['success' => false, 'error' => 'Supplier not found']); + exit; +} + +// Build ref_supplier for today +$refSupplier = date('ymd') . '-Direkt'; + +// Search for existing draft order for this supplier with today's ref_supplier +$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."commande_fournisseur"; +$sql .= " WHERE fk_soc = ".((int) $supplierId); +$sql .= " AND fk_statut = 0"; // Draft status +$sql .= " AND ref_supplier = '".$db->escape($refSupplier)."'"; +$sql .= " AND entity IN (".getEntity('supplier_order').")"; +$sql .= " ORDER BY rowid DESC LIMIT 1"; + +$resql = $db->query($sql); +$existingOrderId = 0; + +if ($resql && $db->num_rows($resql) > 0) { + $obj = $db->fetch_object($resql); + $existingOrderId = $obj->rowid; +} + +$order = new CommandeFournisseur($db); + +if ($existingOrderId > 0) { + $order->fetch($existingOrderId); +} else { + // Create new order + $order->socid = $supplierId; + $order->ref_supplier = $refSupplier; + $order->cond_reglement_id = $supplier->cond_reglement_supplier_id ?: getDolGlobalInt('FOURN_COND_REGLEMENT_ID_DEFAULT', 1); + $order->mode_reglement_id = $supplier->mode_reglement_supplier_id ?: getDolGlobalInt('FOURN_MODE_REGLEMENT_ID_DEFAULT', 1); + $order->date = dol_now(); + $order->date_livraison = dol_now() + (7 * 24 * 60 * 60); + + $result = $order->create($user); + + if ($result < 0) { + echo json_encode(['success' => false, 'error' => 'Failed to create order: ' . $order->error]); + exit; + } +} + +// Add free text line (no product_id) +$tva_tx = getDolGlobalString('MAIN_DEFAULT_TVA') ?: 19; + +$result = $order->addline( + $description, // description + $price, // pu_ht (unit price) + $qty, // qty + $tva_tx, // txtva + 0, // localtax1 + 0, // localtax2 + 0, // fk_product (0 = free text) + 0, // fk_prod_fourn_price + '', // ref_fourn + 0, // remise_percent + 'HT', // price_base_type + 0, // pu_devise + 0, // type (0 = product, 1 = service) + 0, // rang + 0 // special_code +); + +if ($result < 0) { + echo json_encode(['success' => false, 'error' => 'Failed to add line: ' . $order->error]); + exit; +} + +// Get updated order info +$order->fetch($order->id); +$totalItems = count($order->lines); +$totalQty = 0; +foreach ($order->lines as $line) { + $totalQty += $line->qty; +} + +echo json_encode([ + 'success' => true, + 'order_id' => $order->id, + 'order_ref' => $order->ref, + 'total_items' => $totalItems, + 'total_qty' => $totalQty, + 'message' => 'Free text line added to ' . $order->ref +]); diff --git a/ajax/getorderlines.php b/ajax/getorderlines.php new file mode 100644 index 0000000..6e2be51 --- /dev/null +++ b/ajax/getorderlines.php @@ -0,0 +1,104 @@ + + * + * AJAX: Get order lines for a specific supplier order + */ + +if (!defined('NOTOKENRENEWAL')) { + define('NOTOKENRENEWAL', '1'); +} +if (!defined('NOREQUIREMENU')) { + define('NOREQUIREMENU', '1'); +} +if (!defined('NOREQUIREHTML')) { + define('NOREQUIREHTML', '1'); +} +if (!defined('NOREQUIREAJAX')) { + define('NOREQUIREAJAX', '1'); +} + +// Load Dolibarr environment +$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(['success' => false, 'error' => 'Failed to load Dolibarr'])); +} + +require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.commande.class.php'; +require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php'; + +header('Content-Type: application/json; charset=utf-8'); + +// Security check +if (!$user->hasRight('fournisseur', 'commande', 'lire') && !$user->hasRight('supplier_order', 'read')) { + echo json_encode(['success' => false, 'error' => 'Access denied']); + exit; +} + +$orderId = GETPOSTINT('order_id'); + +if (empty($orderId)) { + echo json_encode(['success' => false, 'error' => 'No order_id provided']); + exit; +} + +$order = new CommandeFournisseur($db); +if ($order->fetch($orderId) <= 0) { + echo json_encode(['success' => false, 'error' => 'Order not found']); + exit; +} + +$lines = []; + +foreach ($order->lines as $line) { + $productLabel = $line->product_label ?: $line->desc; + $productRef = $line->product_ref ?: ''; + $stock = 0; + + // Get stock if product exists + if (!empty($line->fk_product)) { + $product = new Product($db); + if ($product->fetch($line->fk_product) > 0) { + $stock = (float) $product->stock_reel; + if (empty($productLabel)) { + $productLabel = $product->label; + } + if (empty($productRef)) { + $productRef = $product->ref; + } + } + } + + $lines[] = [ + 'id' => (int) $line->id, + 'product_id' => (int) $line->fk_product, + 'product_ref' => $productRef, + 'product_label' => $productLabel, + 'qty' => (float) $line->qty, + 'price' => (float) $line->subprice, + 'total_ht' => (float) $line->total_ht, + 'stock' => $stock, + 'ref_fourn' => $line->ref_fourn ?: '' + ]; +} + +echo json_encode([ + 'success' => true, + 'order' => [ + 'id' => $order->id, + 'ref' => $order->ref, + 'ref_supplier' => $order->ref_supplier, + 'status' => $order->statut, + 'supplier_name' => $order->thirdparty->name ?? '', + 'total_ht' => (float) $order->total_ht + ], + 'lines' => $lines +]); diff --git a/ajax/getorders.php b/ajax/getorders.php new file mode 100644 index 0000000..3471828 --- /dev/null +++ b/ajax/getorders.php @@ -0,0 +1,111 @@ + + * + * AJAX: Get supplier orders for overview + */ + +if (!defined('NOTOKENRENEWAL')) { + define('NOTOKENRENEWAL', '1'); +} +if (!defined('NOREQUIREMENU')) { + define('NOREQUIREMENU', '1'); +} +if (!defined('NOREQUIREHTML')) { + define('NOREQUIREHTML', '1'); +} +if (!defined('NOREQUIREAJAX')) { + define('NOREQUIREAJAX', '1'); +} + +// Load Dolibarr environment +$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(['success' => false, 'error' => 'Failed to load Dolibarr'])); +} + +require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.commande.class.php'; + +header('Content-Type: application/json; charset=utf-8'); + +// Security check +if (!$user->hasRight('fournisseur', 'commande', 'lire') && !$user->hasRight('supplier_order', 'read')) { + echo json_encode(['success' => false, 'error' => 'Access denied']); + exit; +} + +$orders = []; + +// Get all draft orders (fk_statut = 0) and approved orders (fk_statut = 1) +// Sorted: Direkt-drafts first, then other drafts, then approved +$sql = "SELECT cf.rowid, cf.ref, cf.ref_supplier, cf.fk_statut as status, cf.date_commande, cf.total_ht,"; +$sql .= " s.nom as supplier_name, s.rowid as supplier_id,"; +$sql .= " (SELECT COUNT(*) FROM ".MAIN_DB_PREFIX."commande_fournisseurdet WHERE fk_commande = cf.rowid) as line_count,"; +$sql .= " (SELECT SUM(qty) FROM ".MAIN_DB_PREFIX."commande_fournisseurdet WHERE fk_commande = cf.rowid) as total_qty"; +$sql .= " FROM ".MAIN_DB_PREFIX."commande_fournisseur as cf"; +$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."societe as s ON cf.fk_soc = s.rowid"; +$sql .= " WHERE cf.entity IN (".getEntity('supplier_order').")"; +$sql .= " AND cf.fk_statut IN (0, 1, 2)"; // Draft, Validated, Approved +$sql .= " ORDER BY"; +// Direkt-Bestellungen (Entwurf) zuerst +$sql .= " CASE WHEN cf.fk_statut = 0 AND cf.ref_supplier LIKE '%-Direkt' THEN 0"; +// Dann Direkt-Bestellungen (freigestellt) +$sql .= " WHEN cf.fk_statut IN (1,2) AND cf.ref_supplier LIKE '%-Direkt' THEN 1"; +// Dann andere Entwürfe +$sql .= " WHEN cf.fk_statut = 0 THEN 2"; +// Dann alle anderen +$sql .= " ELSE 3 END,"; +$sql .= " cf.date_commande DESC, cf.rowid DESC"; + +$resql = $db->query($sql); +if ($resql) { + while ($obj = $db->fetch_object($resql)) { + $isDirekt = (strpos($obj->ref_supplier, '-Direkt') !== false); + + $orders[] = [ + 'id' => (int) $obj->rowid, + 'ref' => $obj->ref, + 'ref_supplier' => $obj->ref_supplier, + 'status' => (int) $obj->status, + 'status_label' => getOrderStatusLabel((int) $obj->status), + 'date' => $obj->date_commande, + 'total_ht' => (float) $obj->total_ht, + 'supplier_id' => (int) $obj->supplier_id, + 'supplier_name' => $obj->supplier_name, + 'line_count' => (int) $obj->line_count, + 'total_qty' => (float) $obj->total_qty, + 'is_direkt' => $isDirekt + ]; + } +} + +/** + * Get status label + */ +function getOrderStatusLabel($status) { + switch ($status) { + case 0: return 'Entwurf'; + case 1: return 'Validiert'; + case 2: return 'Freigegeben'; + case 3: return 'Bestellt'; + case 4: return 'Teilweise geliefert'; + case 5: return 'Geliefert'; + case 6: return 'Storniert'; + case 7: return 'Annulliert'; + case 9: return 'Abgelehnt'; + default: return 'Unbekannt'; + } +} + +echo json_encode([ + 'success' => true, + 'orders' => $orders +]); diff --git a/ajax/searchproduct.php b/ajax/searchproduct.php new file mode 100644 index 0000000..578421d --- /dev/null +++ b/ajax/searchproduct.php @@ -0,0 +1,121 @@ + + * + * AJAX: Search products by name/ref for autocomplete + */ + +if (!defined('NOTOKENRENEWAL')) { + define('NOTOKENRENEWAL', '1'); +} +if (!defined('NOREQUIREMENU')) { + define('NOREQUIREMENU', '1'); +} +if (!defined('NOREQUIREHTML')) { + define('NOREQUIREHTML', '1'); +} +if (!defined('NOREQUIREAJAX')) { + define('NOREQUIREAJAX', '1'); +} + +// Load Dolibarr environment +$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(['success' => false, 'error' => 'Failed to load Dolibarr'])); +} + +require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php'; +require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.product.class.php'; + +header('Content-Type: application/json; charset=utf-8'); + +// Security check +if (!$user->hasRight('produit', 'lire') && !$user->hasRight('product', 'read')) { + echo json_encode(['success' => false, 'error' => 'Access denied']); + exit; +} + +$search = GETPOST('q', 'alphanohtml'); +$limit = GETPOSTINT('limit') ?: 15; + +if (empty($search) || strlen($search) < 2) { + echo json_encode(['success' => true, 'products' => []]); + exit; +} + +$products = []; + +// Search products by ref, label +$sql = "SELECT p.rowid, p.ref, p.label, p.barcode, p.stock as stock_reel, p.fk_unit"; +$sql .= " FROM ".MAIN_DB_PREFIX."product as p"; +$sql .= " WHERE p.entity IN (".getEntity('product').")"; +$sql .= " AND (p.ref LIKE '%".$db->escape($search)."%'"; +$sql .= " OR p.label LIKE '%".$db->escape($search)."%'"; +$sql .= " OR p.barcode LIKE '%".$db->escape($search)."%')"; +$sql .= " ORDER BY p.label ASC"; +$sql .= " LIMIT ".((int) $limit); + +$resql = $db->query($sql); +if ($resql) { + while ($obj = $db->fetch_object($resql)) { + // Get suppliers for this product + $suppliers = []; + $productFourn = new ProductFournisseur($db); + $supplierPrices = $productFourn->list_product_fournisseur_price($obj->rowid); + + if ($supplierPrices) { + foreach ($supplierPrices as $sp) { + // Get supplier name + $supplierSql = "SELECT nom FROM ".MAIN_DB_PREFIX."societe WHERE rowid = ".((int) $sp->fourn_id); + $supplierRes = $db->query($supplierSql); + $supplierObj = $db->fetch_object($supplierRes); + + $refFourn = $sp->ref_supplier ?? $sp->fourn_ref ?? $sp->ref_fourn ?? ''; + + $suppliers[] = [ + 'id' => $sp->fourn_id, + 'name' => $supplierObj->nom ?? 'Unknown', + 'price' => (float) $sp->fourn_price, + 'ref_fourn' => $refFourn + ]; + } + // Sort by price + usort($suppliers, function($a, $b) { + return $a['price'] <=> $b['price']; + }); + } + + // Get unit label + $stockUnit = 'Stk'; + if (!empty($obj->fk_unit)) { + $unitSql = "SELECT label FROM ".MAIN_DB_PREFIX."c_units WHERE rowid = ".((int) $obj->fk_unit); + $unitRes = $db->query($unitSql); + if ($unitRes && $unitObj = $db->fetch_object($unitRes)) { + $stockUnit = $unitObj->label; + } + } + + $products[] = [ + 'id' => $obj->rowid, + 'ref' => $obj->ref, + 'label' => $obj->label, + 'barcode' => $obj->barcode, + 'stock' => (float) $obj->stock_reel, + 'stock_unit' => $stockUnit, + 'suppliers' => $suppliers + ]; + } +} + +echo json_encode([ + 'success' => true, + 'products' => $products +]); diff --git a/ajax/updateorderline.php b/ajax/updateorderline.php new file mode 100644 index 0000000..139fc3c --- /dev/null +++ b/ajax/updateorderline.php @@ -0,0 +1,147 @@ + + * + * AJAX: Update or delete order line + */ + +if (!defined('NOTOKENRENEWAL')) { + define('NOTOKENRENEWAL', '1'); +} +if (!defined('NOREQUIREMENU')) { + define('NOREQUIREMENU', '1'); +} +if (!defined('NOREQUIREHTML')) { + define('NOREQUIREHTML', '1'); +} +if (!defined('NOREQUIREAJAX')) { + define('NOREQUIREAJAX', '1'); +} + +// Load Dolibarr environment +$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(['success' => false, 'error' => 'Failed to load Dolibarr'])); +} + +require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.commande.class.php'; + +header('Content-Type: application/json; charset=utf-8'); + +// Security check +if (!$user->hasRight('fournisseur', 'commande', 'creer') && !$user->hasRight('supplier_order', 'creer')) { + echo json_encode(['success' => false, 'error' => 'Access denied']); + exit; +} + +$action = GETPOST('action', 'alpha'); +$orderId = GETPOSTINT('order_id'); +$lineId = GETPOSTINT('line_id'); + +if (empty($orderId) || empty($lineId)) { + echo json_encode(['success' => false, 'error' => 'Missing parameters']); + exit; +} + +$order = new CommandeFournisseur($db); +if ($order->fetch($orderId) <= 0) { + echo json_encode(['success' => false, 'error' => 'Order not found']); + exit; +} + +// Only allow changes to draft orders +if ($order->statut != 0) { + echo json_encode(['success' => false, 'error' => 'Order is not a draft']); + exit; +} + +// Find line +$targetLine = null; +foreach ($order->lines as $line) { + if ($line->id == $lineId) { + $targetLine = $line; + break; + } +} + +if (!$targetLine) { + echo json_encode(['success' => false, 'error' => 'Line not found']); + exit; +} + +if ($action === 'delete') { + $result = $order->deleteLine($lineId); + + if ($result < 0) { + echo json_encode(['success' => false, 'error' => 'Failed to delete line: ' . $order->error]); + exit; + } + + // Check if order is now empty - if so, delete it + $order->fetch($orderId); + if (count($order->lines) == 0) { + $order->delete($user); + echo json_encode([ + 'success' => true, + 'message' => 'Line and empty order deleted', + 'order_deleted' => true + ]); + exit; + } + + echo json_encode([ + 'success' => true, + 'message' => 'Line deleted', + 'order_deleted' => false + ]); + +} elseif ($action === 'update') { + $newQty = GETPOSTFLOAT('qty'); + + if ($newQty <= 0) { + echo json_encode(['success' => false, 'error' => 'Quantity must be greater than 0']); + exit; + } + + $result = $order->updateline( + $lineId, + $targetLine->desc, + $targetLine->subprice, + $newQty, + $targetLine->remise_percent, + $targetLine->tva_tx, + $targetLine->localtax1_tx ?: 0, + $targetLine->localtax2_tx ?: 0, + 'HT', + 0, + 0, + $targetLine->product_type ?: 0, + false, + null, + null, + 0, + $targetLine->ref_fourn + ); + + if ($result < 0) { + echo json_encode(['success' => false, 'error' => 'Failed to update line: ' . $order->error]); + exit; + } + + echo json_encode([ + 'success' => true, + 'message' => 'Line updated', + 'new_qty' => $newQty + ]); + +} else { + echo json_encode(['success' => false, 'error' => 'Unknown action']); +} diff --git a/core/modules/modHandyBarcodeScanner.class.php b/core/modules/modHandyBarcodeScanner.class.php index 300ed86..1683b5e 100755 --- a/core/modules/modHandyBarcodeScanner.class.php +++ b/core/modules/modHandyBarcodeScanner.class.php @@ -76,7 +76,7 @@ class modHandyBarcodeScanner extends DolibarrModules $this->editor_squarred_logo = ''; // Must be image filename into the module/img directory followed with @modulename. Example: 'myimage.png@handybarcodescanner' // Possible values for version are: 'development', 'experimental', 'dolibarr', 'dolibarr_deprecated', 'experimental_deprecated' or a version string like 'x.y.z' - $this->version = '4.7'; + $this->version = '5.3'; // Url to the file with your last numberversion of this module //$this->url_last_version = 'http://www.example.com/versionmodule.txt'; diff --git a/css/scanner.css b/css/scanner.css index 3a4360e..8e94692 100755 --- a/css/scanner.css +++ b/css/scanner.css @@ -600,3 +600,592 @@ min-height: 48px; } } + +/* ============================================ + ORDER VIEW ENHANCEMENTS + ============================================ */ + +/* Tools row (search + freetext buttons) */ +.order-tools { + display: flex; + gap: 8px; + margin-bottom: 12px; +} + +.tool-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 12px; + background: var(--colorbacklinepair1, #f8f8f8); + border: 1px solid var(--colorborder, #ddd); + border-radius: 6px; + font-size: 13px; + color: var(--colortextlink, #333); + cursor: pointer; + transition: all 0.2s ease; +} + +.tool-btn:hover { + background: var(--colorbacklinepair2, #f0f0f0); +} + +.tool-btn:active { + transform: scale(0.98); +} + +.tool-btn svg { + width: 16px; + height: 16px; +} + +/* Search Modal */ +.search-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: flex-start; + justify-content: center; + z-index: 10000; + padding: 20px; + padding-top: 60px; +} + +.search-modal-content { + background: #fff; + border-radius: 12px; + width: 100%; + max-width: 450px; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 10px 40px rgba(0,0,0,0.3); +} + +.search-modal-header { + padding: 15px; + border-bottom: 1px solid var(--colorborder, #ddd); + display: flex; + align-items: center; + gap: 10px; +} + +.search-modal-header input { + flex: 1; + padding: 10px 12px; + border: 1px solid var(--colorborder, #ddd); + border-radius: 6px; + font-size: 16px; +} + +.search-modal-header input:focus { + border-color: var(--butactionbg, #0077b3); + outline: none; +} + +.search-close-btn { + padding: 8px; + background: none; + border: none; + font-size: 20px; + cursor: pointer; + color: #999; +} + +.search-results { + flex: 1; + overflow-y: auto; + padding: 10px; +} + +.search-result-item { + padding: 12px; + border: 1px solid var(--colorborder, #ddd); + border-radius: 6px; + margin-bottom: 8px; + cursor: pointer; + transition: all 0.2s ease; +} + +.search-result-item:hover { + background: var(--colorbacklinepair2, #f5f5f5); +} + +.search-result-label { + font-weight: 600; + font-size: 14px; + margin-bottom: 4px; +} + +.search-result-info { + font-size: 12px; + color: #666; + display: flex; + gap: 15px; +} + +.search-loading { + text-align: center; + padding: 20px; + color: #666; +} + +/* Freetext Modal */ +.freetext-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + padding: 20px; +} + +.freetext-modal-content { + background: #fff; + border-radius: 12px; + padding: 20px; + width: 100%; + max-width: 400px; + box-shadow: 0 10px 40px rgba(0,0,0,0.3); +} + +.freetext-modal-title { + font-size: 18px; + font-weight: 600; + margin-bottom: 15px; +} + +.freetext-form { + display: flex; + flex-direction: column; + gap: 12px; +} + +.freetext-form label { + font-size: 13px; + font-weight: 500; + color: #666; + margin-bottom: 4px; + display: block; +} + +.freetext-form input, +.freetext-form select { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--colorborder, #ddd); + border-radius: 6px; + font-size: 15px; + box-sizing: border-box; +} + +.freetext-form input:focus, +.freetext-form select:focus { + border-color: var(--butactionbg, #0077b3); + outline: none; +} + +.freetext-row { + display: flex; + gap: 10px; +} + +.freetext-row > div { + flex: 1; +} + +.freetext-buttons { + display: flex; + gap: 10px; + margin-top: 10px; +} + +.freetext-buttons .action-btn { + flex: 1; + margin-top: 0; +} + +/* ============================================ + SWIPEABLE ORDER OVERVIEW + ============================================ */ + +.order-view-container { + position: relative; + overflow: hidden; + width: 100%; +} + +/* Swipe indicator */ +.swipe-indicator { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + padding: 8px; + background: rgba(0,0,0,0.1); + border-radius: 50%; + font-size: 16px; + color: #666; + pointer-events: none; + opacity: 0.6; + animation: swipeHint 2s ease-in-out infinite; + z-index: 5; +} + +@keyframes swipeHint { + 0%, 100% { transform: translateY(-50%) translateX(0); } + 50% { transform: translateY(-50%) translateX(-5px); } +} + +.order-view-container.swiped .swipe-indicator { + display: none; +} + +/* Orders panel (slides in from right) */ +.orders-panel { + position: absolute; + top: 0; + left: 100%; + width: 100%; + height: 100%; + background: var(--colorbackbody, #fff); + transition: transform 0.3s ease; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.order-view-container.swiped .orders-panel { + transform: translateX(-100%); +} + +.orders-panel-header { + padding: 12px 15px; + background: var(--colorbacktitle1, #f0f0f0); + border-bottom: 1px solid var(--colorborder, #ddd); + display: flex; + align-items: center; + justify-content: space-between; +} + +.orders-panel-title { + font-weight: 600; + font-size: 15px; +} + +.orders-panel-back { + padding: 6px 12px; + background: none; + border: 1px solid var(--colorborder, #ddd); + border-radius: 4px; + font-size: 13px; + cursor: pointer; +} + +/* Horizontal order scroller */ +.orders-scroller { + flex: 1; + overflow-x: auto; + overflow-y: hidden; + white-space: nowrap; + padding: 15px; + -webkit-overflow-scrolling: touch; +} + +.orders-scroller::-webkit-scrollbar { + height: 6px; +} + +.orders-scroller::-webkit-scrollbar-thumb { + background: #ccc; + border-radius: 3px; +} + +/* Order card */ +.order-card { + display: inline-block; + vertical-align: top; + width: 180px; + min-height: 100px; + padding: 12px; + margin-right: 10px; + background: var(--colorbacklinepair1, #f8f8f8); + border: 2px solid var(--colorborder, #ddd); + border-radius: 8px; + cursor: pointer; + white-space: normal; + transition: all 0.2s ease; +} + +.order-card:hover { + border-color: var(--butactionbg, #0077b3); +} + +.order-card.active { + border-color: var(--butactionbg, #0077b3); + background: rgba(0, 119, 179, 0.05); +} + +.order-card.direkt { + font-weight: bold; +} + +.order-card.direkt .order-card-ref { + font-weight: 700; +} + +.order-card-supplier { + font-size: 14px; + font-weight: 600; + margin-bottom: 6px; + overflow: hidden; + text-overflow: ellipsis; +} + +.order-card-ref { + font-size: 12px; + color: #666; + margin-bottom: 4px; +} + +.order-card-status { + display: inline-block; + font-size: 10px; + padding: 2px 6px; + border-radius: 3px; + background: #e9ecef; + color: #495057; +} + +.order-card-status.draft { + background: #fff3cd; + color: #856404; +} + +.order-card-status.validated { + background: #d4edda; + color: #155724; +} + +.order-card-info { + margin-top: 8px; + font-size: 11px; + color: #888; +} + +/* Order detail panel */ +.order-detail-panel { + position: absolute; + top: 0; + left: 200%; + width: 100%; + height: 100%; + background: var(--colorbackbody, #fff); + transition: transform 0.3s ease; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.order-view-container.detail-open .order-detail-panel { + transform: translateX(-200%); +} + +.order-detail-header { + padding: 12px 15px; + background: var(--colorbacktitle1, #f0f0f0); + border-bottom: 1px solid var(--colorborder, #ddd); + display: flex; + align-items: center; + justify-content: space-between; +} + +.order-detail-title { + font-weight: 600; + font-size: 15px; +} + +.order-detail-back { + padding: 6px 12px; + background: none; + border: 1px solid var(--colorborder, #ddd); + border-radius: 4px; + font-size: 13px; + cursor: pointer; +} + +.order-detail-content { + flex: 1; + overflow-y: auto; + padding: 15px; +} + +/* Order lines */ +.order-line { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + background: var(--colorbacklinepair1, #f8f8f8); + border: 1px solid var(--colorborder, #ddd); + border-radius: 6px; + margin-bottom: 8px; + cursor: pointer; + transition: all 0.2s ease; +} + +.order-line:hover { + background: var(--colorbacklinepair2, #f0f0f0); +} + +.order-line-info { + flex: 1; + min-width: 0; +} + +.order-line-label { + font-size: 14px; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.order-line-ref { + font-size: 11px; + color: #888; + margin-top: 2px; +} + +.order-line-qty { + font-size: 16px; + font-weight: 700; + color: var(--butactionbg, #0077b3); + margin-left: 15px; + white-space: nowrap; +} + +/* Line edit dialog */ +.line-edit-dialog { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + padding: 20px; +} + +.line-edit-content { + background: #fff; + border-radius: 12px; + padding: 20px; + width: 100%; + max-width: 350px; + box-shadow: 0 10px 40px rgba(0,0,0,0.3); +} + +.line-edit-title { + font-size: 16px; + font-weight: 600; + margin-bottom: 15px; +} + +.line-edit-info { + background: var(--colorbacklinepair1, #f8f8f8); + padding: 12px; + border-radius: 6px; + margin-bottom: 15px; +} + +.line-edit-stock { + font-size: 13px; + color: #666; + margin-bottom: 8px; +} + +.line-edit-qty { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 15px; +} + +.line-edit-qty label { + font-size: 14px; + font-weight: 500; +} + +.line-edit-qty input { + width: 80px; + padding: 8px; + text-align: center; + font-size: 16px; + font-weight: 600; + border: 1px solid var(--colorborder, #ddd); + border-radius: 6px; +} + +.line-edit-buttons { + display: flex; + gap: 10px; +} + +.line-edit-buttons .action-btn { + flex: 1; + margin-top: 0; + padding: 12px; +} + +/* No orders state */ +.no-orders { + text-align: center; + padding: 30px 20px; + color: #666; +} + +/* Loading spinner */ +.loading-spinner { + display: inline-block; + width: 20px; + height: 20px; + border: 2px solid #ddd; + border-top-color: var(--butactionbg, #0077b3); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Mobile adjustments for order view */ +@media (max-width: 768px) { + .order-card { + width: 160px; + } + + .search-modal { + padding: 10px; + padding-top: 20px; + } + + .search-modal-content { + max-height: 90vh; + } +} diff --git a/js/scanner.js b/js/scanner.js index 9751c81..39c3935 100755 --- a/js/scanner.js +++ b/js/scanner.js @@ -39,11 +39,18 @@ }; let isScanning = false; + let isPaused = false; // Scanner pausiert (Dialog offen) let lastScannedCode = null; let currentProduct = null; let selectedSupplier = null; let allSuppliers = []; let quaggaInitialized = false; + let openDialogCount = 0; // Zaehlt offene Dialoge + + // Order overview state + let lastOrderId = null; + let cachedOrders = []; + let currentOrderDetail = null; // Multi-read confirmation - code must be read multiple times to be accepted let pendingCode = null; @@ -90,6 +97,11 @@ window._scannerHideResult = hideResult; window._scannerShowResult = showProductResult; window._scannerCurrentProduct = null; + + // Globale Hooks fuer Toolbar-Buttons (PWA) + window._showProductSearchModal = showProductSearchModal; + window._showFreetextModal = showFreetextModal; + window._showOrdersPanel = showOrdersPanel; } // Check camera support - just log warnings, never disable button @@ -162,7 +174,7 @@ // Load all suppliers for manual selection function loadAllSuppliers() { - fetch(CONFIG.ajaxUrl + 'getsuppliers.php?token=' + CONFIG.token) + fetch(CONFIG.ajaxUrl + 'getsuppliers.php?token=' + CONFIG.token, {credentials: 'same-origin'}) .then(res => res.json()) .then(data => { if (data.success) { @@ -289,23 +301,70 @@ } function stopScanner() { - if (!isScanning) return; clearTimeout(scanTimeoutTimer); if (quaggaInitialized) { - Quagga.offDetected(onBarcodeDetected); - Quagga.stop(); + try { + Quagga.offDetected(onBarcodeDetected); + Quagga.stop(); + } catch (e) { + console.log('Quagga stop error:', e); + } } isScanning = false; + isPaused = false; quaggaInitialized = false; elements.startBtn.classList.remove('hidden'); elements.startBtn.disabled = false; - elements.startBtn.textContent = CONFIG.lang.startScan || 'Scan starten'; + elements.startBtn.textContent = CONFIG.lang.startScan || 'Scannen'; elements.stopBtn.classList.add('hidden'); elements.videoContainer.classList.remove('scanning'); } + // Scanner pausieren (bei Dialog-Oeffnung) + function pauseScanner() { + if (!isScanning || isPaused) return; + openDialogCount++; + + if (quaggaInitialized) { + try { + Quagga.pause(); + isPaused = true; + console.log('Scanner paused, dialogs:', openDialogCount); + } catch (e) { + console.log('Quagga pause error:', e); + } + } + } + + // Scanner fortsetzen (wenn alle Dialoge geschlossen) + function resumeScanner() { + openDialogCount = Math.max(0, openDialogCount - 1); + + if (openDialogCount > 0) { + console.log('Still dialogs open:', openDialogCount); + return; + } + + if (!isScanning || !isPaused) return; + + if (quaggaInitialized) { + try { + Quagga.start(); + isPaused = false; + console.log('Scanner resumed'); + startScanTimeout(); + } catch (e) { + console.log('Quagga resume error:', e); + } + } + } + + // Globale Funktionen fuer PWA + window._pauseScanner = pauseScanner; + window._resumeScanner = resumeScanner; + function onBarcodeDetected(result) { let code = result.codeResult.code; const format = result.codeResult.format; @@ -448,7 +507,7 @@ function searchProduct(barcode) { showLoading(); - fetch(CONFIG.ajaxUrl + 'findproduct.php?token=' + CONFIG.token + '&barcode=' + encodeURIComponent(barcode)) + fetch(CONFIG.ajaxUrl + 'findproduct.php?token=' + CONFIG.token + '&barcode=' + encodeURIComponent(barcode), {credentials: 'same-origin'}) .then(res => res.json()) .then(data => { hideLoading(); @@ -543,24 +602,65 @@ } elements.resultArea.innerHTML = ` -