diff --git a/assets/controllers/pages/handheld_scan_controller.js b/assets/controllers/pages/handheld_scan_controller.js new file mode 100644 index 000000000..81aa54790 --- /dev/null +++ b/assets/controllers/pages/handheld_scan_controller.js @@ -0,0 +1,135 @@ +import {Controller} from "@hotwired/stimulus"; +/** + * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). + * + * Copyright (C) 2024 Alex Barclay (https://github.com/barclaac) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + + _scanner = null; + + constructor() { + super() + _scanner = new ScannerSerial() + } + + connect(event) { + console.log('Controller connected') + + if (_scanner.isConnected()) { + console.log('already connected') + document.getElementById('handheld_scanner_dialog_connect').style.display='none' + document.getElementById('handheld_scanner_dialog_disconnect').style.display='' + } + } + + async onConnectScanner(event) { + console.log('Connect to barcode reader') + + if (!_scanner.isConnected()) { + await _scanner.connect(async (reader) => { + decoder = new TextDecoder(); + var barcodeBuffer = ''; + while (true) { + const { value, done } = await reader.read(); + if (done) { + console.log('releasing reader') + reader.releaseLock(); + reader = null; + break; + } + console.log(value); + console.log(decoder.decode(value)); + partial = decoder.decode(value); + barcodeBuffer += partial + end = false + endidx = partial.indexOf('\x1e\x04'); + if (endidx != -1) { + end = true; + } else { + endidx = partial.indexOf('\r'); + if (endidx != -1) { + end = true; + } + } + + if (end) { + // Decode the barcode + console.log(barcodeBuffer) + start = barcodeBuffer.indexOf('[)>') + if (start == -1) { + console.log('badly formed barcode') + } else { + // Post this back to the server + document.getElementById('handheld_scanner_dialog_barcode').value = barcodeBuffer; + form = document.getElementById('handheld_dialog_form'); + form.requestSubmit(); + } + barcodeBuffer = ''; + } + } + }) + document.getElementById('handheld_scanner_dialog_connect').style.display='none' + document.getElementById('handheld_scanner_dialog_disconnect').style.display='' + + } + } + + async onDisconnectScanner(event) { + console.log('Disconnect called') + if (_scanner.isConnected) { + _scanner.disconnect() + document.getElementById('handheld_scanner_dialog_connect').style.display='' + document.getElementById('handheld_scanner_dialog_disconnect').style.display='none' + } + } +} + +class ScannerSerial { + device = null + reader = null + + constructor() { + if (ScannerSerial.instance) { + return ScannerSerial.instance; + } + ScannerSerial.instance = this; + } + + async connect(readHandler) { + this.device = await navigator.serial.requestPort({ filters: [{ usbVendorId: 0x03f0, usbDeviceId: 0x0339 }] }) + await this.device.open({baudRate: 9600}) + + console.log(this.device) + this.reader = this.device.readable.getReader() + readHandler(this.reader) + } + + async disconnect() { + await this.reader.cancel() + this.reader = null + this.device.close() + } + + isConnected() { + if (this.reader == null) { + return false; + } + return true + } +} diff --git a/docs/usage/eigp114_barcode_operations.md b/docs/usage/eigp114_barcode_operations.md new file mode 100644 index 000000000..1f135ead0 --- /dev/null +++ b/docs/usage/eigp114_barcode_operations.md @@ -0,0 +1,28 @@ +# EIGP 114 Barcode Operations # + +## Concept of Operation ## +Module is intended to be as quick and easy as possible once you receive your bag of goodies from +Digikey or Mouser. Steps would be: + * Grab a bag from the shipment + * Identify the component type and scan your intended storage box + * Scan the storage container + * Scan the bag and put in container + * Grab the next bag (same component type) + * Scan the bag and put in container + * Grab the next bag (different component type) + * Scan new container + * Scan the bag and put in container + +### Alternatives ### + + * Grab a bag from the shipment + * Scan the bag + * Put the bag in the single storage location indicated + * Scan next bag from shipment... + +## Scanner Types ## + +Currently only supports an HP 4430 scanner. Code uses WebSerial to communicate with the scanner. For +this to me more widespread would need to add a database of scanner USB device IDs and some data +indicating how each is different + diff --git a/src/Controller/HandheldScannerController.php b/src/Controller/HandheldScannerController.php new file mode 100644 index 000000000..3e1ab1b2f --- /dev/null +++ b/src/Controller/HandheldScannerController.php @@ -0,0 +1,437 @@ +. + */ + +declare(strict_types=1); + +namespace App\Controller; + +use App\Entity\Base\AbstractDBElement; +use App\Entity\LabelSystem\LabelOptions; +use App\Entity\LabelSystem\LabelProcessMode; +use App\Entity\LabelSystem\LabelProfile; +use App\Entity\LabelSystem\LabelSupportedElement; +use App\Entity\Parts\Category; +use App\Entity\Parts\Part; +use App\Entity\Parts\PartLot; +use App\Entity\Parts\StorageLocation; +use App\Exceptions\TwigModeException; +use App\Form\LabelSystem\HandheldScannerDialogType; +use App\Helpers\EIGP114; +use App\Repository\DBElementRepository; +use App\Services\ElementTypeNameGenerator; +use App\Services\LabelSystem\LabelGenerator; +use App\Services\Misc\RangeParser; +use App\Services\Parts\PartLotWithdrawAddHelper; +use Doctrine\ORM\EntityManagerInterface; +use Psr\Log\LoggerInterface; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Form\Form; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormError; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; +use Symfony\Contracts\Translation\TranslatorInterface; + +class BarcodeScanType +{ + protected ?string $barcode; + protected ?string $manufacturerPN; + protected ?int $quantity; + protected ?string $location; + + // Show last item and quantity added to help check if scanning is working well + protected ?string $lastManufacturerPN; + protected ?int $lastQuantity; + + public function __construct() { + $this->barcode = ""; + $this->manufacturerPN = ""; + $this->quantity = 0; + $this->location = ""; + + $this->lastManufacturerPN = ""; + $this->lastQuantity = 0; + } + + public function getBarcode(): ?string { + return $this->barcode; + } + + public function setBarcode(?string $barcode): self { + $this->barcode = $barcode; + return $this; + } + + public function getManufacturerPN(): ?string { + return $this->manufacturerPN; + } + + public function setManufacturerPN(?string $manufacturerPN): self { + $this->manufacturerPN = $manufacturerPN; + return $this; + } + + public function getLastManufacturerPN(): ?string { + return $this->lastManufacturerPN; + } + + public function setLastManufacturerPN(?string $lastManufacturerPN): self { + $this->lastManufacturerPN = $lastManufacturerPN; + return $this; + } + + public function getQuantity(): ?int { + return $this->quantity; + } + + public function setQuantity(?int $quantity): self { + $this->quantity = $quantity; + return $this; + } + + public function getLastQuantity(): ?int { + return $this->lastQuantity; + } + + public function setLastQuantity(?int $lastQuantity): self { + $this->lastQuantity = $lastQuantity; + return $this; + } + + public function getLocation(): ?string { + return $this->location; + } + + public function setLocation(?string $location): self { + $this->location = $location; + return $this; + } + + public function cycleLastAdded() : void { + $this->lastManufacturerPN = $this->manufacturerPN; + $this->manufacturerPN = null; + $this->lastQuantity = $this->quantity; + $this->quantity = 0; + } +} + +class HandheldScannerController extends AbstractController +{ + public function __construct(protected EntityManagerInterface $em, protected LoggerInterface $logger, protected TranslatorInterface $translator) + { + } + + #[Route(path: '/handheldscanner',name: 'handheld_scanner_dialog')] + public function generator(Request $request, + PartLotWithdrawAddHelper $withdrawAddHelper): Response + { + $this->logger->info('*** rendering form ***'); + $this->logger->info(var_export($request->getPayload()->all(), true)); + + $barcode = new BarcodeScanType(); + $form = $this->buildForm($barcode); + + $form->handleRequest($request); + + if ($form->get('autocommit')->getData() == true || ($form->isSubmitted() && $form->isValid())) { + if ($this->processSubmit($form, $withdrawAddHelper, $barcode)) { + // Need a new form to render because we can't change submitted form + $this->logger->info('replacing form with fresh'); + $barcode->cycleLastAdded(); // Shuffle into last added slot + $newForm = $this->buildForm($barcode); + $newForm->get('missingloc')->setData($form->get('missingloc')->getData()); + $newForm->get('locfrompart')->setData($form->get('locfrompart')->getData()); + $newForm->get('foundloc')->setData($form->get('foundloc')->getData()); + $newForm->get('missingpart')->setData($form->get('missingpart')->getData()); + $newForm->get('manufacturer_pn')->setData(''); + $newForm->get('quantity')->setData(0); + $form = $newForm; + } + } + return $this->render('label_system/handheld_scanner/handheld_scanner.html.twig', [ + 'form' => $form, + ]); + } + + protected function buildForm(BarcodeScanType $barcode) : FormInterface + { + $builder = $this->container->get('form.factory') + ->createBuilder(HandheldScannerDialogType::class, $barcode); + $this->addPreSubmitEventHandler($builder); + + $form = $builder->getForm(); + + return $form; + } + + protected function addPreSubmitEventHandler(FormBuilderInterface $builder) + { + $builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) { + $form = $event->getForm(); + $data = $event->getData(); + if (!isset($data['barcode'])) { + return; + } + $r = EIGP114::decode($data['barcode']); + + // Remove barcode so that if user has edited fields that the barcode won't + // override any of the user's values + unset($data['barcode']); + + if (array_key_exists('location', $r)) { + $data['location'] = $r['location']; + $data['last_scan'] = 'location'; + $data['manufacturer_pn'] = ''; + $data['locfrompart'] = false; + $data['foundpart'] = false; + $data['quantity'] = ''; + } else if (array_key_exists('supplier_pn', $r)) { + $data['manufacturer_pn'] = $r['supplier_pn']; + $data['last_scan'] = 'part'; + if (array_key_exists('quantity', $r)) { + $data['quantity'] = $r['quantity']; + } + } + + // Look up the location in the database to see if one needs to be created + if ($data['location'] != "") { + $storageRepository = $this->em->getRepository(StorageLocation::class); + $storage = $this->getStorageLocation($data['location']); + $data['foundloc'] = ($storage != null); + } + + // Look up the part in the database to see if one needs to be created + $part = null; + if ($data['manufacturer_pn'] != "") { + $partRepository = $this->em->getRepository(Part::class); + $part = $partRepository->findOneBy(['manufacturer_product_number' => $data['manufacturer_pn']]); + $data['foundpart'] = ($part != null); + if ($data['foundpart'] == false && + (array_key_exists('missingpart', $data) && $data['missingpart'] == false) && + $data['autocommit'] == true) { + $this->addFlash('error', 'Cannot autocommit part - part not in database'); + } + } + + // Did we want to use the storage location for this part + // Will require all part-lots to be in the same location + if (array_key_exists('locfrompart', $data) && $part) { + $this->logger->info('take loc from part'); + $locs=[]; + foreach ($part->getPartLots() as &$pl) { + $locs[$pl->getStorageLocation()->getId()] = $pl->getStorageLocation(); + } + if (count($locs) == 1) { + // Got exactly 1 location - can set this as the default + $storageLoc = array_pop($locs); + $data['location'] = $storageLoc->getName(); + } + } + + $event->setData($data); + }); + } + + protected function processSubmit(FormInterface $form, PartLotWithdrawAddHelper $withdrawAddHelper, + BarcodeScanType $barcode) : bool { + $this->logger->info("form submitted"); + // We could be here through an autosubmit or because the actual button was pressed + // To proceed we need a storage location, manufacturer part number and a quantity + $this->logger->info('processSubmit'); + if ($form instanceof Form && $form->getClickedButton() != null || $form->get('autocommit')->getData() == true) { + $fail = false; + if ($barcode->getLocation() != '' && $barcode->getManufacturerPN() != '' && + $barcode->getQuantity() != 0) { + // Got all the data that we need - now work through the items to see if we + // can submit the data + $storageLocation = $this->getStorageLocation($barcode->getLocation()); + if (!$storageLocation) { + if ($form->get('missingloc')->getData() == true) { + $storageLocation = $this->createStorageLocation($barcode->getLocation()); + } else { + $fail = true; + $this->addFlash('error', 'storage doesn\'t exist'); + } + } + + $part = $this->getPart($barcode->getManufacturerPN()); + if (!$part) { + $this->logger->debug('part not found'); + if ($form->get('missingpart')->getData() == true) { + $this->logger->debug('create part'); + $part = $this->createMissingPart($barcode->getManufacturerPN()); + $this->logger->debug('part created'); + + $this->em->flush(); + } else { + $fail = true; + $this->addFlash('error', 'part doesn\'t exist'); + } + } + + if (!$fail) { + // Have a part and storage location so attempt to add stock for this combination + $found=false; + $partLots = $part->getPartLots(); + if ($partLots != null) { + foreach ($part->getPartLots() as &$pl) { + if ($pl->getStorageLocation()->getId() == $storageLocation->getId()) { + $this->logger->info('Found existing storage location, adding stock'); + if ($withdrawAddHelper->canAdd($pl)) { + $withdrawAddHelper->add($pl, $barcode->getQuantity(), "Barcode scan add"); + $found = true; + } + break; + } + $this->logger->info('Part lot {fullPath}', ['fullPath' => $pl->getStorageLocation()->getFullPath()]); + } + } + if (!$found) { + // No part lot for this storage location - add one + $partLot = new PartLot(); + $partLot->setStorageLocation($storageLocation); + $partLot->setInstockUnknown(false); + $partLot->setAmount(0.0); + $part->addPartLot($partLot); + $this->em->flush(); // Must have an ID for target + if ($withdrawAddHelper->canAdd($partLot)) { + $withdrawAddHelper->add($partLot, $barcode->getQuantity(), "Barcode scan add"); + } + + } + } + + if (!$fail) { + $this->em->flush(); + } + + return true; + } + } + + return false; + } + + protected function getStorageLocation(string $name) : ?StorageLocation + { + $repository = $this->em->getRepository(StorageLocation::class); + $storage = $repository->findOneBy(['name' => $name]); + if ($storage) { + $this->logger->info($storage->getFullPath()); + } else { + $this->logger->info('Storage not found in database'); + } + if ($storage instanceof StorageLocation) { + return $storage; + } + return null; + } + + protected function createStorageLocation(string $location): StorageLocation + { + $repository = $this->em->getRepository(StorageLocation::class); + $storage = new StorageLocation(); + $storage->setName($location); + $this->em->persist($storage); + return $storage; + } + + protected function getPart(string $partNumber) : ?Part + { + $repository = $this->em->getRepository(Part::class); + $part = $repository->findOneBy(['manufacturer_product_number' => $partNumber]); + if ($part) { + $this->logger->info($part->getName()); + } + return $part; + } + + protected function createMissingPart(string $partNumber) : ?Part + { + $repository = $this->em->getRepository(Category::class); + $category = $repository->findOneBy(['name' => 'Unclassified']); + + $part = new Part(); + if ($category instanceof Category) { + $part->setCategory($category); + } + $part->setName($partNumber); + $part->setManufacturerProductNumber($partNumber); + $this->em->persist($part); + + return $part; + } + + protected function addStock(Form $form, PartLotWithdrawAddHelper $withdrawAddHelper, + BarcodeScanType $barcode) + { + $storage = null; + $part = null; + // See if the storage location exists was in barcode + if ($barcode->getLocation() != "") { + $storage = $this->getStorageLocation($barcode->getLocation()); + } + if ($barcode->getManufacturerPN() != "") { + // Got a part instead + $repository = $this->em->getRepository(Part::class); + $part = $repository->findOneBy(['manufacturer_product_number' => $barcode->getManufacturerPN()]); + if ($part) { + $this->logger->info($part->getName()); + } + } + + // Does a part lot exist for this combination? + if ($storage != null && $part != null) { + $found=false; + foreach ($part->getPartLots() as &$pl) { + if ($pl->getStorageLocation()->getId() == $storage->getId()) { + $this->logger->info('Found existing storage location, adding stock'); + if ($withdrawAddHelper->canAdd($pl)) { + $withdrawAddHelper->add($pl, $barcode->getQuantity(), "Test add"); + $found = true; + } + $this->em->flush(); + $this->addFlash('success', 'stock added'); + break; + } + $this->logger->info('Part lot {fullPath}', ['fullPath' => $pl->getStorageLocation()->getFullPath()]); + } + if (!$found) { + // No part lot for this storage location - add one + $partLot = new PartLot(); + $partLot->setStorageLocation($storage); + $partLot->setInstockUnknown(false); + $partLot->setAmount(0.0); + $part->addPartLot($partLot); + $this->em->flush(); + if ($withdrawAddHelper->canAdd($partLot)) { + $withdrawAddHelper->add($partLot, $barcode->getQuantity(), "Creational add"); + } + + $this->em->flush(); + $this->addFlash('success', 'partlot added'); + } + } + } +} diff --git a/src/Form/LabelSystem/HandheldScannerDialogType.php b/src/Form/LabelSystem/HandheldScannerDialogType.php new file mode 100644 index 000000000..7afb5679c --- /dev/null +++ b/src/Form/LabelSystem/HandheldScannerDialogType.php @@ -0,0 +1,139 @@ +. + */ + +declare(strict_types=1); + +namespace App\Form\LabelSystem; + +use Symfony\Bundle\SecurityBundle\Security; +use App\Form\LabelOptionsType; +use App\Helpers\EIGP114; +use App\Validator\Constraints\Misc\ValidRange; +use Psr\Log\LoggerInterface; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\ButtonType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\HiddenType; +use Symfony\Component\Form\Extension\Core\Type\IntegerType; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class HandheldScannerDialogType extends AbstractType +{ + public function __construct(protected Security $security) + { + } + + public function buildForm(FormBuilderInterface $builder, array $options = []): void + { + $builder->add('barcode', HiddenType::Class, [ + 'required' => true, + 'action' => '', + ]); + $builder->add('location', TextType::Class, [ + 'required' => false, + 'label' => 'Storage Location', + 'help' => 'Scan this first, will erase part fields', + ]); + + $builder->add('manufacturer_pn', TextType::Class, [ + 'required' => false, + 'label' => 'Manufacturer Part', + ]); + + $builder->add('last_manufacturer_pn', TextType::Class, [ + 'required' => false, + 'label' => 'Last Added Manufacturer Part', + ]); + + $builder->add('quantity', IntegerType::Class, [ + 'required' => false, + 'label' => 'Quantity', + ]); + + $builder->add('last_quantity', IntegerType::Class, [ + 'required' => false, + 'label' => 'Last Added Quantity', + ]); + + $builder->add('missingloc', CheckboxType::Class, [ + 'label' => 'Create missing Storage Location', + 'mapped' => false, + 'required' => false, + ]); + + $builder->add('locfrompart', CheckboxType::Class, [ + 'label' => 'Take storage location from part', + 'mapped' => false, + 'required' => false, + ]); + + $builder->add('foundloc', CheckboxType::Class, [ + 'label' => 'Storage Location in database', + 'mapped' => false, + 'required' => false, + ]); + + $builder->add('missingpart', CheckboxType::Class, [ + 'label' => 'Create missing Part', + 'mapped' => false, + 'required' => false, + ]); + $builder->add('foundpart', CheckboxType::Class, [ + 'label' => 'Part in database', + 'mapped' => false, + 'required' => false, + ]); + + $builder->add('autocommit', CheckboxType::Class, [ + 'label' => 'Autocommit on Scan', + 'mapped' => false, + 'required' => false, + ]); + + $builder->add('connect', ButtonType::Class, [ + 'label' => 'Connect', + 'attr' => ['data-action' => 'pages--handheld-scan#onConnectScanner', + 'class' => 'btn btn-primary' ], + ]); + + $builder->add('disconnect', ButtonType::Class, [ + 'label' => 'Disconnect', + 'attr' => ['data-action' => 'pages--handheld-scan#onDisconnectScanner', + 'class' => 'btn btn-primary', + 'style' => 'display: none'], + ]); + + $builder->add('submit', SubmitType::Class, [ + 'label' => 'Submit', + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefault('mapped', false); + $resolver->setDefault('allow_extra_fields', true); + } +} diff --git a/src/Helpers/EIGP114.php b/src/Helpers/EIGP114.php new file mode 100644 index 000000000..5933815da --- /dev/null +++ b/src/Helpers/EIGP114.php @@ -0,0 +1,85 @@ +. + */ +declare(strict_types=1); + +namespace App\Helpers; + +use Psr\Log\LoggerInterface; + +/* + EIGP 114 + ISO/IEC 15418 + ANS MH10.8.2-2016 + + Based on the ISO standard but it seems that both Mouser and Digikey aren't quite + following the standard. + + Message is defined as (very loose RE format): ({:RS:} = 0x1e, {:GS:} = 0x1d, {:EOT:} = 0x04) + Header: [)>{:RS:}06{:GS:} + Field data identifier: [0-9]*[A-Z] + Field data: .* + Field Separator: {:GS:} - may or may not be present after last field + Trailer: {:RS:}{:EOT:} + + Mouser & Digikey differences: + Header: [)>06{:GS:} - note the missing {:RS:} + Field: last field missing trailer {:RS:}{:EOT:} so use {:CR:} inserted by scanner itself + + */ +class EIGP114 +{ + public static function decode($barcode): array { + $rslt = []; + + // Find the start sequence for a type 6 ISO/IEC 15434 message + $hdrPattern = "/(\[\)>\u{001e}*06\u{001d})([[:print:]\u{001d}]*)(\u{001e}\u{0004})*/"; + $matches = []; + $loc = preg_match_all($hdrPattern, $barcode, $matches); + $fields = []; + if ($loc) { + $remain = $matches[2][0]; + $fields = preg_split("/\u{001d}/", $remain); + foreach ($fields as &$v) { + EIGP114::processField($v, $rslt); + } + } + return $rslt; + } + + private static function processField($field, &$result) : void { + $pattern = "/([0-9]{0,2}[A-Z])(.+)/"; + $matches = []; + if (preg_match_all($pattern, $field, $matches)) { + switch ($matches[1][0]) { + case 'L': + $result['location'] = $matches[2][0]; + break; + case '1P': + $result['supplier_pn'] = $matches[2][0]; + break; + case 'Q': + $result['quantity'] = $matches[2][0]; + break; + default: + //array_push($result, $matches); + } + } + } +} diff --git a/src/Services/Trees/ToolsTreeBuilder.php b/src/Services/Trees/ToolsTreeBuilder.php index 185713060..fb26cc596 100644 --- a/src/Services/Trees/ToolsTreeBuilder.php +++ b/src/Services/Trees/ToolsTreeBuilder.php @@ -140,6 +140,13 @@ protected function getToolsNode(): array ))->setIcon('fa-treeview fa-fw fa-solid fa-cloud-arrow-down'); } + if ($this->security->isGranted('@info_providers.create_parts')) { + $nodes[] = (new TreeViewNode( + "Handheld Scanner", + $this->urlGenerator->generate('handheld_scanner_dialog') + ))->setIcon('fa-treeview fa-fw fa-solid fa-camera-retro'); + } + return $nodes; } diff --git a/templates/label_system/handheld_scanner/handheld_scanner.html.twig b/templates/label_system/handheld_scanner/handheld_scanner.html.twig new file mode 100644 index 000000000..90c66aa32 --- /dev/null +++ b/templates/label_system/handheld_scanner/handheld_scanner.html.twig @@ -0,0 +1,30 @@ +{% extends 'main_card.html.twig' %} + +{% block card_title %}Handheld Scanner Operations{% endblock %} + +{% block card_content %} +{{ form_start(form, {'attr': {'id': 'handheld_dialog_form'}}) }} + +{{ form_row(form.location) }} +{{ form_row(form.missingloc) }} +{{ form_row(form.locfrompart) }} +{{ form_row(form.foundloc) }} +{{ form_row(form.manufacturer_pn) }} +{{ form_row(form.missingpart) }} +{{ form_row(form.foundpart) }} +{{ form_row(form.quantity) }} +{{ form_row(form.autocommit) }} + +
+{{ form_row(form.last_manufacturer_pn) }} +{{ form_row(form.last_quantity) }} +
+ +
+ {{ form_widget(form.connect) }} + {{ form_widget(form.disconnect) }} + {{ form_widget(form.submit) }} +
+ +{{ form_end(form) }} +{% endblock %}