From 6aa31147dffde5bde03e85d8b74b704bb768323c Mon Sep 17 00:00:00 2001 From: Ronny Grobel Date: Sun, 19 Apr 2026 21:59:40 +0200 Subject: [PATCH] Bump version to 1.17.8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Feat: Verbrauchsauswertung – Ablesung korrigieren (Datum, Zählerstand, Korrekturnotiz) und löschen direkt aus der Tabelle (manage_kleingarten) - Feat: Inventarverwaltung – Gegenstände erfassen, bearbeiten, löschen; Ausleihe und Rückgabe je Mitglied mit Notiz und Fälligkeitsdatum tracken; Export/Import integriert (InventoryRepository, Schema, Validator, DataTransfer) - Feat: Jahresabrechnung Sperrstatus – Festschreiben/Freigeben mit serverseitiger Prüfung aller Schreibzugriffe auf Kosten und Preise --- .gitignore | 0 README.md | 11 +- assets/css/admin.css | 0 assets/js/chat.js | 0 includes/Activator.php | 0 includes/Admin/Admin.php | 651 +++++++++++++++++- includes/Autoloader.php | 0 includes/DataTransfer.php | 2 + includes/Deactivator.php | 0 includes/Plugin.php | 0 includes/Repositories/AbstractRepository.php | 0 .../Repositories/AssignmentRepository.php | 0 includes/Repositories/CostRepository.php | 69 ++ includes/Repositories/InventoryRepository.php | 336 +++++++++ .../Repositories/MeterReadingRepository.php | 21 + includes/Repositories/MeterRepository.php | 0 includes/Repositories/ParcelRepository.php | 0 includes/Repositories/SectionRepository.php | 0 includes/Repositories/TenantRepository.php | 0 includes/Repositories/WorkRepository.php | 0 includes/Roles.php | 0 includes/Schema.php | 45 ++ includes/Services/ParcelService.php | 0 includes/Validator.php | 85 +++ kgv-verein-manager.php | 9 +- readme.txt | 11 +- uninstall.php | 0 27 files changed, 1219 insertions(+), 21 deletions(-) mode change 100644 => 100755 .gitignore mode change 100644 => 100755 README.md mode change 100644 => 100755 assets/css/admin.css mode change 100644 => 100755 assets/js/chat.js mode change 100644 => 100755 includes/Activator.php mode change 100644 => 100755 includes/Admin/Admin.php mode change 100644 => 100755 includes/Autoloader.php mode change 100644 => 100755 includes/DataTransfer.php mode change 100644 => 100755 includes/Deactivator.php mode change 100644 => 100755 includes/Plugin.php mode change 100644 => 100755 includes/Repositories/AbstractRepository.php mode change 100644 => 100755 includes/Repositories/AssignmentRepository.php mode change 100644 => 100755 includes/Repositories/CostRepository.php create mode 100644 includes/Repositories/InventoryRepository.php mode change 100644 => 100755 includes/Repositories/MeterReadingRepository.php mode change 100644 => 100755 includes/Repositories/MeterRepository.php mode change 100644 => 100755 includes/Repositories/ParcelRepository.php mode change 100644 => 100755 includes/Repositories/SectionRepository.php mode change 100644 => 100755 includes/Repositories/TenantRepository.php mode change 100644 => 100755 includes/Repositories/WorkRepository.php mode change 100644 => 100755 includes/Roles.php mode change 100644 => 100755 includes/Schema.php mode change 100644 => 100755 includes/Services/ParcelService.php mode change 100644 => 100755 includes/Validator.php mode change 100644 => 100755 kgv-verein-manager.php mode change 100644 => 100755 readme.txt mode change 100644 => 100755 uninstall.php diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 diff --git a/README.md b/README.md old mode 100644 new mode 100755 index cddb118..5d5d907 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Contributors: ronnygrobel Tags: verein, mitgliederverwaltung, parzellen, zaehler, abrechnung Requires at least: 6.0 Tested up to: 6.8 -Stable tag: 1.17.5 +Stable tag: 1.17.8 Requires PHP: 7.2 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -43,6 +43,15 @@ Ja, insbesondere fuer Kleingartenvereine und deren Verwaltungsprozesse. == Changelog == += 1.17.8 = +Feat: Verbrauchsauswertung – Ablesung korrigieren (Datum, Zählerstand, Korrekturnotiz) und löschen direkt aus der Tabelle. + += 1.17.7 = +Feat: Inventarverwaltung – Gegenstände (Werkzeug etc.) erfassen, bearbeiten, löschen. Ausleihe und Rückgabe je Mitglied mit Notiz und Fälligkeitsdatum tracken. Export/Import integriert. + += 1.17.6 = +Feat: Jahresabrechnung kann festgeschrieben (gesperrt) werden. Alle Schreibzugriffe auf Kosten, Preise und Parzellenzu­ordnungen prüfen den Sperrstatus serverseitig. + = 1.17.5 = Fix: Spaltenbreiten in Abrechnungstabellen angepasst für bessere Lesbarkeit langer Texte. diff --git a/assets/css/admin.css b/assets/css/admin.css old mode 100644 new mode 100755 diff --git a/assets/js/chat.js b/assets/js/chat.js old mode 100644 new mode 100755 diff --git a/includes/Activator.php b/includes/Activator.php old mode 100644 new mode 100755 diff --git a/includes/Admin/Admin.php b/includes/Admin/Admin.php old mode 100644 new mode 100755 index 80a9418..f86b1a4 --- a/includes/Admin/Admin.php +++ b/includes/Admin/Admin.php @@ -14,6 +14,7 @@ use KGV\VereinManager\Repositories\ChatRepository; use KGV\VereinManager\Repositories\CostRepository; use KGV\VereinManager\Repositories\MeterReadingRepository; use KGV\VereinManager\Repositories\MeterRepository; +use KGV\VereinManager\Repositories\InventoryRepository; use KGV\VereinManager\Repositories\ParcelRepository; use KGV\VereinManager\Repositories\SectionRepository; use KGV\VereinManager\Repositories\TenantRepository; @@ -41,6 +42,7 @@ class Admin { private $parcels; private $meters; private $readings; + private $inventory; private $tenants; private $assignments; private $chat; @@ -58,6 +60,7 @@ class Admin { $this->parcels = new ParcelRepository(); $this->meters = new MeterRepository(); $this->readings = new MeterReadingRepository(); + $this->inventory = new InventoryRepository(); $this->tenants = new TenantRepository(); $this->assignments = new AssignmentRepository(); $this->chat = new ChatRepository(); @@ -106,6 +109,7 @@ class Admin { add_submenu_page( 'kgvvm-dashboard', __( 'Sparten', KGVVM_TEXT_DOMAIN ), __( 'Sparten', KGVVM_TEXT_DOMAIN ), 'edit_sparten', 'kgvvm-sparten', array( $this, 'render_sections_page' ) ); add_submenu_page( 'kgvvm-dashboard', __( 'Parzellen', KGVVM_TEXT_DOMAIN ), __( 'Parzellen', KGVVM_TEXT_DOMAIN ), 'edit_parzellen', 'kgvvm-parzellen', array( $this, 'render_parcels_page' ) ); add_submenu_page( 'kgvvm-dashboard', __( 'Zähler', KGVVM_TEXT_DOMAIN ), __( 'Zähler', KGVVM_TEXT_DOMAIN ), 'edit_zaehler', 'kgvvm-zaehler', array( $this, 'render_meters_page' ) ); + add_submenu_page( 'kgvvm-dashboard', __( 'Inventar', KGVVM_TEXT_DOMAIN ), __( 'Inventar', KGVVM_TEXT_DOMAIN ), $cap, 'kgvvm-inventory', array( $this, 'render_inventory_page' ) ); add_submenu_page( 'kgvvm-dashboard', __( 'Verbrauch', KGVVM_TEXT_DOMAIN ), __( 'Verbrauch', KGVVM_TEXT_DOMAIN ), $cap, 'kgvvm-consumption', array( $this, 'render_consumption_page' ) ); add_submenu_page( 'kgvvm-dashboard', __( 'Kosten', KGVVM_TEXT_DOMAIN ), __( 'Kosten', KGVVM_TEXT_DOMAIN ), $cap, 'kgvvm-costs', array( $this, 'render_costs_page' ) ); add_submenu_page( 'kgvvm-dashboard', __( 'Arbeitsstunden', KGVVM_TEXT_DOMAIN ), __( 'Arbeitsstunden', KGVVM_TEXT_DOMAIN ), $cap, 'kgvvm-arbeit', array( $this, 'render_work_page' ) ); @@ -234,6 +238,18 @@ class Admin { case 'save_meter_reading': $this->save_meter_reading(); break; + case 'correct_meter_reading': + $this->correct_meter_reading(); + break; + case 'save_inventory_item': + $this->save_inventory_item(); + break; + case 'borrow_inventory_item': + $this->borrow_inventory_item(); + break; + case 'return_inventory_loan': + $this->return_inventory_loan(); + break; case 'save_settings': $this->save_settings(); break; @@ -252,6 +268,9 @@ class Admin { case 'toggle_parcel_cost_assignment': $this->toggle_parcel_cost_assignment(); break; + case 'set_statement_lock': + $this->set_statement_lock(); + break; } } @@ -296,6 +315,25 @@ class Admin { $this->meters->delete( $id ); $this->redirect_with_notice( 'kgvvm-zaehler', 'success', __( 'Zähler wurde gelöscht.', KGVVM_TEXT_DOMAIN ) ); break; + case 'delete_meter_reading': + $this->require_cap( 'manage_kleingarten' ); + if ( ! wp_verify_nonce( $nonce, 'kgvvm_delete_meter_reading_' . $id ) ) { + $this->redirect_with_notice( 'kgvvm-consumption', 'error', __( 'Der Löschvorgang wurde aus Sicherheitsgründen abgebrochen.', KGVVM_TEXT_DOMAIN ) ); + } + $this->readings->delete( $id ); + $this->redirect_with_notice( 'kgvvm-consumption', 'success', __( 'Die Ablesung wurde gelöscht.', KGVVM_TEXT_DOMAIN ) ); + break; + case 'delete_inventory_item': + $this->require_cap( 'manage_kleingarten' ); + if ( ! wp_verify_nonce( $nonce, 'kgvvm_delete_inventory_item_' . $id ) ) { + $this->redirect_with_notice( 'kgvvm-inventory', 'error', __( 'Der Löschvorgang für den Inventargegenstand wurde aus Sicherheitsgründen abgebrochen.', KGVVM_TEXT_DOMAIN ) ); + } + if ( $this->inventory->has_open_loans( $id ) ) { + $this->redirect_with_notice( 'kgvvm-inventory', 'error', __( 'Der Inventargegenstand hat noch offene Ausleihen und kann nicht gelöscht werden.', KGVVM_TEXT_DOMAIN ) ); + } + $this->inventory->delete( $id ); + $this->redirect_with_notice( 'kgvvm-inventory', 'success', __( 'Inventargegenstand wurde gelöscht.', KGVVM_TEXT_DOMAIN ) ); + break; case 'delete_tenant': $this->require_cap( 'edit_paechter' ); if ( ! wp_verify_nonce( $nonce, 'kgvvm_delete_tenant_' . $id ) ) { @@ -316,6 +354,9 @@ class Admin { $this->redirect_with_notice( 'kgvvm-costs', 'error', __( 'Der gewünschte Kostenposten wurde nicht gefunden.', KGVVM_TEXT_DOMAIN ), array( 'year' => $year ) ); } $year = (int) $cost->entry_year; + if ( $this->costs->is_statement_locked( $year ) ) { + $this->redirect_with_notice( 'kgvvm-costs', 'error', __( 'Die Jahresabrechnung ist festgeschrieben. Änderungen sind erst nach erneuter Freigabe möglich.', KGVVM_TEXT_DOMAIN ), array( 'year' => $year ) ); + } $this->costs->delete_assignments_for_entry( $id ); $this->costs->delete( $id ); $this->redirect_with_notice( 'kgvvm-costs', 'success', __( 'Kostenposten wurde gelöscht.', KGVVM_TEXT_DOMAIN ), array( 'year' => $year ) ); @@ -660,6 +701,52 @@ class Admin { $this->redirect_with_notice( $return_page, 'success', __( 'Die Ablesung wurde gespeichert.', KGVVM_TEXT_DOMAIN ), $return_args ); } + /** + * Correct an existing meter reading (admin only). + * + * @return void + */ + private function correct_meter_reading() { + $this->require_cap( 'manage_kleingarten' ); + check_admin_referer( 'kgvvm_correct_meter_reading' ); + + $id = absint( isset( $_POST['reading_id'] ) ? $_POST['reading_id'] : 0 ); + $value = isset( $_POST['reading_value'] ) ? (float) wp_unslash( $_POST['reading_value'] ) : null; + $date = isset( $_POST['reading_date'] ) ? sanitize_text_field( wp_unslash( $_POST['reading_date'] ) ) : ''; + $note = isset( $_POST['note'] ) ? sanitize_textarea_field( wp_unslash( $_POST['note'] ) ) : ''; + + $return_args = array_filter( + array( + 'section_id' => absint( isset( $_POST['section_id'] ) ? $_POST['section_id'] : 0 ), + 'date_from' => sanitize_text_field( wp_unslash( isset( $_POST['date_from'] ) ? $_POST['date_from'] : '' ) ), + 'date_to' => sanitize_text_field( wp_unslash( isset( $_POST['date_to'] ) ? $_POST['date_to'] : '' ) ), + 'order' => sanitize_key( wp_unslash( isset( $_POST['order'] ) ? $_POST['order'] : 'DESC' ) ), + ), + static function( $v ) { return '' !== $v && 0 !== $v; } + ); + + $reading = $id ? $this->readings->find( $id ) : null; + + if ( ! $reading ) { + $this->redirect_with_notice( 'kgvvm-consumption', 'error', __( 'Die Ablesung wurde nicht gefunden.', KGVVM_TEXT_DOMAIN ), $return_args ); + } + + if ( null === $value || '' === $date ) { + $this->redirect_with_notice( 'kgvvm-consumption', 'error', __( 'Bitte Zählerstand und Datum angeben.', KGVVM_TEXT_DOMAIN ), $return_args ); + } + + $this->readings->update_reading( + $id, + array( + 'reading_value' => $value, + 'reading_date' => $date, + 'note' => $note, + ) + ); + + $this->redirect_with_notice( 'kgvvm-consumption', 'success', __( 'Die Ablesung wurde korrigiert.', KGVVM_TEXT_DOMAIN ), $return_args ); + } + /** * Save general settings. * @@ -794,6 +881,10 @@ class Admin { $this->redirect_with_notice( 'kgvvm-costs', 'error', $errors->get_error_message(), array( 'year' => $data['entry_year'] ) ); } + if ( $this->costs->is_statement_locked( (int) $data['entry_year'] ) ) { + $this->redirect_with_notice( 'kgvvm-costs', 'error', __( 'Die Jahresabrechnung ist festgeschrieben. Änderungen sind erst nach erneuter Freigabe möglich.', KGVVM_TEXT_DOMAIN ), array( 'year' => $data['entry_year'] ) ); + } + if ( ! $this->costs->save_section_prices( $data ) ) { $this->redirect_with_notice( 'kgvvm-costs', 'error', __( 'Die Preise für diese Sparte konnten nicht gespeichert werden.', KGVVM_TEXT_DOMAIN ), array( 'year' => $data['entry_year'] ) ); } @@ -813,11 +904,20 @@ class Admin { $id = absint( isset( $_POST['id'] ) ? $_POST['id'] : 0 ); $data = $this->validator->sanitize_cost_entry( $_POST ); $errors = $this->validator->validate_cost_entry( $data ); + $existing = $id > 0 ? $this->costs->find( $id ) : null; if ( $errors->has_errors() ) { $this->redirect_with_notice( 'kgvvm-costs', 'error', $errors->get_error_message(), array( 'year' => $data['entry_year'], 'id' => $id ) ); } + if ( $existing && $this->costs->is_statement_locked( (int) $existing->entry_year ) ) { + $this->redirect_with_notice( 'kgvvm-costs', 'error', __( 'Die Jahresabrechnung ist festgeschrieben. Änderungen sind erst nach erneuter Freigabe möglich.', KGVVM_TEXT_DOMAIN ), array( 'year' => (int) $existing->entry_year, 'id' => $id ) ); + } + + if ( $this->costs->is_statement_locked( (int) $data['entry_year'] ) ) { + $this->redirect_with_notice( 'kgvvm-costs', 'error', __( 'Die Jahresabrechnung ist festgeschrieben. Änderungen sind erst nach erneuter Freigabe möglich.', KGVVM_TEXT_DOMAIN ), array( 'year' => $data['entry_year'], 'id' => $id ) ); + } + $active_parcels = array_values( array_filter( $this->parcels->search(), @@ -857,6 +957,10 @@ class Admin { $this->redirect_with_notice( 'kgvvm-costs', 'error', __( 'Ungültige Anfrage.', KGVVM_TEXT_DOMAIN ), array( 'view' => 'statement', 'statement_type' => 'parcel', 'subject_id' => $parcel_id, 'year' => $year ) ); } + if ( $this->costs->is_statement_locked( $year ) ) { + $this->redirect_with_notice( 'kgvvm-costs', 'error', __( 'Die Jahresabrechnung ist festgeschrieben. Änderungen sind erst nach erneuter Freigabe möglich.', KGVVM_TEXT_DOMAIN ), array( 'view' => 'statement', 'statement_type' => 'parcel', 'subject_id' => $parcel_id, 'year' => $year ) ); + } + if ( 'add' === $mode ) { $this->costs->assign_to_parcel( $parcel_id, $cost_entry_id ); } else { @@ -867,6 +971,401 @@ class Admin { exit; } + /** + * Lock or unlock annual statement changes for one year. + * + * @return void + */ + private function set_statement_lock() { + $this->require_cap( 'manage_kleingarten' ); + $year = absint( isset( $_POST['year'] ) ? $_POST['year'] : current_time( 'Y' ) ); + $statement_type = isset( $_POST['statement_type'] ) ? sanitize_key( wp_unslash( $_POST['statement_type'] ) ) : 'parcel'; + $subject_id = absint( isset( $_POST['subject_id'] ) ? $_POST['subject_id'] : 0 ); + $mode = isset( $_POST['lock_mode'] ) ? sanitize_key( wp_unslash( $_POST['lock_mode'] ) ) : ''; + + check_admin_referer( 'kgvvm_set_statement_lock_' . $year ); + + if ( $year < 1 || ! in_array( $mode, array( 'lock', 'unlock' ), true ) ) { + $this->redirect_with_notice( 'kgvvm-costs', 'error', __( 'Ungültige Anfrage.', KGVVM_TEXT_DOMAIN ), array( 'view' => 'statement', 'statement_type' => $statement_type, 'subject_id' => $subject_id, 'year' => $year ) ); + } + + $is_locked = 'lock' === $mode; + $saved = $this->costs->set_statement_lock( $year, $is_locked, get_current_user_id() ); + + if ( ! $saved ) { + $this->redirect_with_notice( 'kgvvm-costs', 'error', __( 'Der Sperrstatus der Jahresabrechnung konnte nicht gespeichert werden.', KGVVM_TEXT_DOMAIN ), array( 'view' => 'statement', 'statement_type' => $statement_type, 'subject_id' => $subject_id, 'year' => $year ) ); + } + + $this->redirect_with_notice( + 'kgvvm-costs', + 'success', + $is_locked ? __( 'Jahresabrechnung wurde festgeschrieben.', KGVVM_TEXT_DOMAIN ) : __( 'Jahresabrechnung wurde erneut freigegeben.', KGVVM_TEXT_DOMAIN ), + array( 'view' => 'statement', 'statement_type' => $statement_type, 'subject_id' => $subject_id, 'year' => $year ) + ); + } + + /** + * Save one inventory item. + * + * @return void + */ + private function save_inventory_item() { + $this->require_cap( 'manage_kleingarten' ); + check_admin_referer( 'kgvvm_save_inventory_item' ); + + $id = absint( isset( $_POST['id'] ) ? $_POST['id'] : 0 ); + $data = $this->validator->sanitize_inventory_item( $_POST ); + $errors = $this->validator->validate_inventory_item( $data ); + $existing = $id > 0 ? $this->inventory->find( $id ) : null; + + if ( $existing ) { + $borrowed_qty = $this->inventory->get_open_borrowed_quantity( $id ); + + if ( (int) $data['total_quantity'] < $borrowed_qty ) { + $errors->add( 'inventory_total_too_low', __( 'Die Gesamtmenge ist kleiner als aktuell ausgeliehen. Bitte zuerst Rückgaben erfassen.', KGVVM_TEXT_DOMAIN ) ); + } + } + + if ( $errors->has_errors() ) { + $this->redirect_with_notice( 'kgvvm-inventory', 'error', $errors->get_error_message(), array( 'id' => $id ) ); + } + + $saved = $this->inventory->save_item( $data, $id ); + + if ( ! $saved ) { + $this->redirect_with_notice( 'kgvvm-inventory', 'error', __( 'Der Inventargegenstand konnte nicht gespeichert werden.', KGVVM_TEXT_DOMAIN ), array( 'id' => $id ) ); + } + + $this->redirect_with_notice( 'kgvvm-inventory', 'success', __( 'Inventargegenstand wurde gespeichert.', KGVVM_TEXT_DOMAIN ) ); + } + + /** + * Borrow inventory item for one member. + * + * @return void + */ + private function borrow_inventory_item() { + $this->require_cap( 'manage_kleingarten' ); + check_admin_referer( 'kgvvm_borrow_inventory_item' ); + + $data = $this->validator->sanitize_inventory_loan( $_POST ); + $errors = $this->validator->validate_inventory_loan( $data ); + + $item = $this->inventory->find( $data['item_id'] ); + + if ( ! $item ) { + $errors->add( 'inventory_item_missing', __( 'Der gewählte Inventargegenstand wurde nicht gefunden.', KGVVM_TEXT_DOMAIN ) ); + } elseif ( ! (bool) $item->is_active ) { + $errors->add( 'inventory_item_inactive', __( 'Der gewählte Inventargegenstand ist inaktiv.', KGVVM_TEXT_DOMAIN ) ); + } elseif ( (int) $data['quantity'] > (int) $item->available_quantity ) { + $errors->add( 'inventory_item_not_available', __( 'Die gewünschte Menge ist nicht verfügbar.', KGVVM_TEXT_DOMAIN ) ); + } + + if ( $errors->has_errors() ) { + $this->redirect_with_notice( 'kgvvm-inventory', 'error', $errors->get_error_message() ); + } + + $saved = $this->inventory->borrow_item( $data['item_id'], $data['user_id'], $data['quantity'], $data['due_date'], $data['note'] ); + + if ( ! $saved ) { + $this->redirect_with_notice( 'kgvvm-inventory', 'error', __( 'Die Ausleihe konnte nicht gespeichert werden.', KGVVM_TEXT_DOMAIN ) ); + } + + $this->redirect_with_notice( 'kgvvm-inventory', 'success', __( 'Ausleihe wurde erfasst.', KGVVM_TEXT_DOMAIN ) ); + } + + /** + * Return one inventory loan. + * + * @return void + */ + private function return_inventory_loan() { + $this->require_cap( 'manage_kleingarten' ); + check_admin_referer( 'kgvvm_return_inventory_loan' ); + + $loan_id = absint( isset( $_POST['loan_id'] ) ? $_POST['loan_id'] : 0 ); + $return_note = sanitize_textarea_field( wp_unslash( isset( $_POST['return_note'] ) ? $_POST['return_note'] : '' ) ); + + if ( $loan_id < 1 ) { + $this->redirect_with_notice( 'kgvvm-inventory', 'error', __( 'Ungültige Anfrage.', KGVVM_TEXT_DOMAIN ) ); + } + + $saved = $this->inventory->return_loan( $loan_id, $return_note ); + + if ( ! $saved ) { + $this->redirect_with_notice( 'kgvvm-inventory', 'error', __( 'Die Rückgabe konnte nicht gespeichert werden.', KGVVM_TEXT_DOMAIN ) ); + } + + $this->redirect_with_notice( 'kgvvm-inventory', 'success', __( 'Rückgabe wurde erfasst.', KGVVM_TEXT_DOMAIN ) ); + } + + /** + * Render inventory management page. + * + * @return void + */ + public function render_inventory_page() { + $this->require_cap( 'manage_kleingarten' ); + + $search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : ''; + $edit_id = absint( isset( $_GET['id'] ) ? $_GET['id'] : 0 ); + $item = $edit_id > 0 ? $this->inventory->find( $edit_id ) : null; + + $items = $this->inventory->search_items( array( 's' => $search, 'orderby' => isset( $_GET['orderby'] ) ? sanitize_key( wp_unslash( $_GET['orderby'] ) ) : 'name', 'order' => isset( $_GET['order'] ) ? sanitize_key( wp_unslash( $_GET['order'] ) ) : 'ASC' ) ); + $open_loans = $this->inventory->get_open_loans(); + $recent_loans = $this->inventory->get_recent_loans( 20 ); + + $member_query = new \WP_User_Query( + array( + 'role' => Roles::MEMBER_ROLE, + 'fields' => array( 'ID', 'display_name', 'user_email' ), + 'orderby' => 'display_name', + 'order' => 'ASC', + 'number' => 2000, + ) + ); + + $members = (array) $member_query->get_results(); + + if ( empty( $members ) ) { + $members = (array) $this->assignments->get_member_users(); + } + + $summary_total_items = count( $items ); + $summary_available_total = 0; + $summary_open_total = count( $open_loans ); + + foreach ( $items as $row ) { + $summary_available_total += max( 0, (int) $row->available_quantity ); + } + ?> +
+

+ + ' class='page-title-action'> + + render_notice(); ?> + +
+ +
+ ' /> + +
+
+ +
+
+

+

+
+
+

+

+
+
+

+

+
+
+ +
+
+

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
' />
' />
+ +
+ +
+
+ +
+

+
+ + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+
+
+ +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
name ); ?>storage_location ) ? $row->storage_location : '—' ); ?>total_quantity, 0 ) ); ?>available_quantity, 0 ) ); ?>is_active ? __( 'Aktiv', KGVVM_TEXT_DOMAIN ) : __( 'Inaktiv', KGVVM_TEXT_DOMAIN ) ); ?> + $row->id ) ) ); ?>'> + | + 'delete_inventory_item', 'id' => $row->id ) ), 'kgvvm_delete_inventory_item_' . $row->id ) ); ?>' onclick='return confirm("");'> +
+
+ +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
item_name ? $loan->item_name : '—' ); ?>borrower_name ? $loan->borrower_name : '—' ); ?>borrowed_quantity, 0 ) ); ?>borrowed_at ) ? wp_date( 'd.m.Y H:i', strtotime( $loan->borrowed_at ) ) : '—' ); ?>due_date ) ? wp_date( 'd.m.Y', strtotime( $loan->due_date ) ) : '—' ); ?> +
+ + + + ' /> + +
+
+
+ +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + +
borrowed_at ) ? wp_date( 'd.m.Y H:i', strtotime( $row->borrowed_at ) ) : '—' ); ?>item_name ? $row->item_name : '—' ); ?>borrower_name ? $row->borrower_name : '—' ); ?>borrowed_quantity, 0 ) ); ?>status ? __( 'Offen', KGVVM_TEXT_DOMAIN ) : __( 'Zurückgegeben', KGVVM_TEXT_DOMAIN ) ); ?>
+
+
+ require_cap( 'manage_kleingarten' ); - $section_id = isset( $_GET['section_id'] ) ? absint( $_GET['section_id'] ) : 0; - $date_from = isset( $_GET['date_from'] ) ? sanitize_text_field( wp_unslash( $_GET['date_from'] ) ) : ''; - $date_to = isset( $_GET['date_to'] ) ? sanitize_text_field( wp_unslash( $_GET['date_to'] ) ) : ''; - $order = isset( $_GET['order'] ) ? strtoupper( sanitize_key( wp_unslash( $_GET['order'] ) ) ) : 'DESC'; - $order = 'ASC' === $order ? 'ASC' : 'DESC'; - $sections = $this->sections->all_for_options(); + $section_id = isset( $_GET['section_id'] ) ? absint( $_GET['section_id'] ) : 0; + $date_from = isset( $_GET['date_from'] ) ? sanitize_text_field( wp_unslash( $_GET['date_from'] ) ) : ''; + $date_to = isset( $_GET['date_to'] ) ? sanitize_text_field( wp_unslash( $_GET['date_to'] ) ) : ''; + $order = isset( $_GET['order'] ) ? strtoupper( sanitize_key( wp_unslash( $_GET['order'] ) ) ) : 'DESC'; + $order = 'ASC' === $order ? 'ASC' : 'DESC'; + $edit_reading_id = isset( $_GET['edit_reading'] ) ? absint( $_GET['edit_reading'] ) : 0; + $edit_reading = $edit_reading_id ? $this->readings->find( $edit_reading_id ) : null; + $sections = $this->sections->all_for_options(); if ( $date_from && $date_to && strtotime( $date_from ) > strtotime( $date_to ) ) { $tmp = $date_from; @@ -1051,6 +1552,42 @@ class Admin {

render_notice(); ?> + +
+

+
+ + + + + + + + + + + + + + + + + + + + +
' required />
+ +

+
+

+ + $section_id, 'date_from' => $date_from, 'date_to' => $date_to, 'order' => $order ) ) ) ); ?>' class='button'> +

