diff --git a/Layout/default/VueViews/Vue.php b/Layout/default/VueViews/Vue.php index 16ffac393..190f88aed 100644 --- a/Layout/default/VueViews/Vue.php +++ b/Layout/default/VueViews/Vue.php @@ -1,5 +1,4 @@ setReturnValue(['filename' => $shippingNote->id . ".pdf"]); + + + + ?> @@ -87,11 +92,15 @@ TODO: enable option for showing prices -->

Ihr XINON Lieferschein vom create)?>

+

+ note ?> +

+ - + @@ -102,7 +111,7 @@ TODO: enable option for showing prices "> - +
Position MengeEHEinheit Artikel Preis
+ + signature) && $shippingNote->signature !== ''): ?> +
+ Unterschrift konnte nicht geladen werden +
Unterschrieben am: signatureDate))?>
+
Unterschrieben von: signatureName?>
+
+ + \ No newline at end of file diff --git a/application/WarehouseHistory/WarehouseHistoryController.php b/application/WarehouseHistory/WarehouseHistoryController.php index f5e88b80d..b990d0133 100644 --- a/application/WarehouseHistory/WarehouseHistoryController.php +++ b/application/WarehouseHistory/WarehouseHistoryController.php @@ -36,21 +36,24 @@ class WarehouseHistoryController { if (isset($columns[array_search($item['key'], array_column($columns, 'key'))]['modal']['type'])) { - if($columns[array_search($item['key'], array_column($columns, 'key'))]['modal']['type'] === 'checkbox') { + if ($columns[array_search($item['key'], array_column($columns, 'key'))]['modal']['type'] === 'checkbox') { $item['old_value'] = $item['old_value'] === '1' ? 'Ja' : 'Nein'; $item['new_value'] = $item['new_value'] === '1' ? 'Ja' : 'Nein'; } - if($columns[array_search($item['key'], array_column($columns, 'key'))]['modal']['type'] === 'select') { + if ($columns[array_search($item['key'], array_column($columns, 'key'))]['modal']['type'] === 'select') { $column = $columns[array_search($item['key'], array_column($columns, 'key'))]; - $item['old_value'] = $column['modal']['items'][array_search($item['old_value'], array_column($column['modal']['items'], 'value'))]['text']; - $item['new_value'] = $column['modal']['items'][array_search($item['new_value'], array_column($column['modal']['items'], 'value'))]['text']; + + if (isset($column['modal']['items'][array_search($item['old_value'], array_column($column['modal']['items'], 'value'))]) && + isset($column['modal']['items'][array_search($item['new_value'], array_column($column['modal']['items'], 'value'))])) { + $item['old_value'] = $column['modal']['items'][array_search($item['old_value'], array_column($column['modal']['items'], 'value'))]['text']; + $item['new_value'] = $column['modal']['items'][array_search($item['new_value'], array_column($column['modal']['items'], 'value'))]['text']; + } } } - $item['columnHeader'] = $columns[array_search($item['key'], array_column($columns, 'key'))]['text']; return $item; }, $history); diff --git a/application/WarehouseShippingNote/WarehouseShippingNoteController.php b/application/WarehouseShippingNote/WarehouseShippingNoteController.php index 374ebef3c..2dde19cdb 100644 --- a/application/WarehouseShippingNote/WarehouseShippingNoteController.php +++ b/application/WarehouseShippingNote/WarehouseShippingNoteController.php @@ -14,7 +14,8 @@ class WarehouseShippingNoteController extends TTCrud { ['key' => 'deliveryAddressName', 'text' => 'L.-Adr. Name', 'required' => true], ['key' => 'deliveryAddressLine', 'text' => 'L.-Adr.', 'required' => true], ['key' => 'deliveryAddressPLZ', 'text' => 'L.-Adr. PLZ', 'required' => true], - ['key' => 'deliveryAddressCity', 'text' => 'L.-Adr. Ort', 'required' => true], + ['key' => 'deliveryAddressEMail', 'text' => 'L.-Adr. EMail', 'required' => true, 'table' => false], + ['key' => 'note', 'text' => 'Notiz', 'required' => true, 'table' => false], ['key' => 'status', 'text' => 'Status', 'required' => true, @@ -63,6 +64,7 @@ class WarehouseShippingNoteController extends TTCrud { self::returnJson(['success' => false, 'message' => 'Status muss "Neu" sein']); die(); } + $postData['positions'] = json_encode($postData['positions']); return true; } @@ -75,6 +77,7 @@ class WarehouseShippingNoteController extends TTCrud { "\r"], " ", $address->getCompanyOrName())) . " (" . $address->zip . " " . $address->city . ", " . $address->street . ")" . (($address->customer_number) ? " [" . $address->customer_number . "]" : "")]; return $result; } + return null; } protected function beforeUpdate($postData): bool { @@ -169,6 +172,40 @@ class WarehouseShippingNoteController extends TTCrud { self::returnJson($textElements); } + 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 ($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']); + } + + try { + $shippingNote['signatureDate'] = date("Y-m-d"); + WarehouseShippingNoteModel::update($shippingNote); + self::returnJson(['success' => true, 'message' => 'Unterschrift wurde gespeichert']); + } catch (Exception $e) { + http_response_code(500); + self::returnJson(['success' => false, 'message' => 'Unterschrift konnte nicht gespeichert werden']); + } + } + protected function createPDFAction() { $id = $this->request->id; if (strlen($id) < 1) { @@ -194,9 +231,38 @@ class WarehouseShippingNoteController extends TTCrud { $position['articleDescription'] = $articlePacket->description === $articlePacket->title ? "" : $articlePacket->description; $position['articleUnit'] = 'Stk.'; $positions[] = $position; + } elseif (isset($position['articleText'])) { + $position['articleTitle'] = $position['articleText']; + $position['articleDescription'] = ""; + $position['articleUnit'] = 'Stk.'; + $positions[] = $position; } } + // json decode hoursEntries and add to positions + $hoursEntries = json_decode($shippingNote->hoursEntries, true); + foreach ($hoursEntries as $hoursEntry) { + $positions[] = [ + 'articleTitle' => "Arbeitsstunden", + 'articleDescription' => $hoursEntry['note'], + 'articleUnit' => 'Std.', + 'amount' => $hoursEntry['hourCount'], + 'price' => $hoursEntry['hourlyPrice'] * $hoursEntry['hourCount'] ?? 0, + ]; + + if ($hoursEntry['carId']) { + $positions[] = [ + 'articleTitle' => "Fahrkostenpauschale", + 'articleDescription' => "Fahrzeug: " . TimerecordingCarModel::getOne($hoursEntry['carId'])->number_plate, + 'articleUnit' => 'Km', + 'amount' => $hoursEntry['kilometerCount'], + 'price' => 1 * $hoursEntry['kilometerCount'] ?? 0, + ]; + } + + } + + $textElements = []; // parse shippingNote.textElements ({"1":true,"2":true}) to array, fetch each text element and put content into array $shippingNoteTextElements = json_decode($shippingNote->textElements, true); @@ -259,4 +325,148 @@ class WarehouseShippingNoteController extends TTCrud { header('Content-Disposition: inline; filename="' . $filename . '"'); readfile($filename); } + + // TODO: either move this to UserController or make it better + protected function userAutoCompleteAction() { + $users = array_map(function($user) { + return ['value' => $user->id, 'text' => $user->name]; + }, UserModel::search(['employee' => true])); + + $out = null; + $searchedID = $this->request->searchedID; + if (strlen($searchedID) > 0) { + // find user with value searchedID + $out = array_filter($users, function($user) use ($searchedID) { + return $user['value'] == $searchedID; + }); + } else { + $out = array_filter($users, function($user) { + ; + return strpos(strtolower($user['text']), strtolower($this->request->q)) !== false; + }); + + $out = array_slice($out, 0, 10); + } + + self::returnJson(array_values($out)); + } + + //TODO: either move this to TimerecordingCarController or make it better + protected function timerecordingCarAutoCompleteAction() { + $timerecordingCars = array_map(function($timerecordingCar) { + return ['value' => $timerecordingCar->id, 'text' => $timerecordingCar->number_plate . " " . $timerecordingCar->brand . " " . $timerecordingCar->model]; + }, TimerecordingCarModel::getAll()); + + $out = null; + $searchedID = $this->request->searchedID; + if (strlen($searchedID) > 0) { + // find user with value searchedID + $out = array_filter($timerecordingCars, function($timerecordingCar) use ($searchedID) { + return $timerecordingCar['value'] == $searchedID; + }); + } else { + $out = array_filter($timerecordingCars, function($timerecordingCar) { + return strpos(strtolower($timerecordingCar['text']), strtolower($this->request->q)) !== false; + }); + + $out = array_slice($out, 0, 10); + } + + self::returnJson(array_values($out)); + } + + protected function timerecordingCarForUserAction() { + $timerecordingCars = TimerecordingCarModel::getAll(); + $out = null; + foreach ($timerecordingCars as $timerecordingCar) { + if ($timerecordingCar->user_id == $this->user->id) { + header('Content-Type: application/json'); + die(json_encode(['success' => true, 'id' => $timerecordingCar->id])); + } + } + die(json_encode(['success' => true, 'id' => 2])); + } + + + //TODO: export this to an api class for openstreetmap + protected function getDistanceAction() { +// $filename = TEMP_DIR . "/DeviceMonitoring/interfacesWithCongestion.json"; + // use dir TEMP_DIR /OpenStreetMap/from-to.json to cache the results + + $filename = TEMP_DIR . "/OpenStreetMap/" . urlencode($this->request->from) . "-" . urlencode($this->request->to) . ".json"; + + if (file_exists($filename)) { + $data = file_get_contents($filename); + self::returnJson(json_decode($data, true)); + } + + + $from = $this->request->from; + $to = $this->request->to; + $from = urlencode($from); + $to = urlencode($to); + + function geocode($address) { + if ($address === 'Xinon GmbH') { + return [['lat' => 46.99555015, 'lon' => 15.77507876755547]]; + } + + $curl = curl_init(); + curl_setopt_array($curl, [ + CURLOPT_URL => "https://nominatim.openstreetmap.org/search?q=$address&format=json", + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_ENCODING => "", + CURLOPT_MAXREDIRS => 10, + CURLOPT_TIMEOUT => 30, + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, + CURLOPT_CUSTOMREQUEST => "GET", + CURLOPT_HTTPHEADER => [ + "accept: application/json", + "accept-language: de-AT,de;q=0.9,en;q=0.8", + "origin: https://routing.openstreetmap.de", + "referer: https://routing.openstreetmap.de/", + "user-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36" + ], + ]); + + $response = curl_exec($curl); + $err = curl_error($curl); + + if ($err) { + die(json_encode(['success' => false, 'message' => 'Error while geocoding'])); + } + + curl_close($curl); + return json_decode($response, true); + } + + function route($from, $to) { + $fromData = geocode($from); + $toData = geocode($to); + $fromLat = $fromData[0]['lat']; + $fromLon = $fromData[0]['lon']; + $toLat = $toData[0]['lat']; + $toLon = $toData[0]['lon']; + $url = "https://router.project-osrm.org/route/v1/driving/$fromLon,$fromLat;$toLon,$toLat?overview=false"; + $data = json_decode(file_get_contents($url), true); + $distance = $data['routes'][0]['distance']; + return $distance; + } + + $fromData = geocode($from); + $toData = geocode($to); + + $distance = route($from, $to); + + $roundedDistanceKm = round($distance / 1000, 0); + + if (!file_exists(dirname($filename))) { + mkdir(dirname($filename), 0777, true); + } + file_put_contents($filename, json_encode(['success' => true, 'distance' => $roundedDistanceKm])); + + self::returnJson(['success' => true, 'distance' => $roundedDistanceKm]); + } + } \ No newline at end of file diff --git a/application/WarehouseShippingNote/WarehouseShippingNoteModel.php b/application/WarehouseShippingNote/WarehouseShippingNoteModel.php index e97115139..374fa7046 100644 --- a/application/WarehouseShippingNote/WarehouseShippingNoteModel.php +++ b/application/WarehouseShippingNote/WarehouseShippingNoteModel.php @@ -7,9 +7,15 @@ class WarehouseShippingNoteModel extends TTCrudBaseModel { public string $deliveryAddressLine; public string $deliveryAddressPLZ; public string $deliveryAddressCity; + public string $deliveryAddressEMail; + public string $note; public string $status; // 'new'|'accepted'|'invoiced' public string $positions; public string $textElements; + public string $hoursEntries; + public ?string $signature; + public ?string $signatureName; + public ?string $signatureDate; public ?int $eShopOrderId; public int $create; public int $createBy; diff --git a/db/migrations/20241112180000_warehouse_modify_2.php b/db/migrations/20241112180000_warehouse_modify_2.php new file mode 100644 index 000000000..72b3da21f --- /dev/null +++ b/db/migrations/20241112180000_warehouse_modify_2.php @@ -0,0 +1,47 @@ +getEnvironment() == "thetool") { + //WarehouseShippingNote Table + $WarehouseShippingNote = $this->table("WarehouseShippingNote", ["signed" => true]); + $WarehouseShippingNote->addColumn("deliveryAddressEMail", "string", ["null" => false, "limit" => 255]); + $WarehouseShippingNote->addColumn("note", "string", ["null" => false, "limit" => 255]); + $WarehouseShippingNote->addColumn("hoursEntries", "string", ["null" => false, "limit" => 255]); + $WarehouseShippingNote->addColumn("signature", "string", ["null" => true, "limit" => 255]); + $WarehouseShippingNote->addColumn("signatureName", "string", ["null" => true, "limit" => 255]); + $WarehouseShippingNote->addColumn("signatureDate", "string", ["null" => true, "limit" => 255]); + $WarehouseShippingNote->save(); + + //WarehouseArticle Table + $WarehouseArticle = $this->table("WarehouseArticle", ["signed" => true]); + $WarehouseArticle->changeColumn("cheapestSellPrice", "string", ["null" => true, "limit" => 255]); + $WarehouseArticle->save(); + } + + if ($this->getEnvironment() == "addressdb") { + + } + } + + public function down(): void { + if ($this->getEnvironment() == "thetool") { + $this->table("WarehouseShippingNote")->removeColumn("deliveryAddressEMail"); + $this->table("WarehouseShippingNote")->removeColumn("note"); + $this->table("WarehouseShippingNote")->removeColumn("hoursEntries"); + $this->table("WarehouseShippingNote")->removeColumn("signature"); + $this->table("WarehouseShippingNote")->removeColumn("signatureName"); + $this->table("WarehouseShippingNote")->removeColumn("signatureDate"); + $this->table("WarehouseShippingNote")->save(); + + $this->table("WarehouseArticle")->changeColumn("cheapestSellPrice", "string", ["null" => false, "limit" => 255]); + } + + if ($this->getEnvironment() == "addressdb") { + + } + } +} diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile index e97ec1a02..603c42c77 100644 --- a/docker/php/Dockerfile +++ b/docker/php/Dockerfile @@ -5,12 +5,15 @@ FROM debian:bookworm RUN apt update RUN apt install wget libfontenc1 xfonts-75dpi xfonts-base xfonts-encodings xfonts-utils openssl build-essential libssl-dev libxrender-dev git-core libx11-dev libxext-dev libfontconfig1-dev libfreetype6-dev fontconfig -y # wget https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.stretch_amd64.deb -# dpkg ignore - +# dpkg --force-all -i wkhtmltox_0.12.6-1.stretch_amd64.deb +# wget https://www.mytaxexpress.com/download/libssl1.1_1.1.1f-1ubuntu2.17_amd64.deb +# dpkg -i libssl1.1_1.1.1f-1ubuntu2.17_amd64.deb +# wget https://archive.debian.org/debian/pool/main/libj/libjpeg8/libjpeg8_8b-1_amd64.deb +# dpkg -i libjpeg8_8b-1_amd64.deb # Install apache2 and PHP and PHP modules RUN apt update && \ - apt install -y apache2 curl cron unzip php8.2 php8.2-curl php8.2-cli php8.2-mysqli php8.2-gd php8.2-zip php8.2-dom php8.2-mbstring && \ + apt install -y apache2 curl cron unzip php8.2 php8.2-imap php8.2-curl php8.2-cli php8.2-mysqli php8.2-gd php8.2-zip php8.2-dom php8.2-mbstring && \ curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer && \ apt clean && \ rm -rf /var/lib/apt/lists/* diff --git a/public/bundler.php b/public/bundler.php index 25b2a0777..012849926 100644 --- a/public/bundler.php +++ b/public/bundler.php @@ -44,6 +44,7 @@ $jsFiles = [ "plugins/vue/tt-components/tt-icon-select.js", "plugins/vue/tt-components/tt-number-range.js", "plugins/vue/tt-components/tt-checkbox.js", + "plugins/vue/tt-components/tt-textarea.js", ]; diff --git a/public/js/pages/WarehouseShippingNote/WarehouseShippingNote.css b/public/js/pages/WarehouseShippingNote/WarehouseShippingNote.css new file mode 100644 index 000000000..4cde1ab0c --- /dev/null +++ b/public/js/pages/WarehouseShippingNote/WarehouseShippingNote.css @@ -0,0 +1,61 @@ +.warehouse-shipping-note-modal-positions-entry-container { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr; + grid-gap: 10px; +} + +.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 2fr 1fr 1fr 1fr; + grid-gap: 10px; +} + +.warehouse-shipping-note-modal-hours-entry-container.hideHourlyPrice { + grid-template-columns: 2fr 1fr 1fr 2fr 1fr 1fr; +} + +@media (min-width: 992px) { + .modal-lg, .modal-xl { + /*max width either 90% or 1120px*/ + max-width: min(90vw, 1120px) !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; + } + + .signModal > div { + margin: 0; + width: 100vw; + height: 100vh; + max-height: unset; + max-width: unset; + } + + .signModal .modal-content { + height: 100%; + max-height: 100%; + } + + .signModal .modal-body { + height: 100%; + max-height: 100%; + } + + .signModal .modal-footer { + display: none; + } + +} \ No newline at end of file diff --git a/public/js/pages/WarehouseShippingNote/WarehouseShippingNote.js b/public/js/pages/WarehouseShippingNote/WarehouseShippingNote.js index bee7650da..a81c70e4f 100644 --- a/public/js/pages/WarehouseShippingNote/WarehouseShippingNote.js +++ b/public/js/pages/WarehouseShippingNote/WarehouseShippingNote.js @@ -1,25 +1,11 @@ -const defaultCrudModalData = { - billingAddressId: '', - deliveryAddressName: '', - deliveryAddressLine: '', - deliveryAddressPLZ: '', - deliveryAddressCity: '', - status: 'new', - positions: [], - textElements: {} -} - -window.crudModalStatusOptions = - [{value: 'new', text: 'Neu'}, {value: 'accepted', text: 'Akzeptiert'}, {value: 'invoiced', text: 'In Rechnung gestellt'}] - -// create a additional vue component for showing positions in the table with lazy loading for article titles and description Vue.component('warehouse-shipping-note-positions', { //language=Vue - props: { - positions: Array + props: { + positions: Array, + hoursEntries: Array }, data() { return { - articleData: {}, loading: false, articlePacketData: {} + articleData: {}, loading: false, articlePacketData: {}, userData: {} } }, template: `
@@ -30,8 +16,13 @@ Vue.component('warehouse-shipping-note-positions', {
  • - {{ position.amount }}x {{ position.article ? articleData[position.article]?.text : articlePacketData[position.articlePacket]?.text }} + {{ position.amount }}x + {{ position.article ? articleData[position.article]?.text : position.articlePacket ? articlePacketData[position.articlePacket]?.text : position.articleText }}
  • +
`, async mounted() { @@ -45,306 +36,51 @@ Vue.component('warehouse-shipping-note-positions', { this.$set(this.articlePacketData, position.articlePacket, response.data[0]); } } + for (const entry of this.hoursEntries) { + if (entry.userId) { + const response = await axios.get(window.TT_CONFIG["BASE_PATH"] + '/WarehouseShippingNote/userAutoComplete?searchedID=' + entry.userId); + this.$set(this.userData, entry.userId, response.data[0]); + } + } this.loading = false; } }) - // noinspection JSUnusedLocalSymbols Vue.component('warehouse-shipping-note', { //language=Vue template: ` - - - - - - - - - - - - -
-

Positionen

- - - - - - -
- + + - - - +
`, data() { return { - window: window, - historyModal: false, - historyModalId: null, - crudModal: false, - crudModalSelectDeliveryAddressModeItems: [{text: 'Wie Rechnungsadresse', value: 'billing'}, - {text: 'Bestehende Lieferadresse', value: 'existing'}, - {text: 'Neue Lieferadresse', value: 'new'}], - crudModalSelectDeliveryAddressMode: 'billing', - crudModalDataDeliveryAddressOptions: [], - crudModalDataDeliveryAddressSelected: '', - crudModalVerifyMode: false, - crudModalId: null, - crudModalData: defaultCrudModalData, - crudModalAddPositionArticle: '', - crudModalAddPositionAmount: '', - crudModalAddPositionPrice: '', - articleNames: {}, - articlePacketNames: {}, - textElements: [], + window: window, + historyModal: false, + historyModalId: null, + shippingNoteModalId: null, + signingShippingNoteId: null } - }, async mounted() { - const response = await axios.get(window.TT_CONFIG["BASE_PATH"] + '/WarehouseShippingNote/getAllTextElements'); - this.textElements = response.data; }, + methods: { - methods: { - async openBilledModal() { - const unbilledShippingNotes = await axios.post(window.TT_CONFIG['BASE_PATH'] + '/WarehouseShippingNote/get', { - "pagination": {"page": 1, "per_page": 1}, "filters": { - "status": "accepted" - }, "order": {"key": null, "order": "asc"} - }); - - if (unbilledShippingNotes.data.rows.length === 0) { - this.window.notify('warning', 'Keine Lieferscheine zum Verrechnen gefunden'); - return; - } - - await this.openCrudModal(unbilledShippingNotes.data.rows[0]); - this.crudModalVerifyMode = true; - }, - async openVerifyModal() { - const unverifiedShippingNotes = await axios.post(window.TT_CONFIG['BASE_PATH'] + '/WarehouseShippingNote/get', { - "pagination": {"page": 1, "per_page": 1}, "filters": { - "status": "new" - }, "order": {"key": null, "order": "asc"} - }); - - if (unverifiedShippingNotes.data.rows.length === 0) { - this.window.notify('warning', 'Keine Lieferscheine zum Freigeben gefunden'); - return; - } - - await this.openCrudModal(unverifiedShippingNotes.data.rows[0]); - this.crudModalVerifyMode = true; - - }, resetCrudModalData() { - this.crudModalData.billingAddressId = ''; - this.crudModalData.deliveryAddressName = ''; - this.crudModalData.deliveryAddressLine = ''; - this.crudModalData.deliveryAddressPLZ = ''; - this.crudModalData.deliveryAddressCity = ''; - this.crudModalAddPositionArticle = ''; - this.crudModalAddPositionAmount = ''; - this.crudModalAddPositionPrice = ''; - this.crudModalSelectDeliveryAddressMode = 'billing'; - this.crudModalDataDeliveryAddressSelected = ''; - this.crudModal = false; - }, async openCrudModal(data) { - this.resetCrudModalData(); - this.crudModalVerifyMode = false; - if (data === 'create') { - this.crudModalId = 'create' - this.crudModalData = defaultCrudModalData - this.crudModal = true - } else { - this.crudModalSelectDeliveryAddressMode = 'new'; - const disconnectedData = JSON.parse(JSON.stringify(data)); - - if (disconnectedData.status !== 'new' && disconnectedData.status !== 'accepted') { - this.window.notify('warning', 'Lieferschein kann nicht bearbeitet werden, da er bereits in Rechnung gestellt wurde'); - return; - } - - disconnectedData.textElements = JSON.parse(disconnectedData.textElements); - disconnectedData.positions = JSON.parse(disconnectedData.positions); - for (const position of disconnectedData.positions) { - if (position.article) await this.fetchArticleNames(position.article); - if (position.articlePacket) await this.fetchArticlePacketNames(position.articlePacket); - } - this.crudModalId = 'update' - this.crudModalData = disconnectedData - this.crudModal = true - } - }, async addPosition() { - const missingFields = []; - - // ---------- Check Required Fields ---------- - - if (!this.crudModalAddPositionArticle) missingFields.push('Artikel'); - if (!this.crudModalAddPositionAmount) missingFields.push('Menge'); - if (!this.crudModalAddPositionPrice) missingFields.push('Preis-Überschreibung'); - if (missingFields.length > 0) { - window.notify('error', 'Bitte füllen Sie die folgenden Felder aus: ' + missingFields.join(', ')); - return; - } - - // ---------- Check if same article is already in positions ---------- - - const articleAlreadyInPositions = this.crudModalData.positions.find(position => position.article === this.crudModalAddPositionArticle); - if (articleAlreadyInPositions) { - window.notify('error', 'Artikel ist bereits in den Positionen enthalten'); - return; - } - - await this.fetchArticleNames(this.crudModalAddPositionArticle); - - this.crudModalData.positions.push({ - article: this.crudModalAddPositionArticle, amount: this.crudModalAddPositionAmount, price: parseFloat(this.crudModalAddPositionPrice) - }); - - //TODO: post to server - }, async fetchArticleNames(articleId) { - const response = await axios.get(window.TT_CONFIG["BASE_PATH"] + '/WarehouseArticle/autoComplete?searchedID=' + articleId); - this.$set(this.articleNames, articleId, response.data[0].text); - }, async fetchArticlePacketNames(articlePacketId) { - const response = await axios.get(window.TT_CONFIG["BASE_PATH"] + '/WarehouseArticlePacket/autoComplete?searchedID=' + articlePacketId); - this.$set(this.articlePacketNames, articlePacketId, response.data[0].text); - }, async createOrUpdate() { - const response = await axios.post(this.crudModalId === 'create' ? window['TT_CONFIG']['CREATE_URL'] : window['TT_CONFIG']['UPDATE_URL'], - this.crudModalData); - if (response.data.success) { - this.$refs.table.$refs.table.refreshTable(); - this.resetCrudModalData(); - this.window.notify('success', response.data.message || 'Erfolgreich gespeichert'); - } else { - this.window.notify('error', - response.data.errors ? Object.values(response.data.errors).join('
') : response.data.message || 'Ein Fehler ist aufgetreten'); - } - }, async fetchDeliveryAddresses() { - if (!this.crudModalData.billingAddressId || this.crudModalSelectDeliveryAddressMode !== 'existing' && this.crudModalSelectDeliveryAddressMode !== 'billing') return; - - if (this.crudModalSelectDeliveryAddressMode === 'billing') { - const response = await axios.get(window.TT_CONFIG["BASE_PATH"] + '/Address/api?do=getAddress&id=' + this.crudModalData.billingAddressId); - if (response.data.status !== 'OK' || !response.data.result.address) { - window.notify('error', 'Rechnungsadresse konnte nicht gefunden werden'); - return; - } - - this.crudModalData.deliveryAddressName = - response.data.result.address.company || response.data.result.address.firstname + ' ' + response.data.result.address.lastname; - this.crudModalData.deliveryAddressLine = response.data.result.address.street; - this.crudModalData.deliveryAddressPLZ = response.data.result.address.zip; - this.crudModalData.deliveryAddressCity = response.data.result.address.city; - } - if (!this.crudModalData.billingAddressId || this.crudModalSelectDeliveryAddressMode !== 'existing') return; - - const response = await axios.get(window.TT_CONFIG["BASE_PATH"] + - '/WarehouseShippingNote/getDeliveryAddresses?billingAddressId=' + - this.crudModalData.billingAddressId); - - this.crudModalDataDeliveryAddressOptions = response.data.map(address => { - address.value = address.id; - address.text = `${address.deliveryAddressName} - ${address.deliveryAddressLine}, ${address.deliveryAddressPLZ} ${address.deliveryAddressCity}`; - return address; - }); - } - }, watch: { - crudModalAddPositionArticle: async function (newValue) { - if (!newValue) return; - - const url = `${window.TT_CONFIG["BASE_PATH"]}/WarehouseShippingNote/getArticleAddressPrice?articleId=${newValue}&addressId=${this.crudModalData.billingAddressId}`; - const response = await axios.get(url); - this.crudModalAddPositionPrice = response.data.price; - }, - crudModalData: {handler: 'fetchDeliveryAddresses', deep: true}, - crudModalSelectDeliveryAddressMode: {handler: 'fetchDeliveryAddresses', deep: true}, - crudModalDataDeliveryAddressSelected: function (newValue) { - if (!newValue) return; - - const selectedAddress = this.crudModalDataDeliveryAddressOptions.find(address => address.id === parseInt(newValue)); - if (!selectedAddress) { - window.notify('error', 'Lieferadresse konnte nicht gefunden werden'); - return; - } - - this.crudModalData.deliveryAddressName = selectedAddress.deliveryAddressName; - this.crudModalData.deliveryAddressLine = selectedAddress.deliveryAddressLine; - this.crudModalData.deliveryAddressPLZ = selectedAddress.deliveryAddressPLZ; - this.crudModalData.deliveryAddressCity = selectedAddress.deliveryAddressCity; - } } -}) \ No newline at end of file +}) diff --git a/public/js/pages/WarehouseShippingNote/WarehouseShippingNoteModal.js b/public/js/pages/WarehouseShippingNote/WarehouseShippingNoteModal.js new file mode 100644 index 000000000..ccfde159e --- /dev/null +++ b/public/js/pages/WarehouseShippingNote/WarehouseShippingNoteModal.js @@ -0,0 +1,727 @@ +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: '', + } + }, + //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, + hourlyPrice: this.hourlyPrice || null, + carId: this.carId, + kilometerCount: this.kilometerCount + }); + // 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() { + 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() { + const response = await axios.get(window.TT_CONFIG["BASE_PATH"] + '/WarehouseShippingNote/timerecordingCarForUser?userId=' + this.userId); + 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.carId) this.updateCarId().then(); + if (!this.userId) this.userId = this.window.TT_CONFIG['USER_ID']; + if (!this.date) this.updateDate(); + if (!this.kilometerCount) this.updateKilometerCount().then(); + + this.$parent.$parent.$parent.$watch('delAddrLine', 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: ` +
+ + + + + + + + + + + + + + + + + + + + + + + + +
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, + articleApiUrl: window.TT_CONFIG['BASE_PATH'] + '/WarehouseArticle/autoComplete', + articlePacketApiUrl: window.TT_CONFIG['BASE_PATH'] + '/WarehouseArticlePacket/autoComplete', + articleId: '', + articlePacketId: '', + amount: '', + price: '', + } + }, + //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'); + if (!this.price) return this.window.notify('error', 'Bitte füllen sie den Preis aus'); + const data = { + amount: this.amount, + price: parseFloat(this.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, + articleNames: {}, + articlePacketNames: {}, + } + }, + //language=Vue + template: ` +
+ + + + + + + + + + + + + + + + + + + + +
ArtikelMengePreisAktionen
Keine Einträge
{{ position.article ? articleNames[position.article] : position.articlePacket ? articlePacketNames[position.articlePacket] : position.articleText }}{{ position.amount }}{{ (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; + }, + }, +}) + + +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, + billAddrAutoCompleteUrl: window.TT_CONFIG['BASE_PATH'] + '/Address/Api?do=findAddress', + billAddrId: '', + delAddrName: '', + delAddrLine: '', + delAddrPLZ: '', + delAddrCity: '', + delAddrEMail: '', + status: '', + note: '', + textElements: [], + hoursEntries: [], + positions: [], + } + }, + + //language=Vue + template: ` + +
+

