diff --git a/Layout/default/WarehouseShippingNote/PDF_HEADER.html b/Layout/default/WarehouseShippingNote/PDF_HEADER.html index f1ba3ce5e..6c6bd5781 100644 --- a/Layout/default/WarehouseShippingNote/PDF_HEADER.html +++ b/Layout/default/WarehouseShippingNote/PDF_HEADER.html @@ -67,7 +67,7 @@
{{ addressLine_5 }}
-
Rechnungsadresse
+
{{ billingAddressHeader }}
{{ billingAddressLine_1 }}
{{ billingAddressLine_2 }}
{{ billingAddressLine_3 }}
diff --git a/Layout/default/WarehouseShippingNote/PDF_MAIN.php b/Layout/default/WarehouseShippingNote/PDF_MAIN.php index 3c2806f8f..70a178950 100644 --- a/Layout/default/WarehouseShippingNote/PDF_MAIN.php +++ b/Layout/default/WarehouseShippingNote/PDF_MAIN.php @@ -96,6 +96,10 @@ TODO: enable option for showing prices note ?>

+ status === 'cancelled'): ?> +
STORNIERT
+ + diff --git a/application/WarehouseShippingNote/WarehouseShippingNoteController.php b/application/WarehouseShippingNote/WarehouseShippingNoteController.php index 38c6f7123..fa2aa67b5 100644 --- a/application/WarehouseShippingNote/WarehouseShippingNoteController.php +++ b/application/WarehouseShippingNote/WarehouseShippingNoteController.php @@ -7,12 +7,14 @@ class WarehouseShippingNoteController extends TTCrud { //@formatter:off protected array $columns = [ ['key' => 'id', 'text' => 'LS-Nr.', 'required' => false, 'modal' => false, 'table' => ['class' => 'text-nowrap']], - ['key' => 'billingAddressId', 'text' => 'Rechnungsadresse', 'required' => true, 'type' => 'autocomplete', 'table' => ['class' => 'text-nowrap', 'filter' => 'autocomplete'], 'modal' => ['apiUrl' => 'Address/api?do=findAddress', 'items' => '/Address/Api?do=findAddress', 'type' => 'autocomplete']], + ['key' => 'billingAddressId', 'text' => 'Rechnungsadresse', 'type' => 'autocomplete', 'table' => ['class' => 'text-nowrap', 'filter' => 'autocomplete'], 'modal' => ['apiUrl' => 'Address/api?do=findAddress', 'items' => '/Address/Api?do=findAddress', 'type' => 'autocomplete']], ['key' => 'status', 'text' => 'Status', 'required' => true, 'table' => ['filter' => 'iconSelect'], 'modal' => ['type' => 'iconSelect', 'items' => [ ['value' => 'new', 'text' => 'Neu', 'icon' => 'fas fa-star text-primary'], ['value' => 'in_progress', 'text' => 'In Bearbeitung', 'icon' => 'fas fa-cog text-warning'], ['value' => 'accepted', 'text' => 'Akzeptiert', 'icon' => 'fas fa-check text-success'], ['value' => 'invoiced', 'text' => 'In Rechnung gestellt', 'icon' => 'fas fa-file-invoice-dollar text-info'], + ['value' => 'cancelled', 'text' => 'Storniert', 'icon' => 'fas fa-ban text-danger'], + ['value' => 'on_hold', 'text' => 'In Wartestellung', 'icon' => 'fas fa-pause text-warning'], ]]], ['key' => 'deliveryAddressName', 'text' => 'L.-Adr. Name', 'required' => true], ['key' => 'deliveryAddressLine', 'text' => 'L.-Adr.', 'required' => true], @@ -26,11 +28,6 @@ class WarehouseShippingNoteController extends TTCrud { protected array $defaultOrder = ['key' => 'create', 'order' => 'DESC']; - protected array $additionalActions = [['key' => 'openHistory', 'title' => 'Historie', 'class' => 'fas fa-history text-primary'], - ['key' => 'print', 'title' => 'Drucken', 'class' => 'fas fa-print text-primary'], - ['key' => 'printWithPrice', 'title' => 'Drucken mit Preis', 'class' => 'fas fa-print text-success'], - ]; - protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => true]; protected array $infoMessages = ['create' => 'Lieferschein wurde erstellt.', @@ -40,189 +37,117 @@ class WarehouseShippingNoteController extends TTCrud { //@formatter:on protected function prepareCrudConfig() { - $users = array_map(function ($user) { - return ['value' => intval($user->id), 'text' => $user->name]; - }, UserModel::search(['employee' => true])); - - $this->columns[array_search('createBy', array_column($this->columns, 'key'))]['modal']['items'] = $users; - - if (!$this->user->can('WarehouseAdmin')) { - $this->additionalJSVariables['WAREHOUSE_ADMIN'] = false; - } + if (!$this->user->can('WarehouseAdmin')) $this->additionalJSVariables['WAREHOUSE_ADMIN'] = false; } protected function beforeCreate($postData): bool { - // if postdata status is not new we return an error - if ($postData['status'] !== 'new') { - http_response_code(500); - self::returnJson(['success' => false, 'message' => 'Status muss "Neu" sein']); - die(); - } - - foreach ($postData['hoursEntries'] as $hoursEntry) { - if (!preg_match('/^[0-9,.]*$/', $hoursEntry['hourCount'])) { - http_response_code(500); - self::returnJson(['success' => false, 'message' => 'Stundenanzahl darf nur Zahlen, Komma oder Punkt enthalten']); - die(); - } - } - + $this->validate($postData, [ + fn($p) => $p['status'] === 'new' ?: 'Status muss "Neu" sein', + fn($p) => $this->validateHours($p['hoursEntries']) + ]); $postData['positions'] = json_encode($postData['positions']); return true; } - protected function customAutoCompleteBillingAddressId($id) { - $address = new Address($id); - if ($address->id) { - $result = ['id' => $address->id, - 'title' => str_replace("'", "\\'", str_replace(["\n", - "\r"], " ", $address->getCompanyOrName())) . " (" . $address->zip . " " . $address->city . ", " . $address->street . ")" . (($address->customer_number) ? " [" . $address->customer_number . "]" : "")]; - return $result; - } - return null; - } - protected function beforeUpdate($postData): bool { - $shippingNote = WarehouseShippingNoteModel::get($postData['id']); - if ($shippingNote->status === 'accepted' || $shippingNote->status === 'invoiced') { - http_response_code(500); - self::returnJson(['success' => false, 'message' => 'Änderungen nicht mehr möglich']); - die(); - } - - foreach ($postData['hoursEntries'] as $hoursEntry) { - if (!preg_match('/^[0-9,.]*$/', $hoursEntry['hourCount'])) { - http_response_code(500); - self::returnJson(['success' => false, 'message' => 'Stundenanzahl darf nur Zahlen, Komma oder Punkt enthalten']); - die(); - } - } - + $this->validate($postData, [ + fn($p) => !in_array(WarehouseShippingNoteModel::get($p['id'])->status, + ['accepted', 'invoiced']) ?: 'Änderungen nicht mehr möglich', + fn($p) => $this->validateHours($p['hoursEntries']) + ]); $postData['positions'] = json_encode($postData['positions']); (new WarehouseHistoryController)->create($postData, $this->mod); return true; } - protected function getHistoryAction() { - $historyEntries = []; - - // remove all history elements where key is positions - - foreach ((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns) as $entry) { - if ($entry['key'] !== 'positions') { - $historyEntries[] = $entry; + private function validateHours($entries): bool { + foreach ($entries as $e) { + if (!preg_match('/^[\d,.]*$/', $e['hourCount'])) { + $this->sendError('Stundenanzahl darf nur Zahlen, Komma oder Punkt enthalten'); } } + return true; + } -// $historyEntries = array_filter($historyEntries, function ($entry) { -// return $entry['key'] !== 'positions'; -// }); + private function validate($data, array $rules): void { + foreach ($rules as $rule) { + if (($result = $rule($data)) !== true) { + $this->sendError(is_string($result) ? $result : 'Validation failed'); + } + } + } - self::returnJson($historyEntries); + protected function customAutoCompleteBillingAddressId($id) { + $address = new Address($id); + + if (!$address->id) return null; + + return [ + 'id' => $address->id, + 'title' => sprintf( + "%s (%s %s, %s)%s", + str_replace(["'", "\n", "\r"], ["\\'", ' ', ' '], $address->getCompanyOrName()), + $address->zip, + $address->city, + $address->street, + $address->customer_number ? " [{$address->customer_number}]" : '' + ) + ]; + } + + protected function getHistoryAction() { + self::returnJson(array_values( + array_filter( + (new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns), + fn($e) => $e['key'] !== 'positions' + ) + )); } protected function getArticleAddressPriceAction() { - $articleId = $this->request->articleId; - $addressId = $this->request->addressId; + empty($this->request->articleId) && $this->sendError('Keine Artikel ID gefunden'); + empty($this->request->addressId) && $this->sendError('Keine Adress ID gefunden'); - if (strlen($articleId) < 1) { - http_response_code(500); - self::returnJson(['success' => false, 'message' => 'Keine Artikel ID gefunden']); - } + // TODO: we use verkauf as default now, later we will need to use from address + $priceTypes = WarehouseArticlePriceTypeModel::getAll(['title' => 'Verkauf']); + if (empty($priceTypes)) $this->sendError('Keine Preiskategorie gefunden'); + $priceType = $priceTypes[0]->title; - if (strlen($addressId) < 1) { - http_response_code(500); - self::returnJson(['success' => false, 'message' => 'Keine Adress ID gefunden']); - } + $article = WarehouseArticleModel::get($this->request->articleId); + $prices = json_decode($article->cheapestSellPrice, true); - //TODO: implement a select to select price category for each address - // for now we default with price with name "Verkauf" - $prices = WarehouseArticlePriceTypeModel::getAll(['title' => 'Verkauf']); - // if array is empty we return an error - if (empty($prices)) { - http_response_code(500); - self::returnJson(['success' => false, 'message' => 'Keine Preiskategorie gefunden']); - } - $priceType = $prices[0]->title; + foreach ($prices as $price) + if ($price['title'] === $priceType) + self::returnJson(['success' => true, 'price' => $price['price']]); - $article = WarehouseArticleModel::get($articleId); - $sellPrices = json_decode($article->cheapestSellPrice, true); - $sellPrice = array_search($priceType, array_column($sellPrices, 'title')); - if (empty($sellPrice)) { - http_response_code(500); - self::returnJson(['success' => false, 'message' => 'Kein Preis gefunden']); - } - - self::returnJson(['success' => true, 'price' => $sellPrices[$sellPrice]['price']]); - } - - protected function getDeliveryAddressesAction() { - $billingAddressId = $this->request->billingAddressId; - if (strlen($billingAddressId) < 1) { - http_response_code(500); - self::returnJson(['success' => false, 'message' => 'Keine Rechnungsadresse gefunden']); - } - - $deliveryAddresses = WarehouseShippingNoteModel::getAll(['billingAddressId' => $billingAddressId]); - // TODO: maybe this should be improved as it is kinda hacky - $result = []; - foreach ($deliveryAddresses as $deliveryAddress) { - $found = false; - foreach ($result as $r) { - if ($r->deliveryAddressName == $deliveryAddress->deliveryAddressName && $r->deliveryAddressLine == $deliveryAddress->deliveryAddressLine) { - $found = true; - break; - } - } - if ($found) { - continue; - } - $result[] = $deliveryAddress; - } - - self::returnJson($result); - } - - protected function getAllTextElementsAction() { - $textElements = WarehouseShippingNoteTextElementModel::getAll(); - self::returnJson($textElements); + $this->sendError('Kein Preis gefunden'); } protected function signAction() { $id = $this->request->id; - if (strlen($id) < 1) { - http_response_code(500); - self::returnJson(['success' => false, 'message' => 'Lieferschein wurde nicht gefunden']); - } - $shippingNote = WarehouseShippingNoteModel::get($id); + if (empty($id) || !$shippingNote = (array) WarehouseShippingNoteModel::get($id)) $this->sendError('Lieferschein nicht gefunden'); + if ($shippingNote["signature"] || $shippingNote["signatureName"]) $this->sendError('Bereits unterschrieben'); - if ($shippingNote->signature || $shippingNote->signatureName) { - http_response_code(500); - self::returnJson(['success' => false, 'message' => 'Lieferschein wurde bereits unterschrieben']); - } $post = json_decode(file_get_contents('php://input'), true); - $shippingNote = (array) $shippingNote; - $shippingNote['signature'] = $post['signature']; - $shippingNote['signatureName'] = $post['signatureName']; - - if (strlen($shippingNote['signature']) < 1 || strlen($shippingNote['signatureName']) < 1) { - http_response_code(500); - self::returnJson(['success' => false, 'message' => 'Unterschrift oder Name fehlt']); + if (empty($post['signature']) || empty($post['signatureName'])) { + $this->sendError('Unterschrift/Name fehlt'); } try { - $shippingNote['signatureDate'] = date("Y-m-d"); - WarehouseShippingNoteModel::update($shippingNote); - self::returnJson(['success' => true, 'message' => 'Unterschrift wurde gespeichert']); + WarehouseShippingNoteModel::update(array_merge($shippingNote, ['signature' => $post['signature'], 'signatureName' => $post['signatureName'], 'signatureDate' => date('Y-m-d')])); + self::returnJson(['success' => true, 'message' => 'Unterschrift gespeichert']); } catch (Exception $e) { - http_response_code(500); - self::returnJson(['success' => false, 'message' => 'Unterschrift konnte nicht gespeichert werden']); + $this->sendError('Speichern fehlgeschlagen'); } } + private function sendSuccess(string $message) { + self::returnJson(['success' => true, 'message' => $message]); + } + public function createPDFAction($returnFilename = false, $idOverride = null) { $id = $idOverride ?? $this->request->id; if (strlen($id) < 1) { @@ -231,7 +156,7 @@ class WarehouseShippingNoteController extends TTCrud { } $shippingNote = WarehouseShippingNoteModel::get($id); - $address = AddressModel::getOne($shippingNote->billingAddressId); + if (!empty($shippingNote->billingAddressId)) $address = AddressModel::getOne($shippingNote->billingAddressId); $positions = []; // loop through all positions and add articleTitle and articleDescription to each position entry @@ -265,6 +190,7 @@ class WarehouseShippingNoteController extends TTCrud { } } + // json decode hoursEntries and add to positions $hoursEntries = json_decode($shippingNote->hoursEntries, true); foreach ($hoursEntries as $hoursEntry) { @@ -274,31 +200,64 @@ class WarehouseShippingNoteController extends TTCrud { $articleTitle = "Arbeitsstunden (50% Zuschlag)"; } elseif (isset($hoursEntry['priceType']) && $hoursEntry['priceType'] == 100) { $articleTitle = "Arbeitsstunden (100% Zuschlag)"; + } elseif (isset($hoursEntry['hourType']) && $hoursEntry['hourType'] == '50') { + $articleTitle = "Arbeitsstunden (+50% Zuschlag)"; + } elseif (isset($hoursEntry['hourType']) && $hoursEntry['hourType'] == '100') { + $articleTitle = "Arbeitsstunden (+100% Zuschlag)"; + } elseif (isset($hoursEntry['hourType']) && $hoursEntry['hourType'] == 'regie') { + $articleTitle = "Arbeitsstunden (Regie)"; } if (floatval(str_replace(",", ".", $hoursEntry['hourCount'])) > 0) { + $userText = ''; + if (!empty($hoursEntry['userId'])) { + $user = UserModel::getOne($hoursEntry['userId']); + $userText = $user->name; + } else { + $userText = $hoursEntry['userId_text']; + } + + $articleDescription = "Datum: ". date("d.m.Y", strtotime($hoursEntry['date'])) . " | Mitarbeiter: " . $userText; + if (isset($hoursEntry['comment'])) { + $articleDescription .= " | Zusatz: " . $hoursEntry['comment']; + } + $positions[] = [ 'articleTitle' => $articleTitle, - 'articleDescription' => "Datum: ". date("d.m.Y", strtotime($hoursEntry['date'])) . " | Mitarbeiter: " . UserModel::getOne($hoursEntry['userId'])->name, + 'articleDescription' => $articleDescription, 'articleUnit' => 'Std.', 'amount' => $hoursEntry['hourCount'], - 'price' => $hoursEntry['hourlyPrice'] * $hoursEntry['hourCount'] ?? 0, ]; } - if ($hoursEntry['carId']) { + if (!empty($hoursEntry['externalCar']) && !empty($hoursEntry['kilometerCount']) && $hoursEntry['kilometerCount'] > 0 && $hoursEntry['externalCar'] == 1) { + $positions[] = [ + 'articleTitle' => "Fahrkostenpauschale (hin und retour)", + 'articleDescription' => "Datum: ". date("d.m.Y", strtotime($hoursEntry['date'])) . " | Externes Fahrzeug", + 'articleUnit' => 'km', + 'amount' => $hoursEntry['kilometerCount'] * 2, + 'price' => 2 * $hoursEntry['kilometerCount'] ?? 0, + ]; + } else if (!empty($hoursEntry['carId']) && $hoursEntry['kilometerCount'] > 0) { + $hoursEntry['carId'] = intval($hoursEntry['carId']); $positions[] = [ 'articleTitle' => "Fahrkostenpauschale (hin und retour)", 'articleDescription' => "Datum: ". date("d.m.Y", strtotime($hoursEntry['date'])) . " | Fahrzeug: " . TimerecordingCarModel::getOne($hoursEntry['carId'])->number_plate, - 'articleUnit' => 'Km', + 'articleUnit' => 'km', + 'amount' => $hoursEntry['kilometerCount'] * 2, + 'price' => 2 * $hoursEntry['kilometerCount'] ?? 0, + ]; + } else if (!empty($hoursEntry['carId_text']) && $hoursEntry['kilometerCount'] > 0) { + $positions[] = [ + 'articleTitle' => "Fahrkostenpauschale (hin und retour)", + 'articleDescription' => "Datum: ". date("d.m.Y", strtotime($hoursEntry['date'])) . " | Fahrzeug: " . $hoursEntry['carId_text'], + 'articleUnit' => 'km', 'amount' => $hoursEntry['kilometerCount'] * 2, 'price' => 2 * $hoursEntry['kilometerCount'] ?? 0, ]; } - } - usort($positions, function ($a, $b) { $aHasMitarbeiter = str_contains($a['articleDescription'], 'Mitarbeiter'); $bHasMitarbeiter = str_contains($b['articleDescription'], 'Mitarbeiter'); @@ -347,12 +306,13 @@ class WarehouseShippingNoteController extends TTCrud { $headerHtml = str_replace("{{ addressLine_3 }}", $shippingNote->deliveryAddressPLZ . " " . $shippingNote->deliveryAddressCity, $headerHtml); $headerHtml = str_replace("{{ addressLine_4 }}", "", $headerHtml); $headerHtml = str_replace("{{ addressLine_5 }}", "", $headerHtml); - $headerHtml = str_replace("{{ billingAddressLine_1 }}", $address->getCompanyOrName(), $headerHtml); - $headerHtml = str_replace("{{ billingAddressLine_2 }}", $address->street, $headerHtml); - $headerHtml = str_replace("{{ billingAddressLine_3 }}", $address->zip . " " . $address->city, $headerHtml); + $headerHtml = str_replace("{{ billingAddressHeader }}", !isset($address) ? '' : "Rechnungsadresse", $headerHtml); + $headerHtml = str_replace("{{ billingAddressLine_1 }}", !isset($address) ? '' : $address->getCompanyOrName(), $headerHtml); + $headerHtml = str_replace("{{ billingAddressLine_2 }}", !isset($address) ? '' : $address->street, $headerHtml); + $headerHtml = str_replace("{{ billingAddressLine_3 }}", !isset($address) ? '' : $address->zip . " " . $address->city, $headerHtml); $headerHtml = str_replace("{{ billingAddressLine_4 }}", "", $headerHtml); $headerHtml = str_replace("{{ billingAddressLine_5 }}", "", $headerHtml); - $headerHtml = str_replace("{{ customerNumber }}", $address->customer_number, $headerHtml); + $headerHtml = str_replace("{{ customerNumber }}", !isset($address) ? '' : $address->customer_number, $headerHtml); $headerHtml = str_replace("{{ shippingNoteNumber }}", $shippingNoteNumber, $headerHtml); $headerHtml = str_replace("{{ shippingNoteDate }}", date("d.m.Y", $shippingNote->create), $headerHtml); @@ -434,6 +394,8 @@ class WarehouseShippingNoteController extends TTCrud { 'in_progress' => 'In Bearbeitung', 'accepted' => 'Akzeptiert', 'invoiced' => 'In Rechnung gestellt', + 'cancelled' => 'Storniert', + 'on_hold' => 'In Wartestellung', ]; self::returnJson(['success' => true, 'message' => 'Status wurde auf ' . $statusNiceText[$status] . ' geändert']); } @@ -465,9 +427,8 @@ class WarehouseShippingNoteController extends TTCrud { protected function timerecordingCarForUserAction() { $timerecordingCars = TimerecordingCarModel::getAll(); - $out = null; foreach ($timerecordingCars as $timerecordingCar) { - if ($timerecordingCar->user_id == $this->user->id) { + if ($timerecordingCar->user_id == $this->request->userId) { header('Content-Type: application/json'); die(json_encode(['success' => true, 'id' => $timerecordingCar->id])); } @@ -502,7 +463,7 @@ class WarehouseShippingNoteController extends TTCrud { $lon = $this->request->lon; $url = "https://nominatim.haid.in/reverse?lat=$lat&lon=$lon&format=json"; $data = json_decode(file_get_contents($url), true); - self::returnJson($data); + self::returnJson(is_array($data) ? $data : ['data' => $data]); } @@ -587,4 +548,51 @@ class WarehouseShippingNoteController extends TTCrud { self::returnJson(['success' => true, 'distance' => $roundedDistanceKm]); } + protected function uploadFileAction() { + $file = $_FILES['file'] ?? null; + + if (!$file || $file['error'] !== UPLOAD_ERR_OK) { + self::returnJson(['error' => 'File upload failed']); + exit; + } + + try { + $uploaded = mfUpload::handleFormUpload("file", false, "/WarehouseShippingNote"); + self::returnJson(['success' => true, 'fileId' => $uploaded->id]); + } catch (Exception $e) { + self::returnJson(['error' => 'Upload error: ' . $e->getMessage()]); + } + } + + protected function createNewLogAction() { + $postData = json_decode(file_get_contents('php://input'), true); + + if (empty($postData['shippingNoteId'])) { + self::returnJson(['success' => false, 'message' => 'ShippingNoteId fehlt']); + return; + } + + $log = [ + "table" => "WarehouseShippingNote", + "rowId" => intval($postData['shippingNoteId']), + "type" => 'noChanges', + "fileIds" => $postData['fileIds'] ?? null, + "message" => $postData['note'] ?? null, + "createBy" => intval($this->user->id), + "create" => time() + ]; + WarehouseLogModel::create($log); + self::returnJson(['success' => true, 'message' => 'Logeintrag erfolgreich erstellt']); + } + + protected function getLogAction() { + $shippingNoteId = $this->request->shippingNoteId; + if (empty($shippingNoteId)) { + self::returnJson(['success' => false, 'message' => 'ShippingNoteId fehlt']); + return; + } + + $logs = WarehouseLogModel::getAll(['table' => 'WarehouseShippingNote','rowId' => $shippingNoteId], null, 0, ['order' => 'DESC', 'key' => 'create']); + self::returnJson($logs); + } } diff --git a/application/WarehouseShippingNote/WarehouseShippingNoteModel.php b/application/WarehouseShippingNote/WarehouseShippingNoteModel.php index 59aed4ea2..5817569c2 100644 --- a/application/WarehouseShippingNote/WarehouseShippingNoteModel.php +++ b/application/WarehouseShippingNote/WarehouseShippingNoteModel.php @@ -2,7 +2,7 @@ class WarehouseShippingNoteModel extends TTCrudBaseModel { public int $id; - public int $billingAddressId; + public ?int $billingAddressId; public string $deliveryAddressName; public string $deliveryAddressLine; public string $deliveryAddressPLZ; diff --git a/db/migrations/20250320130000_warehouse_modify_15.php b/db/migrations/20250320130000_warehouse_modify_15.php new file mode 100644 index 000000000..e656b9ff2 --- /dev/null +++ b/db/migrations/20250320130000_warehouse_modify_15.php @@ -0,0 +1,25 @@ +getEnvironment() == "thetool") { + $WarehouseShippingNoteTable = $this->table("WarehouseShippingNote"); + $WarehouseShippingNoteTable + ->changeColumn("billingAddressId", "integer", ["null" => true]) + ->changeColumn("status", "enum", ["values" => ['new', 'in_progress', 'accepted', 'invoiced', 'cancelled', 'on_hold']]) + ->save(); + } + } + + public function down(): void { + if ($this->getEnvironment() == "thetool") { + $WarehouseShippingNoteTable = $this->table("WarehouseShippingNote"); + $WarehouseShippingNoteTable + ->changeColumn("billingAddressId", "integer", ["null" => false]) + ->changeColumn("status", "enum", ["values" => ['new', 'in_progress', 'accepted', 'invoiced']]) + ->save(); + } + } +} diff --git a/lib/TTCrudBaseModel/TTCrudBaseModel.php b/lib/TTCrudBaseModel/TTCrudBaseModel.php index 72647faf7..d336a25d6 100644 --- a/lib/TTCrudBaseModel/TTCrudBaseModel.php +++ b/lib/TTCrudBaseModel/TTCrudBaseModel.php @@ -29,6 +29,10 @@ class TTCrudBaseModel { if (is_array($value)) { $value = json_encode($value); + } else if ($value === "" && (new ReflectionProperty(get_called_class(), $field))->getType()->getName() === "float") { + $value = null; + } else if ($value === "" && (new ReflectionProperty(get_called_class(), $field))->getType()->getName() === "int") { + $value = null; } $sqlValues[] = $value === null ? 'NULL' : "'" . $db->real_escape_string($value) . "'"; diff --git a/lib/mvcfronk/mfBase/mfBaseController.php b/lib/mvcfronk/mfBase/mfBaseController.php index 775bfb910..05c8322dd 100644 --- a/lib/mvcfronk/mfBase/mfBaseController.php +++ b/lib/mvcfronk/mfBase/mfBaseController.php @@ -370,4 +370,10 @@ class mfBaseController return $dbdate; } + public static function sendError(string $message): void { + http_response_code(422); // More appropriate status for validation errors + self::returnJson(['success' => false, 'message' => $message]); + exit; + } + } diff --git a/lib/mvcfronk/mfUpload/mfUpload_TmpFile.php b/lib/mvcfronk/mfUpload/mfUpload_TmpFile.php index 9fb29c407..f2cdfca90 100644 --- a/lib/mvcfronk/mfUpload/mfUpload_TmpFile.php +++ b/lib/mvcfronk/mfUpload/mfUpload_TmpFile.php @@ -118,6 +118,7 @@ class mfUpload_TmpFile { } public function pdftotext() { + if (!isset($cmd)) $cmd = ""; $cmd .= PDFTOTEXT_BIN_PATH . " " . $this->tmp_name . " -"; $lines = []; diff --git a/public/js/pages/WarehouseShippingNote/WarehouseShippingNote.css b/public/js/pages/WarehouseShippingNote/WarehouseShippingNote.css index be92707e7..3c020b08c 100644 --- a/public/js/pages/WarehouseShippingNote/WarehouseShippingNote.css +++ b/public/js/pages/WarehouseShippingNote/WarehouseShippingNote.css @@ -1,62 +1,11 @@ -.warehouse-shipping-note-modal-positions-entry-container { - display: grid; - grid-template-columns: 2fr 1fr 0.5fr 1fr 1fr; - grid-gap: 10px; -} - -.warehouse-shipping-note-modal-positions-entry-container.hidePrice { - grid-template-columns: 2fr 1fr 0.5fr 1fr; -} - -.warehouse-shipping-note-modal-positions-entry-actions, .warehouse-shipping-note-modal-hours-entry-actions { - display: flex; - flex-direction: column; - justify-content: center; - padding-top: 13px; -} - -.warehouse-shipping-note-modal-hours-entry-container { - display: grid; - grid-template-columns: 2fr 1fr 1fr 1fr 2fr 1fr 1fr 1fr; - grid-gap: 10px; -} - -.warehouse-shipping-note-modal-hours-entry-container.hideHourlyPrice { - grid-template-columns: 2fr 1fr 1fr 1fr 2fr 1fr 1fr; -} - -.warehouse-shipping-note-modal-hours-entry-container.hideHourlyPrice.hideKilometer { - grid-template-columns: 2fr 1fr 1fr 1fr 2fr 1fr; -} - @media (min-width: 992px) { .modal-lg, .modal-xl { /*max width either 90% or 1120px*/ - max-width: min(90vw, 1120px) !important; + max-width: min(90vw, 1400px) !important; } } @media (max-width: 992px) { - .warehouse-shipping-note-modal-positions-entry-container, - .warehouse-shipping-note-modal-hours-entry-container{ - display: grid; - grid-template-columns: 1fr 1fr !important; - grid-gap: 10px; - } - - .warehouse-shipping-note-modal-positions-entry-actions { - grid-column: 2; - } - - .warehouse-shipping-note-modal-hours-entry-actions { - grid-column: 1 / span 2; - margin-bottom: 8px; - } - - .modal .table.table-striped.table-sm button{ - margin-bottom: 4px; - } - .signModal > div { margin: 0; width: 100vw; @@ -79,4 +28,159 @@ display: none; } -} \ No newline at end of file +} + +.grid-container { + display: grid; + grid-template-columns: 2fr 0.5fr 0.5fr 1fr 2fr 0.5fr; + grid-gap: 10px; +} +.grid-container.header { + margin-top: 24px; +} + +.upload-success-alert { + background-color: #d4edda; + border: 1px solid #c3e6cb; + border-radius: 8px; + padding: 15px; + margin-bottom: 20px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); +} + +.alert-header { + display: flex; + align-items: center; + margin-bottom: 10px; + font-size: 18px; + color: #155724; +} + +.alert-header i { + margin-right: 10px; + font-size: 24px; +} + +.file-list { + list-style-type: none; + padding: 0; + margin: 0; +} + +.file-item { + display: flex; + align-items: center; + background-color: #ffffff; + border-radius: 4px; + padding: 10px; + margin-bottom: 8px; + transition: background-color 0.3s ease; +} + +.file-item:hover { + background-color: #f8f9fa; +} + +.file-item i { + color: #000000; +} + +.file-name { + margin-left: 10px; + flex-grow: 1; + font-size: 14px; +} + +.remove-btn { + background-color: #dc3545; + color: #ffffff; + border: none; + border-radius: 4px; + padding: 5px 10px; + cursor: pointer; + transition: background-color 0.3s ease; +} + +.remove-btn:hover { + background-color: #c82333; +} + + + + +/* Expanded Row Styling */ +.order-summary { + padding: 1rem; +} +.position-item { + margin-bottom: 1rem; + border: 1px solid #ddd; + border-radius: 4px; +} +.position-header { + background-color: #f0f0f0; + padding: 0.5rem; + font-weight: bold; +} +.position-details { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr; + padding: 0.5rem; +} +.field-item { + margin-bottom: 0.5rem; +} + + +.ios-switch-wrapper { + position: relative; + display: inline-block; + width: 50px; + height: 28px; +} + +.ios-switch-wrapper.disabled { + opacity: 0.6; +} + +.ios-switch-wrapper input { + opacity: 0; + width: 0; + height: 0; +} + +.ios-switch-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + transition: .4s; + border-radius: 34px; +} + +.ios-switch-slider:before { + position: absolute; + content: ""; + height: 20px; + width: 20px; + left: 4px; + bottom: 4px; + background-color: white; + transition: .4s; + border-radius: 50%; +} + +input:checked + .ios-switch-slider { + background-color: #4cd964; +} + +input:checked + .ios-switch-slider:before { + transform: translateX(22px); +} + +input:disabled + .ios-switch-slider { + cursor: not-allowed; +} diff --git a/public/js/pages/WarehouseShippingNote/WarehouseShippingNote.js b/public/js/pages/WarehouseShippingNote/WarehouseShippingNote.js index e4e7c81b4..dcdd9fa3e 100644 --- a/public/js/pages/WarehouseShippingNote/WarehouseShippingNote.js +++ b/public/js/pages/WarehouseShippingNote/WarehouseShippingNote.js @@ -1,43 +1,49 @@ -// here we need to change window.TT_CONFIG["CRUD_CONFIG"]["additionalActions"] to add actions and this conditions like this: -// -// -// -// - -// inside additionalActions each entry looks like { -// "key": "openHistory", -// "title": "Historie", -// "class": "fas fa-history text-primary" -// } but this is without the condition function - window.TT_CONFIG["CRUD_CONFIG"]["additionalActions"] = [ - ...window.TT_CONFIG["CRUD_CONFIG"]["additionalActions"], { "key": "status_to_progress", "title": "In Bearbeitung", "class": "fas fa-cog text-warning", - "condition": (row) => window.TT_CONFIG['WAREHOUSE_ADMIN'] === '1' && row.status === 'new', + "condition": (row) => window.TT_CONFIG['WAREHOUSE_ADMIN'] === '1' && ['new', 'on_hold'].includes(row.status), }, { "key": "status_to_accepted", "title": "Akzeptieren", "class": "fas fa-check text-success", - "condition": (row) => window.TT_CONFIG['WAREHOUSE_ADMIN'] === '1' && (row.status === 'new' || row.status === 'in_progress'), + "condition": (row) => window.TT_CONFIG['WAREHOUSE_ADMIN'] === '1' && ['new', 'in_progress', 'on_hold'].includes(row.status), }, { "key": "status_to_invoiced", "title": "Verrechnet", "class": "fas fa-file-invoice text-info", - "condition": (row) => window.TT_CONFIG['WAREHOUSE_ADMIN'] === '1' && row.status === 'accepted', + "condition": (row) => window.TT_CONFIG['WAREHOUSE_ADMIN'] === '1' && ['in_progress', 'accepted', 'on_hold'].includes(row.status), }, + { + "key": "status_to_on_hold", + "title": "On Hold", + "class": "fas fa-pause text-warning", + "condition": (row) => window.TT_CONFIG['WAREHOUSE_ADMIN'] === '1' && ['accepted', 'new', 'in_progress'].includes(row.status), + }, + { + "key": "status_to_cancelled", + "title": "Storniert", + "class": "fas fa-ban text-danger", + "condition": (row) => (window.TT_CONFIG['WAREHOUSE_ADMIN'] === '1' && ['new', 'in_progress', 'accepted'].includes(row.status)) || (row.status === 'new' && row.signature === null), + }, + { + "key": "add_log", + "title": "Log Eintrag hinzufügen", + "class": "fas fa-plus text-primary", + }, + { + "key": "print", + "title": "Drucken", + "class": "fas fa-print text-primary", + } ] +// normal regie 50% 100% + +window.TT_CONFIG["CRUD_CONFIG"]["editCondition"] = (row) => row.status === 'new' && row.signature === null || window.TT_CONFIG['WAREHOUSE_ADMIN'] === '1'; Vue.component('warehouse-shipping-note-positions', { //language=Vue @@ -87,6 +93,165 @@ Vue.component('warehouse-shipping-note-positions', { } }) +Vue.component('add-log-modal-sn', { + props: { + shippingNoteId: {type: Number, required: true}, + }, + data() { + return { + window: window, + note: '', + file: null, + uploadedFiles: [], + submitLoading: false + }; + }, + methods: { + async handleFileUpload(event) { + const files = event.target.files; + if (!files.length) return; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const formData = new FormData(); + formData.append('file', file); + + try { + const response = await axios.post(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseShippingNote/uploadFile`, formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + + if (response.data.success) { + this.uploadedFiles.push({ + id: response.data.fileId, + name: file.name + }); + window.notify('success', `File "${file.name}" uploaded successfully`); + } else { + window.notify('error', `File "${file.name}" upload failed: ${response.data.error || 'Unknown error'}`); + } + } catch (error) { + window.notify('error', `Error uploading file "${file.name}"`); + } + } + + // Clear the file input + event.target.value = ''; + }, + removeFile: index => this.uploadedFiles.splice(index, 1), + async submit() { + this.submitLoading = true; + + const fileIds = this.uploadedFiles.map(file => file.id); + const response = await axios.post(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseShippingNote/createNewLogAction`, { + shippingNoteId: this.shippingNoteId, + note: this.note, + fileIds: JSON.stringify(fileIds), + }); + + if (response.data.success) { + this.$emit('close'); + window.notify('success', response.data.message ?? 'Log Eintrag erfolgreich erstellt'); + } else { + window.notify('error', + response.data.errors ? Object.values(response.data.errors).join('
') : response.data.message || 'Ein Fehler ist aufgetreten'); + } + this.submitLoading = false; + }, + }, + template: ` + + + + ` +}) + +Vue.component('tt-file', { + props: ['id'], + data: () => ({file: null}), + async mounted() { + const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/File/getById`, {params: {id: this.id}}); + this.file = response.data; + }, + template: ` +
+ {{ file.filename }} + +
+ ` +}) + + +Vue.component('warehouse-shipping-note-logs', { + props: { + shippingNoteId: {type: Number, required: true}, + }, + data() { + return { + logs: [], loading: false, + }; + }, + //language=Vue + template: ` +
+
+ +
+ +
+
+ {{ formatDate(log.create) }} ({{ getUserName(log.createBy) }}) | {{ log.message }} + +
+
+
+ `, + async mounted() { + this.loading = true; + const response = await axios.get(window.TT_CONFIG.BASE_PATH + '/WarehouseShippingNote/getLog', {params: {shippingNoteId: this.shippingNoteId}}); + this.logs = response.data; + this.loading = false; + }, + methods: { + formatDate: date => window.moment(date * 1000).format('DD.MM.YYYY HH:mm'), + getUserName: id => window.TT_CONFIG.CRUD_CONFIG.columns.find(col => col.key === 'createBy')?.modal.items.find(u => u.value === id)?.text + } +}) + // noinspection JSUnusedLocalSymbols Vue.component('warehouse-shipping-note', { //language=Vue @@ -98,21 +263,25 @@ Vue.component('warehouse-shipping-note', { + @@ -123,7 +292,8 @@ Vue.component('warehouse-shipping-note', { historyModal: false, historyModalId: null, shippingNoteModalId: null, - signingShippingNoteId: null + signingShippingNoteId: null, + addLogModalId: null } }, mounted() { diff --git a/public/js/pages/WarehouseShippingNote/WarehouseShippingNoteModal.js b/public/js/pages/WarehouseShippingNote/WarehouseShippingNoteModal.js index 42d264e95..f96ab9554 100644 --- a/public/js/pages/WarehouseShippingNote/WarehouseShippingNoteModal.js +++ b/public/js/pages/WarehouseShippingNote/WarehouseShippingNoteModal.js @@ -1,790 +1,184 @@ -Vue.component('warehouse-shipping-note-modal-text-elements', { - props: { - textElements: Array - }, - data() { - return { - window: window, - textElementsData: [], - } - }, - //language=Vue - template: ` -
- - -
- `, - async mounted() { - const response = await axios.get(window.TT_CONFIG["BASE_PATH"] + '/WarehouseShippingNote/getAllTextElements'); - this.textElementsData = response.data; - } -}) - -// TODO: maybe also think about creating a component for simple forms like this -Vue.component('warehouse-shipping-note-modal-hours-entry', { - props: { - index: {type: [Number], required: false, default: null}, - showHourlyPrice: {type: Boolean, default: false}, - }, - data() { - return { - window: window, - userApiUrl: window.TT_CONFIG['BASE_PATH'] + '/WarehouseShippingNote/userAutoComplete', - carApiUrl: window.TT_CONFIG['BASE_PATH'] + '/WarehouseShippingNote/timerecordingCarAutoComplete', - userId: '', - carId: '', - date: '', - hourCount: '', - kilometerCount: '', - hourlyPrice: '', - priceType: 'normal', - } - }, - //language=Vue - template: ` -
- - - - - - - -
- -
-
- `, - methods: { - async createOrUpdate() { - if (!this.userId || !this.date || !this.hourCount) { - this.window.notify('error', 'Bitte füllen Sie alle Felder aus'); - return; - } - - this.$emit(this.index === null ? 'create' : 'update', { - userId: this.userId, - date: this.date, - hourCount: this.hourCount, - priceType: this.priceType, - hourlyPrice: this.hourlyPrice || null, - carId: this.carId ? this.carId : null, - kilometerCount: this.carId ? this.kilometerCount : null - }); - // TODO: maybe make this cleaner - Object.assign(this.$data, this.$options.data.apply(this)) - await this.$nextTick(); - this.userId = this.window.TT_CONFIG['USER_ID'] - this.updateDate(); - this.updateKilometerCount().then(); - this.updateCarId().then(); - }, - async updateKilometerCount() { - if (!this.carId) { - this.kilometerCount = ''; - return; - } - const delAddr = this.$parent.$parent.$parent.delAddrLine + - ' ' + - this.$parent.$parent.$parent.delAddrCity + - ' ' + - this.$parent.$parent.$parent.delAddrPLZ; - const response = await axios.get(window.TT_CONFIG["BASE_PATH"] + '/WarehouseShippingNote/getDistance?from=Xinon%20GmbH&to=' + delAddr); - this.kilometerCount = response.data.distance - }, - async updateCarId() { - if (!this.userId || this.carId) return; - const response = await axios.get(window.TT_CONFIG["BASE_PATH"] + '/WarehouseShippingNote/timerecordingCarForUser?userId=' + this.userId); - if (response.data.status === 'USER_NO_CAR') { - // this.window.notify('info', 'Kein zugewiesenes Fahrzeug gefunden'); - this.carId = ''; - return; - } - this.carId = response.data.id; - }, - updateDate() { - if (!this.date) { - const today = new Date(); - const dd = String(today.getDate()).padStart(2, '0'); - const mm = String(today.getMonth() + 1).padStart(2, '0'); - const yyyy = today.getFullYear(); - this.date = `${yyyy}-${mm}-${dd}`; - } - } - }, - async mounted() { - if (!this.userId) this.userId = this.window.TT_CONFIG['USER_ID']; - if (!this.carId) this.updateCarId().then(); - if (!this.date) this.updateDate(); - if (!this.kilometerCount) this.updateKilometerCount().then(); - - this.$parent.$parent.$parent.$watch('delAddrLine', this.updateKilometerCount); - this.$watch('carId', this.updateKilometerCount); - - } -}) - -// TODO: we should create this to a tt-simple-table component -Vue.component('warehouse-shipping-note-modal-hours-view', { - props: { - hoursEntries: {type: Array, required: true}, - showHourlyPrice: {type: Boolean, default: false}, - }, - data() { - return { - window: window, - userNames: {} - } - }, - //language=Vue - template: ` -
-
Position
- - - - - - - - - - - - - - - - - - - - - - - -
MitarbeiterDatumSTKMStundenlohnAktionen
Keine Einträge
{{ userNames[entry.userId] }}{{ window.moment(entry.date).format('DD.MM.YYYY') }}{{ entry.hourCount }}{{ entry.kilometerCount }}{{ entry.hourlyPrice }} - - -
- - `, - // add a method and a watcher to fetch the user names - methods: { - async fetchUserNames() { - for (const entry of this.hoursEntries) { - if (!entry.userId) continue; - if (entry.userId in this.userNames) continue; - - const response = await axios.get(window.TT_CONFIG["BASE_PATH"] + '/WarehouseShippingNote/userAutoComplete?searchedID=' + entry.userId); - this.$set(this.userNames, entry.userId, response.data[0].text); - } - } - }, - watch: { - hoursEntries: { - handler: 'fetchUserNames', immediate: true - } - }, -}) - -// this component will combine the above 2 components and show the entries and the input fields -Vue.component('warehouse-shipping-note-modal-hours', { - props: { - hoursEntries: {type: Array, required: true}, - showHourlyPrice: {type: Boolean, default: false}, - }, - data() { - return { - window: window, - selectedUpdateIndex: null, - } - }, - //language=Vue - template: ` -
- - -
- `, - methods: { - create(entry) { - this.$emit('update:hoursEntries', [...this.hoursEntries, entry]); - this.window.notify('success', 'Eintrag erstellt'); - }, - update(entry) { - this.$emit('update:hoursEntries', this.hoursEntries.map((oldEntry, index) => index === this.selectedUpdateIndex ? entry : oldEntry)); - this.window.notify('success', 'Eintrag aktualisiert'); - this.selectedUpdateIndex = null; - }, - deleteEntry(entry) { - this.$emit('update:hoursEntries', this.hoursEntries.filter(oldEntry => oldEntry !== entry)); - this.window.notify('success', 'Eintrag gelöscht'); - }, - editEntry(entry) { - this.selectedUpdateIndex = this.hoursEntries.indexOf(entry); - this.$refs.entry.userId = entry.userId; - this.$refs.entry.date = entry.date; - this.$refs.entry.hourCount = entry.hourCount; - this.$refs.entry.note = entry.note; - this.$refs.entry.hourlyPrice = entry.hourlyPrice; - } - } -}) - -// now we need the same as above for positions -// so we need warehouse-shipping-note-modal-positions-entry, warehouse-shipping-note-modal-positions-view and warehouse-shipping-note-modal-positions -// positions have a article or article packet, amount and price -// when a article or article packet is selected we should fetch the name and description -// then fetch the default price for the address -Vue.component('warehouse-shipping-note-modal-positions-entry', { - props: { - index: {type: [Number], required: false, default: null}, - billAddrId: {type: [String, Number], required: true}, - }, - data() { - return { - window: window, - isAdmin: false, - articleApiUrl: window.TT_CONFIG['BASE_PATH'] + '/WarehouseArticle/autoComplete', - articlePacketApiUrl: window.TT_CONFIG['BASE_PATH'] + '/WarehouseArticlePacket/autoComplete', - articleId: '', - articlePacketId: '', - amount: '', - price: '', - isEnergieMaterial: false, - } - }, - //language=Vue - template: ` -
- - - - - -
- -
-
- `, - methods: { - // TODO: if articlePacket is needed we need to implement this - async createOrUpdate() { - if (!this.amount) return this.window.notify('error', 'Bitte füllen sie die Menge aus'); - const data = { - amount: this.amount, - isEnergieMaterial: this.isEnergieMaterial, - price: parseFloat(this.price) ?? '' - } - if (isNaN(data.price)) data.price = ''; - if (!this.articleId && this.$refs.article.displayValue) { - data.articleText = this.$refs.article.displayValue; - } else if (this.articleId) { - data.article = this.articleId; - } else { - return this.window.notify('error', 'Bitte wählen Sie einen Artikel aus'); - } - - this.$emit(this.index === null ? 'create' : 'update', data); - Object.assign(this.$data, this.$options.data.apply(this)) - }, - async fetchPrice() { - if (!this.articleId && !this.articlePacketId || !this.billAddrId) return; - - const url = `${window.TT_CONFIG["BASE_PATH"]}/WarehouseShippingNote/getArticleAddressPrice?articleId=${this.articleId || - this.articlePacketId}&addressId=${this.billAddrId}`; - const response = await axios.get(url); - this.price = response.data.price; - } - }, - watch: { - articleId: {handler: 'fetchPrice', immediate: false}, - articlePacketId: {handler: 'fetchPrice', immediate: false}, - billAddrId: {handler: 'fetchPrice', immediate: false}, - }, -}) - -// here will warehouse-shipping-note-modal-positions-view show the positions in a table -Vue.component('warehouse-shipping-note-modal-positions-view', { - props: { - positions: {type: Array, required: true}, - }, - data() { - return { - window: window, - isAdmin: false, - articleNames: {}, - articlePacketNames: {}, - } - }, - //language=Vue - template: ` -
- - - - - - - - - - - - - - - - - - - - - - -
ArtikelMengeEnergie MaterialPreisAktionen
Keine Einträge
{{ position.article ? articleNames[position.article] : position.articlePacket ? articlePacketNames[position.articlePacket] : - position.articleText }} - {{ position.amount }}{{ position?.isEnergieMaterial ? 'Ja' : 'Nein' }}{{ (position.price?.toFixed(2)) }} € - - -
-
- `, - methods: { - async fetchNames() { - // TODO: there must be a better way to do this - for (const position of this.positions) { - if (position.article) this.$set(this.articleNames, position.article, 'Loading...'); - if (position.articlePacket) this.$set(this.articlePacketNames, position.articlePacket, 'Loading...'); - } - - const articlePromises = this.positions.filter(position => position.article) - .map(position => axios.get(window.TT_CONFIG["BASE_PATH"] + '/WarehouseArticle/autoComplete?searchedID=' + position.article)); - const articlePacketPromises = this.positions.filter(position => position.articlePacket) - .map(position => axios.get(window.TT_CONFIG["BASE_PATH"] + '/WarehouseArticlePacket/autoComplete?searchedID=' + position.articlePacket)); - - const articleResponses = await Promise.all(articlePromises); - const articlePacketResponses = await Promise.all(articlePacketPromises); - - for (const response of articleResponses) { - this.$set(this.articleNames, response.data[0].value, response.data[0].text); - } - - for (const response of articlePacketResponses) { - this.$set(this.articlePacketNames, response.data[0].value, response.data[0].text); - } - - } - }, - // watch positions and fetch article / article packet names - and initially fill them with Loading... - watch: { - positions: { - handler: 'fetchNames', immediate: true - } - } -}) - -// and here we combine the above 2 components -Vue.component('warehouse-shipping-note-modal-positions', { - props: { - positions: {type: Array, required: true}, - billAddrId: {type: [String, Number], required: true}, - }, - data() { - return { - window: window, - articleNames: {}, - articlePacketNames: {}, - selectedUpdateIndex: null, - } - }, - //language=Vue - template: ` -
- - -
- `, - methods: { - create(entry) { - this.$emit('update:positions', [...this.positions, entry]); - this.window.notify('success', 'Eintrag erstellt'); - }, - update(entry) { - this.$emit('update:positions', this.positions.map((oldEntry, index) => index === this.selectedUpdateIndex ? entry : oldEntry)); - this.window.notify('success', 'Eintrag aktualisiert'); - this.selectedUpdateIndex = null; - }, - deleteEntry(entry) { - this.$emit('update:positions', this.positions.filter(oldEntry => oldEntry !== entry)); - this.window.notify('success', 'Eintrag gelöscht'); - }, - editEntry(entry) { - this.selectedUpdateIndex = this.positions.indexOf(entry); - if (entry.article) this.$refs.entry.articleId = entry.article; - if (entry.articlePacket) this.$refs.entry.articlePacketId = entry.articlePacket; - if (entry.articleText) this.$refs.entry.$refs.article.displayValue = entry.articleText; - this.$refs.entry.amount = entry.amount; - this.$refs.entry.price = entry.price; - }, - }, -}) - - // noinspection EqualityComparisonWithCoercionJS Vue.component('warehouse-shipping-note-modal', { props: { id: {type: [String, Number], required: true}, - // available modes are ['sign', 'edit', 'accept', 'create'] - mode: {type: String, default: 'sign'} }, data() { return { window: window, + loading: false, billAddrAutoCompleteUrl: window.TT_CONFIG['BASE_PATH'] + '/Address/Api?do=findAddress&fibu_primary_account=1', - billAddrId: '', - delAddrName: '', - delAddrLine: '', - delAddrPLZ: '', - delAddrCity: '', - delAddrEMail: '', - status: '', - note: '', - textElements: [], - hoursEntries: [], - positions: [], + geoAutocompleteUrl: window.TT_CONFIG['BASE_PATH'] + '/WarehouseShippingNote/geoAutocomplete', + shippingNote: { + billingAddressId: null, + deliveryAddressName: '', + deliveryAddressEMail: '', + deliveryAddressLine: '', + deliveryAddressPLZ: '', + deliveryAddressCity: '', + status: 'new', + positions: [], + textElements: [], + hoursEntries: [], + }, + geoAddr: '', + positionsConfig: { + customOrdering: 'article', + fields: { + article: { + type: 'autocomplete', + label: 'Artikel', + apiUrl: '/WarehouseArticle/autoComplete', + customFieldReference: 'WarehouseArticle', + emitDisplayValue: true, + }, + amount: {type: 'input', label: 'Menge', inputType: 'number'}, + isEnergieMaterial: {type: 'checkbox', label: 'Energie Material'}, + }, + validateFormOptions: [ + {key: 'article', message: 'Bitte füllen Sie den Artikel aus'}, + {key: 'amount', message: 'Bitte füllen Sie die Menge aus'}, + ] + }, + hoursConfig: { + fields: { + userId: { + type: 'autocomplete', + label: 'Mitarbeiter', + apiUrl: '/WarehouseShippingNote/userAutoComplete', + emitDisplayValue: true, + }, + date: {type: 'input', label: 'Datum', inputType: 'date'}, + hourCount: {type: 'input', label: 'Stunden', inputType: 'number'}, + carId: { + type: 'autocomplete', + label: 'Auto', + apiUrl: '/WarehouseShippingNote/timerecordingCarAutoComplete', + emitDisplayValue: true, + showCondition: (formData) => { + return !isNaN(parseInt(formData.userId)) + }, + }, + externalCar: { + type: 'checkbox', + label: 'Externes Auto', + showCondition: (formData) => { + return isNaN(parseInt(formData.userId)) || !formData.userId + }, + }, + kilometerCount: {type: 'input', label: 'Kilometer', inputType: 'number'}, + hourType: {type: 'select', label: 'Stundenart', options: [ + {text: 'Normal', value: undefined}, + {text: '+50%', value: '50'}, + {text: '+100%', value: '100'}, + {text: 'Regie', value: 'regie'}, + ]}, + comment: {type: 'input', label: 'Kommentar'}, + }, + validateFormOptions: [ + {key: 'userId', message: 'Bitte füllen Sie den Mitarbeiter aus'}, + {key: 'hourCount', message: 'Bitte füllen Sie die Stunden aus'}, + ] + } + + } }, - //language=Vue template: ` - +
-

Liefer- und Rechnungsadresse

- - - - -
- - - -
- - - -
-

Stunden

- - - -
-

Positionen

- -
- -
Bitte füllen Sie die Rechnungs- und Lieferadresse aus
- +

Adresse

+ + + + +
{{ shippingNote.deliveryAddressLine }}
{{ shippingNote.deliveryAddressPLZ }} {{ shippingNote.deliveryAddressCity }}
+ + + + + + + Bitte Lieferadresse eingeben +
- +
`, - - // now we need methods for fetching the shipping note, submiting the shipping note and translate the keys as they are different in the backend async mounted() { - // fetch by /getById?id=ID - if (this.id !== 'create') { - const response = await axios.get(window.TT_CONFIG["BASE_PATH"] + '/WarehouseShippingNote/getById?id=' + this.id); - this.billAddrId = response.data.billingAddressId; - this.delAddrName = response.data.deliveryAddressName; - this.delAddrLine = response.data.deliveryAddressLine; - this.delAddrPLZ = response.data.deliveryAddressPLZ; - this.delAddrCity = response.data.deliveryAddressCity; - this.delAddrEMail = response.data.deliveryAddressEMail; - this.note = response.data.note; - this.status = response.data.status; + if (this.id === 'create') return; - for (const key of ['textElements', 'hoursEntries', 'positions']) { - try { - this[key] = JSON.parse(response.data[key]); - } catch { - this.textElements = []; - } - } - } else { - const knr = new URLSearchParams(window.location.search).get('knr'); - if (knr) { - const response = await axios.get(window.TT_CONFIG["BASE_PATH"] + '/Address/Api?do=findAddress&fibu_primary_account=1&autocomplete=1&q=' + knr); - for (const address of response.data) { - if (address.text.endsWith(`[${knr}]`)) { - this.billAddrId = address.value; - break; - } - } - } + const {data} = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseShippingNote/getById`, {params: {id: this.id}}); + this.shippingNote = {...data, positions: JSON.parse(data.positions), hoursEntries: JSON.parse(data.hoursEntries)}; + }, + watch: { + geoAddr: async function() { + const [lat, lon] = this.geoAddr.split(','); + const { address } = (await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseShippingNote/geoReverse?lat=${lat}&lon=${lon}`)).data; + + const addrKey = ['road', 'village', 'hamlet', 'residential', 'city'].find(k => address[k]); + this.shippingNote.deliveryAddressLine = addrKey ? `${address[addrKey]}${address["house_number"] ? ` ${address["house_number"]}` : ''}` : ''; + + this.shippingNote.deliveryAddressPLZ = address["postcode"]; + this.shippingNote.deliveryAddressCity = address["village"] ?? address.city ?? address["town"]; } }, - methods: { - async reqDelete() { - const response = await axios.post(window.TT_CONFIG['DELETE_URL'], {id: this.id}); - if (response.data.success) { - this.window.notify('success', response.data.message || 'Erfolgreich gelöscht'); - this.$emit('close'); - } else { - this.window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten'); - } - }, - async changeStatus(newStatus) { - const response = await axios.post(window.TT_CONFIG.BASE_PATH + '/WarehouseShippingNote/changeStatus', {id: this.id, status: newStatus}); - if (response.data.success) { - this.window.notify('success', response.data.message || 'Erfolgreich aktualisiert'); - this.status = newStatus; - this.$emit('close'); - } else { - this.window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten'); - } - }, + methods: { async submit() { - const data = { - billingAddressId: this.billAddrId, - deliveryAddressName: this.delAddrName, - deliveryAddressLine: this.delAddrLine, - deliveryAddressPLZ: this.delAddrPLZ, - deliveryAddressCity: this.delAddrCity, - deliveryAddressEMail: this.delAddrEMail, - textElements: this.textElements, - hoursEntries: this.hoursEntries, - positions: this.positions, - note: this.note, - status: this.status ? this.status : 'new' - } + this.loading = true; + if (!this.shippingNote.positions.length && !this.shippingNote.hoursEntries.length) + return window.notify('error', 'Mindestens eine Position oder eine Stundenbuchung sind erforderlich'); - if (this.id !== 'create') data.id = this.id; + const response = await axios.post(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseShippingNote/${this.id === 'create' ? 'create' : 'update'}`, this.shippingNote); - const url = this.id === 'create' ? window.TT_CONFIG['CREATE_URL'] : window.TT_CONFIG['UPDATE_URL']; - const response = await axios.post(url, data); + if ((this.id !== 'create' && response.data.success === true) || response.data.success === true) this.$emit('close'); - if (response.data.success) { - this.window.notify('success', response.data.message || 'Erfolgreich gespeichert'); - this.$emit('close'); - } else { - this.window.notify('error', - response.data.errors ? Object.values(response.data.errors).join('
') : response.data.message || 'Ein Fehler ist aufgetreten'); - } - - } - - }, - computed: { - title() { - return this.id === 'create' ? 'Lieferschein erstellen' : `Lieferschein #${this.id} bearbeiten`; + window.notify(response.data.success ? 'success' : 'error', + response.data.success + ? this.id === 'create' ? response.data.message : (response.data.message ?? 'Bestellung erfolgreich aktualisiert') + : (response.data.errors ? Object.values(response.data.errors).join('
') : response.data.message || 'Ein Fehler ist aufgetreten') + ); + this.loading = false; }, - delAddrFilled() { - if (this.id !== 'create') return true; - return !!this.delAddrName && !!this.delAddrLine && !!this.delAddrPLZ && !!this.delAddrCity; - } - } - -}) - -Vue.component('warehouse-shipping-note-modal-address', { - props: { - billAddrId: {type: [String, Number], required: true}, - delAddrName: {type: String, required: true}, - delAddrLine: {type: String, required: true}, - delAddrPLZ: {type: String, required: true}, - delAddrCity: {type: String, required: true}, - delAddrEMail: {type: String, required: true}, - }, - data() { - return { - window: window, - addressModes: [{text: 'Wie Rechnungsadresse', value: 'billing'}, - {text: 'Bestehende Lieferadresse', value: 'existing'}, - {text: 'Andere Lieferadresse', value: 'new'}], - addressMode: 'existing', - addresses: [], - fetchedBillAddr: null, - selectedAddr: '', - newAddrGeoLatLon: '', - } - }, - //language=Vue - template: ` -
- - - - - -
- `, - watch: { - billAddrId: {handler: 'updateBillingMode', immediate: false}, - addressMode: {handler: 'fetchDeliveryAddresses', immediate: false}, - selectedAddr: {handler: 'setSelectedAddrValues', immediate: false}, - newAddrGeoLatLon: {handler: 'fetchGeoAddress', immediate: false}, - }, - methods: { - async fetchGeoAddress() { - if (!this.newAddrGeoLatLon) { - this.$emit('update:delAddrLine', ''); - this.$emit('update:delAddrPLZ', ''); - this.$emit('update:delAddrCity', ''); - return; - } - const [lat, lon] = this.newAddrGeoLatLon.split(','); - const response = await axios.get(window.TT_CONFIG["BASE_PATH"] + '/WarehouseShippingNote/geoReverse?lat=' + lat + '&lon=' + lon); - - if (response.data.address.road) { - this.$emit('update:delAddrLine', - `${response.data.address.road}${response.data.address.house_number ? ' ' + response.data.address.house_number : ''}`); - } else if(response.data.address.village) { - this.$emit('update:delAddrLine', - `${response.data.address.village}${response.data.address.house_number ? ' ' + response.data.address.house_number : ''}`); - } else if(response.data.address.hamlet) { - this.$emit('update:delAddrLine', - `${response.data.address.hamlet}${response.data.address.house_number ? ' ' + response.data.address.house_number : ''}`); - } else if(response.data.address.residential) { - this.$emit('update:delAddrLine', - `${response.data.address.residential}${response.data.address.house_number ? ' ' + response.data.address.house_number : ''}`); - } else if(response.data.address.city) { - this.$emit('update:delAddrLine', - `${response.data.address.city}${response.data.address.house_number ? ' ' + response.data.address.house_number : ''}`); - } - - this.$emit('update:delAddrPLZ', response.data.address.postcode); - this.$emit('update:delAddrCity', response.data.address.village || response.data.address.city || response.data.address.town); + async updateCarId(userId) { + if (!userId) return this.$refs.hoursManager.updateField('carId', null); + const {data} = await axios.get(window.TT_CONFIG["BASE_PATH"] + '/WarehouseShippingNote/timerecordingCarForUser?userId=' + userId); + if (data.status === 'USER_NO_CAR') this.$refs.hoursManager.updateField('carId', null); + else this.$refs.hoursManager.updateField('carId', data.id); }, - async updateBillingMode() { - await this.fetchDeliveryAddresses(); - - // Here we check if the address is already in the list of addresses, if not we will set the addressMode to billing and fetch the billing address - if (this.delAddrName && this.delAddrLine && this.delAddrPLZ && this.delAddrCity) { - const foundAddress = this.addresses.find(address => - address.deliveryAddressName === this.delAddrName && - address.deliveryAddressLine === this.delAddrLine && - address.deliveryAddressPLZ === this.delAddrPLZ && - address.deliveryAddressCity === this.delAddrCity); - if (foundAddress) { - this.addressMode = 'existing'; - this.selectedAddr = foundAddress.id; - } else { - this.addressMode = 'new'; - } - } else { - this.addressMode = 'billing'; - await this.fetchBillingAddress(); - } - }, - async fetchDeliveryAddresses(newVal, oldVal) { - if ((oldVal === 'billing' || oldVal === 'existing') && newVal === 'new') { - this.$emit('update:delAddrName', ''); - this.$emit('update:delAddrLine', ''); - this.$emit('update:delAddrPLZ', ''); - this.$emit('update:delAddrCity', ''); - this.$emit('update:delAddrEMail', ''); - return; - } - - if (this.addressMode === 'billing' && this.billAddrId) { - await this.fetchBillingAddress(); - return; - } - - if (!this.billAddrId || this.addressMode !== 'existing' || this.fetchedBillAddr === this.billAddrId) return; - - const response = await axios.get(window.TT_CONFIG["BASE_PATH"] + '/WarehouseShippingNote/getDeliveryAddresses?billingAddressId=' + this.billAddrId); - - this.fetchedBillAddr = this.billAddrId; - this.addresses = response.data.map(address => { - address.value = address.id; - address.text = `${address.deliveryAddressName} - ${address.deliveryAddressLine}, ${address.deliveryAddressPLZ} ${address.deliveryAddressCity}`; - return address; - }); - }, - setSelectedAddrValues() { - if (!this.selectedAddr) return; - - const selectedAddress = this.addresses.find(address => address.id === parseInt(this.selectedAddr)); - if (!selectedAddress) { - this.window.notify('error', 'Lieferadresse konnte nicht gefunden werden'); - return; - } - - this.$emit('update:delAddrName', selectedAddress.deliveryAddressName); - this.$emit('update:delAddrLine', selectedAddress.deliveryAddressLine); - this.$emit('update:delAddrPLZ', selectedAddress.deliveryAddressPLZ); - this.$emit('update:delAddrCity', selectedAddress.deliveryAddressCity); - }, - async fetchBillingAddress() { - const response = await axios.get(window.TT_CONFIG["BASE_PATH"] + '/Address/api?do=getAddress&id=' + this.billAddrId); - if (response.data.status !== 'OK' || !response.data.result.address) { - this.window.notify('error', 'Rechnungsadresse konnte nicht gefunden werden'); - return; - } - // TODO: here is still a bug that we fetch the billing address twice - // this.window.notify('success', 'Rechnungsadresse gefunden'); - - this.$emit('update:delAddrName', - response.data.result.address.company || response.data.result.address.firstname + ' ' + response.data.result.address.lastname); - this.$emit('update:delAddrLine', response.data.result.address.street); - this.$emit('update:delAddrPLZ', response.data.result.address.zip); - this.$emit('update:delAddrCity', response.data.result.address.city); - this.$emit('update:delAddrEMail', response.data.result.address.email); + async updateKilometer(carId) { + if (!carId) return this.$refs.hoursManager.updateField('kilometerCount', null); + const delAddr = this.shippingNote.deliveryAddressLine + ' ' + this.shippingNote.deliveryAddressPLZ + ' ' + this.shippingNote.deliveryAddressCity; + const {data} = await axios.get(window.TT_CONFIG["BASE_PATH"] + '/WarehouseShippingNote/getDistance?from=Xinon%20GmbH&to=' + delAddr); + this.$refs.hoursManager.updateField('kilometerCount', data.distance); } } }) -// now we need a signature pad component which will fire a close or a signed event and takes shipping note as a prop -// when mounted it will load https://cdn.jsdelivr.net/npm/signature_pad@4.1.7/dist/signature_pad.umd.min.js -// and display using a tt-modal -// and when save/submit is clicked we will send it to /WarehouseShippingNote/sign?id=ID POST with the signature as a base64 encoded image string - Vue.component('warehouse-shipping-note-signature-pad', { props: { shippingNoteId: {type: Number, required: true} diff --git a/public/plugins/vue/tt-components/css/tt-position-manager.css b/public/plugins/vue/tt-components/css/tt-position-manager.css index ed770af35..679e04983 100644 --- a/public/plugins/vue/tt-components/css/tt-position-manager.css +++ b/public/plugins/vue/tt-components/css/tt-position-manager.css @@ -8,14 +8,20 @@ } .positions-manager .form-container { - display: flex; - align-items: center; /* Vertically center */ - justify-content: flex-start; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 1rem; + align-items: center; padding-bottom: 1rem; border-bottom: 1px solid #ddd; } +@media (max-width: 768px) { + .positions-manager .form-container { + grid-template-columns: minmax(100%, 1fr); + } +} + .positions-manager .form-container .button-wrapper { align-self: flex-end; /* Align button container at the bottom */ } diff --git a/public/plugins/vue/tt-components/tt-position-manager.js b/public/plugins/vue/tt-components/tt-position-manager.js index d66e9a34e..7fddfa35f 100644 --- a/public/plugins/vue/tt-components/tt-position-manager.js +++ b/public/plugins/vue/tt-components/tt-position-manager.js @@ -1,29 +1,33 @@ Vue.component('tt-resolver', { props: { - value: {type: Number, required: true}, - reference: {type: String, required: true}, - }, - data() { - return { - window: window, - loading: true, - text: '', - } + value: { type: [Number, String], required: true }, + reference: { type: String, required: true }, + autocomplete: { type: Boolean, default: false } }, + data: () => ({ + loading: true, + text: '' + }), template: ` -
-
- Loading... -
-
- {{ text }} - `, +
+
+ Loading... +
+
+ {{ text }} + `, async created() { - const entry = await axios.get(window.TT_CONFIG['BASE_PATH'] + '/' + this.reference + '/getById?id=' + this.value); - this.text = entry.data.name ?? entry.data.title ?? entry.data.text ?? '[E] Key not found'; + const endpoint = this.autocomplete + ? `${window.TT_CONFIG["BASE_PATH"]}${this.reference}?searchedID=${this.value}` + : `${window.TT_CONFIG["BASE_PATH"]}/${this.reference}/getById?id=${this.value}`; + + const { data } = await axios.get(endpoint); + + this.text = this.autocomplete ? data[0]?.text : data.name ?? data.title ?? data.text ?? '[E] Key not found'; this.loading = false; } -}) +}); + Vue.component('tt-positions-manager', { @@ -48,6 +52,7 @@ Vue.component('tt-positions-manager',
+ {{ position[key] ? 'Ja' : 'Nein' }} {{ formatFieldValue(position[key] ?? position[key + '_text'], field) }} @@ -147,6 +155,8 @@ Vue.component('tt-positions-manager', this.$set(this.formData, key, value); }, defaultValidateForm(formData) { + console.log(formData); + console.log(this.config["validateFormOptions"]); for (const field of this.config["validateFormOptions"]) { if (!(formData[field.key] || formData[field.key + '_text'])) { window.notify('error', field.message); @@ -157,7 +167,7 @@ Vue.component('tt-positions-manager', }, checkEmitDisplayValueAutocomplete() { for (const [key, field] of Object.entries(this.config.fields)) { - if (field.type === 'autocomplete' && field.emitDisplayValue && isNaN(this.formData[key])) { + if (field.type === 'autocomplete' && field.emitDisplayValue && (isNaN(this.formData[key]) || !this.formData[key]) && this.$refs['autocomplete-' + key][0]) { this.$set(this.formData, key + '_text', this.$refs['autocomplete-' + key][0].displayValue); this.$delete(this.formData, key); } @@ -192,10 +202,19 @@ Vue.component('tt-positions-manager', }, resetForm() { this.formData = {}; + for (const [key, field] of Object.entries(this.config.fields)) { + if (field.type === 'autocomplete' && field.emitDisplayValue && this.$refs['autocomplete-' + key][0]) { + this.$refs['autocomplete-' + key][0].displayValue = ''; + } + if (field.inputType === 'date') { + this.$set(this.formData, key, moment().format('YYYY-MM-DD')); + } + } this.selectedIndex = null; }, formatFieldValue(value, field) { if (field.formatter) return field.formatter(value); + if (field.inputType === 'date') return moment(value).format('DD.MM.YYYY'); return value; }, }, @@ -212,6 +231,9 @@ Vue.component('tt-positions-manager', } } }, + mounted() { + this.resetForm(); + }, computed: { groupedPositions() { const groups = {};