+
+
+ +
@@ -1141,23 +1678,49 @@ class Admin { + - + reading_date ) ) ); ?> section_name ? $row->section_name : '—' ); ?> parcel_label ? $row->parcel_label : '—' ); ?> - meter_type_label( $row->type ) . ' – ' . $row->meter_number ); ?> + + meter_type_label( $row->type ) . ' – ' . $row->meter_number ); ?> + note ) ) : ?> +
📌 note, 0, 60, '…' ) ); ?> + + previous_value ? $this->format_meter_value_with_unit( $row->previous_value, $row->type ) : '—' ); ?> format_meter_value_with_unit( $row->reading_value, $row->type ) ); ?> consumption ? $this->format_meter_value_with_unit( $row->consumption, $row->type ) : '—' ); ?> unit_price ? $this->format_price_per_unit( $row->unit_price, 'power' === $row->type ? 'kWh' : 'm³' ) : '—' ); ?> calculated_cost ? $this->format_currency( $row->calculated_cost ) : '—' ); ?> + + $section_id, + 'date_from' => $date_from, + 'date_to' => $date_to, + 'order' => $order, + ) + ); + $edit_url = $this->admin_url( 'kgvvm-consumption', array_merge( $filter_args, array( 'edit_reading' => $row->id ) ) ); + $delete_url = wp_nonce_url( + $this->admin_url( 'kgvvm-consumption', array_merge( $filter_args, array( 'kgvvm_action' => 'delete_meter_reading', 'id' => $row->id ) ) ), + 'kgvvm_delete_meter_reading_' . $row->id + ); + ?> + + | + ")' style='color:#b32d2e'> + @@ -1193,6 +1756,9 @@ class Admin { $selected_year = (int) $cost->entry_year; } + $year_lock_state = $this->costs->get_statement_lock_state( $selected_year ); + $year_is_locked = ! empty( $year_lock_state['is_locked'] ); + $years = $this->costs->get_years( $selected_year ); $sections = $this->sections->all_for_options( true ); $section_rates = $this->costs->get_section_prices( $selected_year ); @@ -1255,6 +1821,9 @@ class Admin { $selected_year ) ) ); ?>' class='page-title-action'> render_notice(); ?> + +