Liefer- und Rechnungsadresse

+ + + + + + +
Bitte füllen Sie die Rechnungs- und Lieferadresse aus
+ +
+ + +
+ `, + + // 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; + + for (const key of ['textElements', 'hoursEntries', 'positions']) { + try { + this[key] = JSON.parse(response.data[key]); + } catch { + this.textElements = []; + } + } + } + }, + methods: { + openSigningModal() { + + }, + 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' + } + + if (this.id !== 'create') data.id = this.id; + + const url = this.id === 'create' ? window.TT_CONFIG['CREATE_URL'] : window.TT_CONFIG['UPDATE_URL']; + const response = await axios.post(url, data); + + 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`; + } + } + +}) + +Vue.component('warehouse-shipping-note-modal-address', { + // also add props for delAddrName, delAddrLine, delAddrPLZ, delAddrCity which we will sync with the parent component + 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: '', + } + }, + //language=Vue + template: ` +
+ + + + + +
+ `, + watch: { + billAddrId: {handler: 'updateBillingMode', immediate: false}, + addressMode: {handler: 'fetchDeliveryAddresses', immediate: false}, + selectedAddr: {handler: 'setSelectedAddrValues', immediate: false}, + }, + methods: { + async updateBillingMode() { + await this.fetchDeliveryAddresses(); + // this.addressMode = 'billing'; + + console.log('updateBillingMode'); + + // 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 && address.deliveryAddressEMail === this.delAddrEMail); + if (foundAddress) { + this.addressMode = 'existing'; + this.selectedAddr = foundAddress.id; + } else { + this.addressMode = 'new'; + } + } else { + this.addressMode = 'billing'; + await this.fetchBillingAddress(); + } + }, + async fetchDeliveryAddresses() { + 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; + } + 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); + } + } +}) + +// 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} + }, + data() { + return { + window: window, + signaturePad: null, + shippingNote: null, + signatureName: '', + } + }, + //language=Vue + template: ` + +
+
+
+
+ + +
+
+
+ `, + methods: { + async submit() { + const data = this.signaturePad.toDataURL(); + const response = await axios.post(window.TT_CONFIG['BASE_PATH'] + '/WarehouseShippingNote/sign?id=' + this.shippingNoteId, {signature: data, signatureName: this.signatureName}); + if (response.data.success) { + this.window.notify('success', response.data.message || 'Erfolgreich unterschrieben'); + this.$emit('close'); + } else { + this.window.notify('error', + response.data.errors ? Object.values(response.data.errors).join('
') : response.data.message || 'Ein Fehler ist aufgetreten'); + } + }, + }, + async mounted() { + // fetch shipping note by id + const response = await axios.get(window.TT_CONFIG["BASE_PATH"] + '/WarehouseShippingNote/getById?id=' + this.shippingNoteId); + this.shippingNote = response.data; + this.signaturePad = new SignaturePad(document.getElementById('signature-pad')); + } +}) \ No newline at end of file diff --git a/public/js/pages/WarehouseShippingNote/WarehouseSignaturePad.min.js b/public/js/pages/WarehouseShippingNote/WarehouseSignaturePad.min.js new file mode 100644 index 000000000..21e6507e6 --- /dev/null +++ b/public/js/pages/WarehouseShippingNote/WarehouseSignaturePad.min.js @@ -0,0 +1,6 @@ +/*! + * Signature Pad v4.1.7 | https://github.com/szimek/signature_pad + * (c) 2023 Szymon Nowak | Released under the MIT license + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).SignaturePad=e()}(this,(function(){"use strict";class t{constructor(t,e,i,s){if(isNaN(t)||isNaN(e))throw new Error(`Point is invalid: (${t}, ${e})`);this.x=+t,this.y=+e,this.pressure=i||0,this.time=s||Date.now()}distanceTo(t){return Math.sqrt(Math.pow(this.x-t.x,2)+Math.pow(this.y-t.y,2))}equals(t){return this.x===t.x&&this.y===t.y&&this.pressure===t.pressure&&this.time===t.time}velocityFrom(t){return this.time!==t.time?this.distanceTo(t)/(this.time-t.time):0}}class e{static fromPoints(t,i){const s=this.calculateControlPoints(t[0],t[1],t[2]).c2,n=this.calculateControlPoints(t[1],t[2],t[3]).c1;return new e(t[1],s,n,t[2],i.start,i.end)}static calculateControlPoints(e,i,s){const n=e.x-i.x,o=e.y-i.y,h=i.x-s.x,r=i.y-s.y,a=(e.x+i.x)/2,c=(e.y+i.y)/2,d=(i.x+s.x)/2,l=(i.y+s.y)/2,u=Math.sqrt(n*n+o*o),v=Math.sqrt(h*h+r*r),_=v/(u+v),p=d+(a-d)*_,m=l+(c-l)*_,g=i.x-p,w=i.y-m;return{c1:new t(a+g,c+w),c2:new t(d+g,l+w)}}constructor(t,e,i,s,n,o){this.startPoint=t,this.control2=e,this.control1=i,this.endPoint=s,this.startWidth=n,this.endWidth=o}length(){let t,e,i=0;for(let s=0;s<=10;s+=1){const n=s/10,o=this.point(n,this.startPoint.x,this.control1.x,this.control2.x,this.endPoint.x),h=this.point(n,this.startPoint.y,this.control1.y,this.control2.y,this.endPoint.y);if(s>0){const s=o-t,n=h-e;i+=Math.sqrt(s*s+n*n)}t=o,e=h}return i}point(t,e,i,s,n){return e*(1-t)*(1-t)*(1-t)+3*i*(1-t)*(1-t)*t+3*s*(1-t)*t*t+n*t*t*t}}class i{constructor(){try{this._et=new EventTarget}catch(t){this._et=document}}addEventListener(t,e,i){this._et.addEventListener(t,e,i)}dispatchEvent(t){return this._et.dispatchEvent(t)}removeEventListener(t,e,i){this._et.removeEventListener(t,e,i)}}class s extends i{constructor(t,e={}){super(),this.canvas=t,this._drawingStroke=!1,this._isEmpty=!0,this._lastPoints=[],this._data=[],this._lastVelocity=0,this._lastWidth=0,this._handleMouseDown=t=>{1===t.buttons&&this._strokeBegin(t)},this._handleMouseMove=t=>{this._strokeMoveUpdate(t)},this._handleMouseUp=t=>{1===t.buttons&&this._strokeEnd(t)},this._handleTouchStart=t=>{if(t.cancelable&&t.preventDefault(),1===t.targetTouches.length){const e=t.changedTouches[0];this._strokeBegin(e)}},this._handleTouchMove=t=>{t.cancelable&&t.preventDefault();const e=t.targetTouches[0];this._strokeMoveUpdate(e)},this._handleTouchEnd=t=>{if(t.target===this.canvas){t.cancelable&&t.preventDefault();const e=t.changedTouches[0];this._strokeEnd(e)}},this._handlePointerStart=t=>{t.preventDefault(),this._strokeBegin(t)},this._handlePointerMove=t=>{this._strokeMoveUpdate(t)},this._handlePointerEnd=t=>{this._drawingStroke&&(t.preventDefault(),this._strokeEnd(t))},this.velocityFilterWeight=e.velocityFilterWeight||.7,this.minWidth=e.minWidth||.5,this.maxWidth=e.maxWidth||2.5,this.throttle="throttle"in e?e.throttle:16,this.minDistance="minDistance"in e?e.minDistance:5,this.dotSize=e.dotSize||0,this.penColor=e.penColor||"black",this.backgroundColor=e.backgroundColor||"rgba(0,0,0,0)",this.compositeOperation=e.compositeOperation||"source-over",this._strokeMoveUpdate=this.throttle?function(t,e=250){let i,s,n,o=0,h=null;const r=()=>{o=Date.now(),h=null,i=t.apply(s,n),h||(s=null,n=[])};return function(...a){const c=Date.now(),d=e-(c-o);return s=this,n=a,d<=0||d>e?(h&&(clearTimeout(h),h=null),o=c,i=t.apply(s,n),h||(s=null,n=[])):h||(h=window.setTimeout(r,d)),i}}(s.prototype._strokeUpdate,this.throttle):s.prototype._strokeUpdate,this._ctx=t.getContext("2d"),this.clear(),this.on()}clear(){const{_ctx:t,canvas:e}=this;t.fillStyle=this.backgroundColor,t.clearRect(0,0,e.width,e.height),t.fillRect(0,0,e.width,e.height),this._data=[],this._reset(this._getPointGroupOptions()),this._isEmpty=!0}fromDataURL(t,e={}){return new Promise(((i,s)=>{const n=new Image,o=e.ratio||window.devicePixelRatio||1,h=e.width||this.canvas.width/o,r=e.height||this.canvas.height/o,a=e.xOffset||0,c=e.yOffset||0;this._reset(this._getPointGroupOptions()),n.onload=()=>{this._ctx.drawImage(n,a,c,h,r),i()},n.onerror=t=>{s(t)},n.crossOrigin="anonymous",n.src=t,this._isEmpty=!1}))}toDataURL(t="image/png",e){return"image/svg+xml"===t?("object"!=typeof e&&(e=void 0),`data:image/svg+xml;base64,${btoa(this.toSVG(e))}`):("number"!=typeof e&&(e=void 0),this.canvas.toDataURL(t,e))}on(){this.canvas.style.touchAction="none",this.canvas.style.msTouchAction="none",this.canvas.style.userSelect="none";const t=/Macintosh/.test(navigator.userAgent)&&"ontouchstart"in document;window.PointerEvent&&!t?this._handlePointerEvents():(this._handleMouseEvents(),"ontouchstart"in window&&this._handleTouchEvents())}off(){this.canvas.style.touchAction="auto",this.canvas.style.msTouchAction="auto",this.canvas.style.userSelect="auto",this.canvas.removeEventListener("pointerdown",this._handlePointerStart),this.canvas.removeEventListener("pointermove",this._handlePointerMove),this.canvas.ownerDocument.removeEventListener("pointerup",this._handlePointerEnd),this.canvas.removeEventListener("mousedown",this._handleMouseDown),this.canvas.removeEventListener("mousemove",this._handleMouseMove),this.canvas.ownerDocument.removeEventListener("mouseup",this._handleMouseUp),this.canvas.removeEventListener("touchstart",this._handleTouchStart),this.canvas.removeEventListener("touchmove",this._handleTouchMove),this.canvas.removeEventListener("touchend",this._handleTouchEnd)}isEmpty(){return this._isEmpty}fromData(t,{clear:e=!0}={}){e&&this.clear(),this._fromData(t,this._drawCurve.bind(this),this._drawDot.bind(this)),this._data=this._data.concat(t)}toData(){return this._data}_getPointGroupOptions(t){return{penColor:t&&"penColor"in t?t.penColor:this.penColor,dotSize:t&&"dotSize"in t?t.dotSize:this.dotSize,minWidth:t&&"minWidth"in t?t.minWidth:this.minWidth,maxWidth:t&&"maxWidth"in t?t.maxWidth:this.maxWidth,velocityFilterWeight:t&&"velocityFilterWeight"in t?t.velocityFilterWeight:this.velocityFilterWeight,compositeOperation:t&&"compositeOperation"in t?t.compositeOperation:this.compositeOperation}}_strokeBegin(t){if(!this.dispatchEvent(new CustomEvent("beginStroke",{detail:t,cancelable:!0})))return;this._drawingStroke=!0;const e=this._getPointGroupOptions(),i=Object.assign(Object.assign({},e),{points:[]});this._data.push(i),this._reset(e),this._strokeUpdate(t)}_strokeUpdate(t){if(!this._drawingStroke)return;if(0===this._data.length)return void this._strokeBegin(t);this.dispatchEvent(new CustomEvent("beforeUpdateStroke",{detail:t}));const e=t.clientX,i=t.clientY,s=void 0!==t.pressure?t.pressure:void 0!==t.force?t.force:0,n=this._createPoint(e,i,s),o=this._data[this._data.length-1],h=o.points,r=h.length>0&&h[h.length-1],a=!!r&&n.distanceTo(r)<=this.minDistance,c=this._getPointGroupOptions(o);if(!r||!r||!a){const t=this._addPoint(n,c);r?t&&this._drawCurve(t,c):this._drawDot(n,c),h.push({time:n.time,x:n.x,y:n.y,pressure:n.pressure})}this.dispatchEvent(new CustomEvent("afterUpdateStroke",{detail:t}))}_strokeEnd(t){this._drawingStroke&&(this._strokeUpdate(t),this._drawingStroke=!1,this.dispatchEvent(new CustomEvent("endStroke",{detail:t})))}_handlePointerEvents(){this._drawingStroke=!1,this.canvas.addEventListener("pointerdown",this._handlePointerStart),this.canvas.addEventListener("pointermove",this._handlePointerMove),this.canvas.ownerDocument.addEventListener("pointerup",this._handlePointerEnd)}_handleMouseEvents(){this._drawingStroke=!1,this.canvas.addEventListener("mousedown",this._handleMouseDown),this.canvas.addEventListener("mousemove",this._handleMouseMove),this.canvas.ownerDocument.addEventListener("mouseup",this._handleMouseUp)}_handleTouchEvents(){this.canvas.addEventListener("touchstart",this._handleTouchStart),this.canvas.addEventListener("touchmove",this._handleTouchMove),this.canvas.addEventListener("touchend",this._handleTouchEnd)}_reset(t){this._lastPoints=[],this._lastVelocity=0,this._lastWidth=(t.minWidth+t.maxWidth)/2,this._ctx.fillStyle=t.penColor,this._ctx.globalCompositeOperation=t.compositeOperation}_createPoint(e,i,s){const n=this.canvas.getBoundingClientRect();return new t(e-n.left,i-n.top,s,(new Date).getTime())}_addPoint(t,i){const{_lastPoints:s}=this;if(s.push(t),s.length>2){3===s.length&&s.unshift(s[0]);const t=this._calculateCurveWidths(s[1],s[2],i),n=e.fromPoints(s,t);return s.shift(),n}return null}_calculateCurveWidths(t,e,i){const s=i.velocityFilterWeight*e.velocityFrom(t)+(1-i.velocityFilterWeight)*this._lastVelocity,n=this._strokeWidth(s,i),o={end:n,start:this._lastWidth};return this._lastVelocity=s,this._lastWidth=n,o}_strokeWidth(t,e){return Math.max(e.maxWidth/(t+1),e.minWidth)}_drawCurveSegment(t,e,i){const s=this._ctx;s.moveTo(t,e),s.arc(t,e,i,0,2*Math.PI,!1),this._isEmpty=!1}_drawCurve(t,e){const i=this._ctx,s=t.endWidth-t.startWidth,n=2*Math.ceil(t.length());i.beginPath(),i.fillStyle=e.penColor;for(let i=0;i0?e.dotSize:(e.minWidth+e.maxWidth)/2;i.beginPath(),this._drawCurveSegment(t.x,t.y,s),i.closePath(),i.fillStyle=e.penColor,i.fill()}_fromData(e,i,s){for(const n of e){const{points:e}=n,o=this._getPointGroupOptions(n);if(e.length>1)for(let s=0;s{const i=document.createElement("path");if(!(isNaN(t.control1.x)||isNaN(t.control1.y)||isNaN(t.control2.x)||isNaN(t.control2.y))){const s=`M ${t.startPoint.x.toFixed(3)},${t.startPoint.y.toFixed(3)} C ${t.control1.x.toFixed(3)},${t.control1.y.toFixed(3)} ${t.control2.x.toFixed(3)},${t.control2.y.toFixed(3)} ${t.endPoint.x.toFixed(3)},${t.endPoint.y.toFixed(3)}`;i.setAttribute("d",s),i.setAttribute("stroke-width",(2.25*t.endWidth).toFixed(3)),i.setAttribute("stroke",e),i.setAttribute("fill","none"),i.setAttribute("stroke-linecap","round"),o.appendChild(i)}}),((t,{penColor:e,dotSize:i,minWidth:s,maxWidth:n})=>{const h=document.createElement("circle"),r=i>0?i:(s+n)/2;h.setAttribute("r",r.toString()),h.setAttribute("cx",t.x.toString()),h.setAttribute("cy",t.y.toString()),h.setAttribute("fill",e),o.appendChild(h)})),o.outerHTML}}return s})); +//# sourceMappingURL=signature_pad.umd.min.js.map \ No newline at end of file diff --git a/public/plugins/vue/tt-components/css/tt-table.css b/public/plugins/vue/tt-components/css/tt-table.css index 5f705b584..39a87672b 100644 --- a/public/plugins/vue/tt-components/css/tt-table.css +++ b/public/plugins/vue/tt-components/css/tt-table.css @@ -151,6 +151,17 @@ input[type=number]::-webkit-outer-spin-button { .tt-table.table-sm > tbody > tr > td * { font-size: 16px !important; } + + .modal-footer { + display: grid; + grid-template-columns: 1fr 1fr; + grid-gap: 4px; + } + + .modal-footer > button { + margin: 0 !important; + } + } td { diff --git a/public/plugins/vue/tt-components/tt-autocomplete.js b/public/plugins/vue/tt-components/tt-autocomplete.js index 7fbc64bd5..e50065751 100644 --- a/public/plugins/vue/tt-components/tt-autocomplete.js +++ b/public/plugins/vue/tt-components/tt-autocomplete.js @@ -5,7 +5,7 @@ Vue.component('tt-autocomplete', { template: `
`, // TODO: Implement giving the option without the need of an API || need to use computed property to filter the items // TODO: Fix the weirdness with timeout and selecting the suggestion - props: { + props: { value: {type: [String, Number]}, label: {type: String, required: false}, apiUrl: String, @@ -76,29 +77,38 @@ Vue.component('tt-autocomplete', { sm: {type: Boolean, default: true}, row: {type: Boolean, default: false}, }, async mounted() { - if (this.value && this.apiUrl) { - const response = await axios.get(`${this.apiUrl}&autocomplete=1&searchedID=${this.value}`); - this.displayValue = response.data[0].text; - } else if (this.value) { - const selectedItem = this.items.find(item => item.value === this.value); - this.displayValue = selectedItem ? selectedItem.text : ''; - } else { - this.$emit('input', ''); - this.displayValue = ''; - } - + this.updateDisplayValue().then(); }, data() { return { - displayingItems: [], displayValue: '', isLoading: false, showSuggestions: false, cursor: -1, fetchSuggestionsDebounceTimer: null, + displayingItems: [], displayValue: '', isLoading: false, showSuggestions: false, cursor: -1, fetchSuggestionsDebounceTimer: null, disableIDFetch: false }; }, watch: { - value(newValue) { - const selectedItem = this.displayingItems.find(item => item.value === newValue); - this.displayValue = selectedItem ? selectedItem.text : ''; - }, apiUrl() { + value: {handler: 'updateDisplayValue', immediate: true}, + apiUrl() { this.fetchSuggestions(); }, }, methods: { + async updateDisplayValue(newValue) { + if (this.disableIDFetch) { + this.disableIDFetch = false; + return; + } + if (newValue) { + this.value = newValue; + } + + + if (this.value && this.apiUrl) { + const response = await axios.get(`${this.apiUrl}&autocomplete=1&searchedID=${this.value}`); + this.displayValue = response.data[0].text; + } else if (this.value) { + const selectedItem = this.items.find(item => item.value === this.value); + this.displayValue = selectedItem ? selectedItem.text : ''; + } else { + this.$emit('input', ''); + this.displayValue = ''; + } + }, onInput(event) { this.displayValue = event.target.value; this.$emit('input', ''); @@ -157,6 +167,7 @@ Vue.component('tt-autocomplete', { }, 100); }, 300); // Adjust the 300ms debounce time as needed }, selectSuggestion(item) { + this.disableIDFetch = true; this.$emit('input', item.value); this.displayValue = item.text; this.showSuggestions = false; diff --git a/public/plugins/vue/tt-components/tt-input.js b/public/plugins/vue/tt-components/tt-input.js index bd35fd7c3..df7cdad69 100644 --- a/public/plugins/vue/tt-components/tt-input.js +++ b/public/plugins/vue/tt-components/tt-input.js @@ -2,6 +2,7 @@ Vue.component('tt-input', { props: { label: String, type: String, + disabled: Boolean, placeholder: String, required: Boolean, row: Boolean, @@ -34,6 +35,7 @@ Vue.component('tt-input', { :class="{'form-control-sm': sm, 'col-sm-8': row}" :placeholder="placeholder" :required="required" + :disabled="disabled" v-bind="additionalProps" v-model="inputValue" @input="$emit('input', $event.target.value)" diff --git a/public/plugins/vue/tt-components/tt-modal.js b/public/plugins/vue/tt-components/tt-modal.js index c4ad02e9a..fe1ee8e9d 100644 --- a/public/plugins/vue/tt-components/tt-modal.js +++ b/public/plugins/vue/tt-components/tt-modal.js @@ -52,8 +52,8 @@ Vue.component('tt-modal', { @mousedown="$emit('update:show', false)" @keydown.esc="$emit('update:show', false)" v-if="show"> -