+ @@ -1327,7 +1896,7 @@ class Admin { ' /> € / m³ - + 'disabled' ) : array() ); ?> $selected_year ) ) ); ?>' class='button-secondary'> @@ -1389,7 +1958,7 @@ class Admin { - + 'disabled' ) : array() ); ?>
@@ -1455,7 +2024,13 @@ class Admin { power_price_per_kwh ? $this->format_price_per_unit( $rate->power_price_per_kwh, 'kWh' ) : '—' ); ?> water_price_per_m3 ? $this->format_price_per_unit( $rate->water_price_per_m3, 'm³' ) : '—' ); ?> updated_at ) ? wp_date( 'd.m.Y H:i', strtotime( $rate->updated_at ) ) : '—' ); ?> - $selected_year, 'edit_rate_section_id' => $rate->section_id ) ) ); ?>'> + + + + + $selected_year, 'edit_rate_section_id' => $rate->section_id ) ) ); ?>'> + + @@ -1496,9 +2071,13 @@ class Admin { note ? $row->note : '—' ); ?> updated_at ) ? wp_date( 'd.m.Y H:i', strtotime( $row->updated_at ) ) : '—' ); ?> - $selected_year, 'id' => $row->id ) ) ); ?>'> - | - 'delete_cost', 'id' => $row->id, 'year' => $selected_year ) ), 'kgvvm_delete_cost_' . $row->id ) ); ?>' onclick='return confirm("");'> + + + + $selected_year, 'id' => $row->id ) ) ); ?>'> + | + 'delete_cost', 'id' => $row->id, 'year' => $selected_year ) ), 'kgvvm_delete_cost_' . $row->id ) ); ?>' onclick='return confirm("");'> + @@ -1605,6 +2184,8 @@ class Admin { $statement_type = isset( $_GET['statement_type'] ) ? sanitize_key( wp_unslash( $_GET['statement_type'] ) ) : 'parcel'; $output = isset( $_GET['output'] ) ? sanitize_key( wp_unslash( $_GET['output'] ) ) : 'html'; $subject_id = absint( isset( $_GET['subject_id'] ) ? $_GET['subject_id'] : 0 ); + $lock_state = $this->costs->get_statement_lock_state( $year ); + $statement_is_locked = ! empty( $lock_state['is_locked'] ); $date_from = sprintf( '%04d-01-01', $year ); $date_to = sprintf( '%04d-12-31', $year ); $all_rows = $this->readings->get_consumption_report( 0, $date_from, $date_to, 'ASC' ); @@ -1803,9 +2384,24 @@ class Admin {

+ +

+ @@ -1912,6 +2508,29 @@ class Admin {
+
+

+

+ + + + + +

+
'> + + + + + + ' /> + + +
+
+

@@ -1937,7 +2556,9 @@ class Admin { name ); ?> - + + +
' style='display:inline;'> diff --git a/includes/Autoloader.php b/includes/Autoloader.php old mode 100644 new mode 100755 diff --git a/includes/DataTransfer.php b/includes/DataTransfer.php old mode 100644 new mode 100755 index c4fc161..32718f2 --- a/includes/DataTransfer.php +++ b/includes/DataTransfer.php @@ -25,6 +25,7 @@ class DataTransfer { 'sections', 'parcels', 'tenants', + 'inventory_items', 'parcel_members', 'parcel_tenants', 'meters', @@ -38,6 +39,7 @@ class DataTransfer { 'work_year_config', 'work_logs', 'work_log_members', + 'inventory_loans', ); public function __construct( \wpdb $wpdb ) { diff --git a/includes/Deactivator.php b/includes/Deactivator.php old mode 100644 new mode 100755 diff --git a/includes/Plugin.php b/includes/Plugin.php old mode 100644 new mode 100755 diff --git a/includes/Repositories/AbstractRepository.php b/includes/Repositories/AbstractRepository.php old mode 100644 new mode 100755 diff --git a/includes/Repositories/AssignmentRepository.php b/includes/Repositories/AssignmentRepository.php old mode 100644 new mode 100755 diff --git a/includes/Repositories/CostRepository.php b/includes/Repositories/CostRepository.php old mode 100644 new mode 100755 index a6fde31..b6bbcfb --- a/includes/Repositories/CostRepository.php +++ b/includes/Repositories/CostRepository.php @@ -222,6 +222,75 @@ class CostRepository extends AbstractRepository { return $this->wpdb->get_row( $this->wpdb->prepare( "SELECT * FROM {$year_table} WHERE entry_year = %d", $year ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared } + /** + * Get lock state for one statement year. + * + * @param int $year Selected year. + * @return array + */ + public function get_statement_lock_state( $year ) { + $details = $this->get_year_details( $year ); + + if ( ! $details ) { + return array( + 'is_locked' => false, + 'locked_at' => null, + 'locked_by' => 0, + ); + } + + return array( + 'is_locked' => ! empty( $details->statement_is_locked ) && 1 === (int) $details->statement_is_locked, + 'locked_at' => isset( $details->statement_locked_at ) ? $details->statement_locked_at : null, + 'locked_by' => isset( $details->statement_locked_by ) ? (int) $details->statement_locked_by : 0, + ); + } + + /** + * Check whether statement data for one year is locked. + * + * @param int $year Selected year. + * @return bool + */ + public function is_statement_locked( $year ) { + $state = $this->get_statement_lock_state( $year ); + + return ! empty( $state['is_locked'] ); + } + + /** + * Lock or unlock a statement year. + * + * @param int $year Selected year. + * @param bool $is_locked Target lock state. + * @param int $locked_by User ID that changes the state. + * @return bool + */ + public function set_statement_lock( $year, $is_locked, $locked_by = 0 ) { + $year = absint( $year ); + + if ( $year < 1 || ! $this->ensure_year( $year ) ) { + return false; + } + + $payload = array( + 'statement_is_locked' => $is_locked ? 1 : 0, + 'statement_locked_at' => $is_locked ? $this->now() : null, + 'statement_locked_by' => $is_locked ? absint( $locked_by ) : 0, + 'updated_at' => $this->now(), + ); + + $result = $this->wpdb->update( + $this->year_table(), + $payload, + array( 'entry_year' => $year ), + array( '%d', '%s', '%d', '%s' ), + array( '%d' ) + ); + + return false !== $result; + } + /** * Save section-specific yearly prices. * diff --git a/includes/Repositories/InventoryRepository.php b/includes/Repositories/InventoryRepository.php new file mode 100644 index 0000000..1c7aefe --- /dev/null +++ b/includes/Repositories/InventoryRepository.php @@ -0,0 +1,336 @@ +sanitize_orderby( isset( $args['orderby'] ) ? sanitize_key( wp_unslash( $args['orderby'] ) ) : 'name', array( 'name', 'total_quantity', 'available_quantity', 'is_active', 'updated_at' ), 'name' ); + $order = $this->sanitize_order( isset( $args['order'] ) ? sanitize_key( wp_unslash( $args['order'] ) ) : 'ASC' ); + + $sql = "SELECT * FROM {$this->table} WHERE 1=1"; + $params = array(); + + if ( '' !== $search ) { + $like = '%' . $this->wpdb->esc_like( $search ) . '%'; + $sql .= ' AND (name LIKE %s OR storage_location LIKE %s OR description LIKE %s)'; + $params[] = $like; + $params[] = $like; + $params[] = $like; + } + + if ( in_array( $status, array( 'active', 'inactive' ), true ) ) { + $sql .= ' AND is_active = %d'; + $params[] = 'active' === $status ? 1 : 0; + } + + $sql .= " ORDER BY {$orderby} {$order}, id DESC"; + + if ( ! empty( $params ) ) { + return $this->wpdb->get_results( $this->wpdb->prepare( $sql, $params ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + } + + return $this->wpdb->get_results( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery + } + + /** + * Save or update one inventory item. + * + * @param array $data Item payload. + * @param int $id Optional item ID. + * @return int|false + */ + public function save_item( $data, $id = 0 ) { + $payload = array( + 'name' => $data['name'], + 'total_quantity' => max( 0, (int) $data['total_quantity'] ), + 'available_quantity' => max( 0, (int) $data['available_quantity'] ), + 'storage_location' => isset( $data['storage_location'] ) ? $data['storage_location'] : '', + 'description' => isset( $data['description'] ) ? $data['description'] : '', + 'is_active' => ! empty( $data['is_active'] ) ? 1 : 0, + 'updated_at' => $this->now(), + ); + + if ( $payload['available_quantity'] > $payload['total_quantity'] ) { + $payload['available_quantity'] = $payload['total_quantity']; + } + + if ( $id > 0 ) { + $result = $this->wpdb->update( + $this->table, + $payload, + array( 'id' => absint( $id ) ), + array( '%s', '%d', '%d', '%s', '%s', '%d', '%s' ), + array( '%d' ) + ); + + return false !== $result ? (int) $id : false; + } + + $payload['created_at'] = $this->now(); + $result = $this->wpdb->insert( $this->table, $payload, array( '%s', '%d', '%d', '%s', '%s', '%d', '%s', '%s' ) ); + + return false !== $result ? (int) $this->wpdb->insert_id : false; + } + + /** + * Check whether one item has open loans. + * + * @param int $item_id Item ID. + * @return bool + */ + public function has_open_loans( $item_id ) { + $item_id = absint( $item_id ); + $table = $this->loans_table(); + + if ( $item_id < 1 || '' === $table ) { + return false; + } + + $count = (int) $this->wpdb->get_var( $this->wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE item_id = %d AND status = %s", $item_id, 'open' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + + return $count > 0; + } + + /** + * Get total currently borrowed quantity for one item. + * + * @param int $item_id Item ID. + * @return int + */ + public function get_open_borrowed_quantity( $item_id ) { + $item_id = absint( $item_id ); + $table = $this->loans_table(); + + if ( $item_id < 1 || '' === $table ) { + return 0; + } + + $sum = (int) $this->wpdb->get_var( $this->wpdb->prepare( "SELECT COALESCE(SUM(borrowed_quantity), 0) FROM {$table} WHERE item_id = %d AND status = %s", $item_id, 'open' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + + return max( 0, $sum ); + } + + /** + * Borrow an inventory item. + * + * @param int $item_id Item ID. + * @param int $user_id Borrower user ID. + * @param int $quantity Quantity. + * @param string $due_date Optional due date. + * @param string $note Optional note. + * @return bool + */ + public function borrow_item( $item_id, $user_id, $quantity, $due_date = '', $note = '' ) { + $item_id = absint( $item_id ); + $user_id = absint( $user_id ); + $quantity = max( 0, (int) $quantity ); + $loan_tab = $this->loans_table(); + + if ( $item_id < 1 || $user_id < 1 || $quantity < 1 || '' === $loan_tab ) { + return false; + } + + $item = $this->find( $item_id ); + + if ( ! $item || (int) $item->available_quantity < $quantity ) { + return false; + } + + $this->wpdb->query( 'START TRANSACTION' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + + $inserted = $this->wpdb->insert( + $loan_tab, + array( + 'item_id' => $item_id, + 'user_id' => $user_id, + 'borrowed_quantity' => $quantity, + 'borrowed_at' => $this->now(), + 'due_date' => '' !== $due_date ? $due_date : null, + 'returned_at' => null, + 'note' => $note, + 'return_note' => '', + 'status' => 'open', + 'created_at' => $this->now(), + 'updated_at' => $this->now(), + ), + array( '%d', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ) + ); + + if ( false === $inserted ) { + $this->wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + return false; + } + + $updated = $this->wpdb->update( + $this->table, + array( + 'available_quantity' => max( 0, (int) $item->available_quantity - $quantity ), + 'updated_at' => $this->now(), + ), + array( 'id' => $item_id ), + array( '%d', '%s' ), + array( '%d' ) + ); + + if ( false === $updated ) { + $this->wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + return false; + } + + $this->wpdb->query( 'COMMIT' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + return true; + } + + /** + * Return one open loan. + * + * @param int $loan_id Loan ID. + * @param string $return_note Optional return note. + * @return bool + */ + public function return_loan( $loan_id, $return_note = '' ) { + $loan_id = absint( $loan_id ); + $loan_tab = $this->loans_table(); + + if ( $loan_id < 1 || '' === $loan_tab ) { + return false; + } + + $loan = $this->wpdb->get_row( $this->wpdb->prepare( "SELECT * FROM {$loan_tab} WHERE id = %d", $loan_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + + if ( ! $loan || 'open' !== $loan->status ) { + return false; + } + + $item = $this->find( (int) $loan->item_id ); + + if ( ! $item ) { + return false; + } + + $this->wpdb->query( 'START TRANSACTION' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + + $updated_loan = $this->wpdb->update( + $loan_tab, + array( + 'status' => 'returned', + 'returned_at' => $this->now(), + 'return_note' => $return_note, + 'updated_at' => $this->now(), + ), + array( 'id' => $loan_id ), + array( '%s', '%s', '%s', '%s' ), + array( '%d' ) + ); + + if ( false === $updated_loan ) { + $this->wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + return false; + } + + $new_available = min( (int) $item->total_quantity, (int) $item->available_quantity + (int) $loan->borrowed_quantity ); + + $updated_item = $this->wpdb->update( + $this->table, + array( + 'available_quantity' => $new_available, + 'updated_at' => $this->now(), + ), + array( 'id' => (int) $loan->item_id ), + array( '%d', '%s' ), + array( '%d' ) + ); + + if ( false === $updated_item ) { + $this->wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + return false; + } + + $this->wpdb->query( 'COMMIT' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + return true; + } + + /** + * Get open loans with item and borrower information. + * + * @return array + */ + public function get_open_loans() { + $table = $this->loans_table(); + + if ( '' === $table ) { + return array(); + } + + $sql = "SELECT l.*, i.name AS item_name, u.display_name AS borrower_name + FROM {$table} l + LEFT JOIN {$this->table} i ON i.id = l.item_id + LEFT JOIN {$this->wpdb->users} u ON u.ID = l.user_id + WHERE l.status = %s + ORDER BY l.borrowed_at DESC, l.id DESC"; + + return $this->wpdb->get_results( $this->wpdb->prepare( $sql, 'open' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + } + + /** + * Get latest loan history entries. + * + * @param int $limit Number of rows. + * @return array + */ + public function get_recent_loans( $limit = 20 ) { + $table = $this->loans_table(); + $limit = max( 1, min( 200, (int) $limit ) ); + + if ( '' === $table ) { + return array(); + } + + $sql = "SELECT l.*, i.name AS item_name, u.display_name AS borrower_name + FROM {$table} l + LEFT JOIN {$this->table} i ON i.id = l.item_id + LEFT JOIN {$this->wpdb->users} u ON u.ID = l.user_id + ORDER BY l.borrowed_at DESC, l.id DESC + LIMIT %d"; + + return $this->wpdb->get_results( $this->wpdb->prepare( $sql, $limit ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + } +} diff --git a/includes/Repositories/MeterReadingRepository.php b/includes/Repositories/MeterReadingRepository.php old mode 100644 new mode 100755 index b7003b8..27e467f --- a/includes/Repositories/MeterReadingRepository.php +++ b/includes/Repositories/MeterReadingRepository.php @@ -244,4 +244,25 @@ class MeterReadingRepository extends AbstractRepository { return array_reverse( array_values( $monthly ) ); } + + /** + * Correct an existing reading (value, date, note). + * + * @param int $id Reading ID. + * @param array $data Corrected data: reading_value, reading_date, note. + * @return int|false + */ + public function update_reading( $id, $data ) { + return $this->wpdb->update( + $this->table, + array( + 'reading_value' => (float) $data['reading_value'], + 'reading_date' => sanitize_text_field( $data['reading_date'] ), + 'note' => sanitize_textarea_field( $data['note'] ), + ), + array( 'id' => absint( $id ) ), + array( '%f', '%s', '%s' ), + array( '%d' ) + ); + } } diff --git a/includes/Repositories/MeterRepository.php b/includes/Repositories/MeterRepository.php old mode 100644 new mode 100755 diff --git a/includes/Repositories/ParcelRepository.php b/includes/Repositories/ParcelRepository.php old mode 100644 new mode 100755 diff --git a/includes/Repositories/SectionRepository.php b/includes/Repositories/SectionRepository.php old mode 100644 new mode 100755 diff --git a/includes/Repositories/TenantRepository.php b/includes/Repositories/TenantRepository.php old mode 100644 new mode 100755 diff --git a/includes/Repositories/WorkRepository.php b/includes/Repositories/WorkRepository.php old mode 100644 new mode 100755 diff --git a/includes/Roles.php b/includes/Roles.php old mode 100644 new mode 100755 diff --git a/includes/Schema.php b/includes/Schema.php old mode 100644 new mode 100755 index 1d46e67..4decee9 --- a/includes/Schema.php +++ b/includes/Schema.php @@ -27,6 +27,8 @@ class Schema { 'parcels' => $wpdb->prefix . 'kgvvm_parcels', 'meters' => $wpdb->prefix . 'kgvvm_meters', 'tenants' => $wpdb->prefix . 'kgvvm_tenants', + 'inventory_items' => $wpdb->prefix . 'kgvvm_inventory_items', + 'inventory_loans' => $wpdb->prefix . 'kgvvm_inventory_loans', 'parcel_members' => $wpdb->prefix . 'kgvvm_parcel_members', 'parcel_tenants' => $wpdb->prefix . 'kgvvm_parcel_tenants', 'chat_messages' => $wpdb->prefix . 'kgvvm_chat_messages', @@ -126,6 +128,42 @@ class Schema { KEY is_active (is_active) ) {$charset_collate};"; + $sql[] = "CREATE TABLE " . self::table( 'inventory_items' ) . " ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(190) NOT NULL, + total_quantity INT UNSIGNED NOT NULL DEFAULT 0, + available_quantity INT UNSIGNED NOT NULL DEFAULT 0, + storage_location VARCHAR(190) NULL, + description TEXT NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY name (name), + KEY is_active (is_active) + ) {$charset_collate};"; + + $sql[] = "CREATE TABLE " . self::table( 'inventory_loans' ) . " ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + item_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + borrowed_quantity INT UNSIGNED NOT NULL, + borrowed_at DATETIME NOT NULL, + due_date DATE NULL, + returned_at DATETIME NULL, + note TEXT NULL, + return_note TEXT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'open', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY item_id (item_id), + KEY user_id (user_id), + KEY status (status), + KEY borrowed_at (borrowed_at), + KEY due_date (due_date) + ) {$charset_collate};"; + $sql[] = "CREATE TABLE " . self::table( 'parcel_members' ) . " ( parcel_id BIGINT UNSIGNED NOT NULL, user_id BIGINT UNSIGNED NOT NULL, @@ -175,6 +213,9 @@ class Schema { entry_year SMALLINT UNSIGNED NOT NULL, power_price_per_kwh DECIMAL(12,4) NULL, water_price_per_m3 DECIMAL(12,4) NULL, + statement_is_locked TINYINT(1) NOT NULL DEFAULT 0, + statement_locked_at DATETIME NULL, + statement_locked_by BIGINT UNSIGNED NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, PRIMARY KEY (id), @@ -282,6 +323,8 @@ class Schema { self::table( 'parcels' ), self::table( 'meters' ), self::table( 'tenants' ), + self::table( 'inventory_items' ), + self::table( 'inventory_loans' ), self::table( 'parcel_members' ), self::table( 'parcel_tenants' ), self::table( 'chat_messages' ), @@ -317,6 +360,8 @@ class Schema { self::table( 'work_logs' ), self::table( 'work_year_config' ), self::table( 'work_jobs' ), + self::table( 'inventory_loans' ), + self::table( 'inventory_items' ), self::table( 'cost_entries' ), self::table( 'cost_rates' ), self::table( 'cost_years' ), diff --git a/includes/Services/ParcelService.php b/includes/Services/ParcelService.php old mode 100644 new mode 100755 diff --git a/includes/Validator.php b/includes/Validator.php old mode 100644 new mode 100755 index 049ed80..f63850f --- a/includes/Validator.php +++ b/includes/Validator.php @@ -367,6 +367,91 @@ class Validator { return $errors; } + /** + * Sanitize one inventory item payload. + * + * @param array $data Raw request data. + * @return array + */ + public function sanitize_inventory_item( $data ) { + return array( + 'name' => sanitize_text_field( wp_unslash( isset( $data['name'] ) ? $data['name'] : '' ) ), + 'total_quantity' => absint( isset( $data['total_quantity'] ) ? $data['total_quantity'] : 0 ), + 'available_quantity' => absint( isset( $data['available_quantity'] ) ? $data['available_quantity'] : 0 ), + 'storage_location' => sanitize_text_field( wp_unslash( isset( $data['storage_location'] ) ? $data['storage_location'] : '' ) ), + 'description' => sanitize_textarea_field( wp_unslash( isset( $data['description'] ) ? $data['description'] : '' ) ), + 'is_active' => ! empty( $data['is_active'] ) ? 1 : 0, + ); + } + + /** + * Validate one inventory item payload. + * + * @param array $data Sanitized data. + * @return \WP_Error + */ + public function validate_inventory_item( $data ) { + $errors = new \WP_Error(); + + if ( '' === $data['name'] ) { + $errors->add( 'inventory_name_required', __( 'Bitte einen Namen für den Inventargegenstand eingeben.', KGVVM_TEXT_DOMAIN ) ); + } + + if ( (int) $data['total_quantity'] < 0 ) { + $errors->add( 'inventory_total_invalid', __( 'Die Gesamtmenge muss 0 oder größer sein.', KGVVM_TEXT_DOMAIN ) ); + } + + if ( (int) $data['available_quantity'] < 0 ) { + $errors->add( 'inventory_available_invalid', __( 'Die verfügbare Menge muss 0 oder größer sein.', KGVVM_TEXT_DOMAIN ) ); + } + + if ( (int) $data['available_quantity'] > (int) $data['total_quantity'] ) { + $errors->add( 'inventory_available_too_high', __( 'Die verfügbare Menge darf nicht größer als die Gesamtmenge sein.', KGVVM_TEXT_DOMAIN ) ); + } + + return $errors; + } + + /** + * Sanitize one inventory borrow request. + * + * @param array $data Raw request data. + * @return array + */ + public function sanitize_inventory_loan( $data ) { + return array( + 'item_id' => absint( isset( $data['item_id'] ) ? $data['item_id'] : 0 ), + 'user_id' => absint( isset( $data['user_id'] ) ? $data['user_id'] : 0 ), + 'quantity' => absint( isset( $data['quantity'] ) ? $data['quantity'] : 0 ), + 'due_date' => $this->normalize_date( isset( $data['due_date'] ) ? $data['due_date'] : '' ), + 'note' => sanitize_textarea_field( wp_unslash( isset( $data['note'] ) ? $data['note'] : '' ) ), + ); + } + + /** + * Validate one inventory borrow request. + * + * @param array $data Sanitized data. + * @return \WP_Error + */ + public function validate_inventory_loan( $data ) { + $errors = new \WP_Error(); + + if ( (int) $data['item_id'] < 1 ) { + $errors->add( 'inventory_item_required', __( 'Bitte einen Inventargegenstand auswählen.', KGVVM_TEXT_DOMAIN ) ); + } + + if ( (int) $data['user_id'] < 1 ) { + $errors->add( 'inventory_user_required', __( 'Bitte ein Mitglied auswählen.', KGVVM_TEXT_DOMAIN ) ); + } + + if ( (int) $data['quantity'] < 1 ) { + $errors->add( 'inventory_quantity_required', __( 'Bitte eine gültige Ausleihmenge eingeben.', KGVVM_TEXT_DOMAIN ) ); + } + + return $errors; + } + /** * Normalize optional date fields to Y-m-d. * diff --git a/kgv-verein-manager.php b/kgv-verein-manager.php old mode 100644 new mode 100755 index 261847d..3a43ab7 --- a/kgv-verein-manager.php +++ b/kgv-verein-manager.php @@ -4,7 +4,7 @@ * Plugin Name: KGV Vereinsverwaltung * Plugin URI: https://apex-project.de/ * Description: Verwaltung von Sparten, Parzellen, Mitgliedern, Pächtern sowie Wasser- und Stromzählern für Kleingartenvereine. - * Version: 1.17.5 + * Version: 1.17.7 * Author: Ronny Grobel * Author URI: https://apex-project.de/ * License: GPL v2 or later @@ -31,7 +31,7 @@ if ( ! defined( 'ABSPATH' ) ) { exit; } -define( 'KGVVM_VERSION', '1.17.5' ); +define( 'KGVVM_VERSION', '1.17.8' ); define( 'KGVVM_PLUGIN_FILE', __FILE__ ); define( 'KGVVM_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'KGVVM_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); @@ -45,8 +45,9 @@ register_activation_hook( __FILE__, array( '\\KGV\\VereinManager\\Activator', 'a register_deactivation_hook( __FILE__, array( '\\KGV\\VereinManager\\Deactivator', 'deactivate' ) ); add_action( - 'plugins_loaded', + 'init', function() { \KGV\VereinManager\Plugin::instance()->run(); - } + }, + 1 ); diff --git a/readme.txt b/readme.txt old mode 100644 new mode 100755 index cbcf199..90ceea7 --- a/readme.txt +++ b/readme.txt @@ -3,7 +3,7 @@ Contributors: ronnygrobel Tags: verein, mitgliederverwaltung, parzellen, zaehler, abrechnung Requires at least: 6.0 Tested up to: 6.8 -Stable tag: 1.17.5 +Stable tag: 1.17.8 Requires PHP: 7.2 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -41,6 +41,15 @@ Ja, insbesondere fuer Kleingartenvereine und deren Verwaltungsprozesse. == Changelog == += 1.17.8 = +Feat: Verbrauchsauswertung – Ablesung korrigieren (Datum, Zählerstand, Korrekturnotiz) und löschen direkt aus der Tabelle. + += 1.17.7 = +Feat: Inventarverwaltung – Gegenstände (Werkzeug etc.) erfassen, bearbeiten, löschen. Ausleihe und Rückgabe je Mitglied mit Notiz und Fälligkeitsdatum tracken. Export/Import integriert. + += 1.17.6 = +Feat: Jahresabrechnung kann festgeschrieben (gesperrt) werden. Alle Schreibzugriffe auf Kosten, Preise und Parzellenzuordnungen prüfen den Sperrstatus serverseitig. + = 1.17.5 = Fix: Spaltenbreiten in Abrechnungstabellen angepasst für bessere Lesbarkeit langer Texte. diff --git a/uninstall.php b/uninstall.php old mode 100644 new mode 100755