From 7d3d543954161402a0c0bcb3f58a0ef86111cbca Mon Sep 17 00:00:00 2001 From: Ronny Grobel Date: Thu, 16 Apr 2026 21:34:09 +0200 Subject: [PATCH] Release 1.16.0 Arbeitsstunden-Modul hinzugefuegt - Pflichtstunden pro Jahr inkl. Preis je fehlender Stunde - Arbeitsarten, Arbeitseintraege und Mehrfachzuordnung von Mitgliedern - Mitgliederuebersicht mit Berechnung fehlender Stunden und Aufschlag - Datenbankschema fuer Arbeitsstunden erweitert - Stable Tag und Changelog in README/readme.txt aktualisiert --- README.md | 8 +- assets/css/admin.css | 18 + includes/Admin/Admin.php | 427 ++++++++++++++++++++ includes/Repositories/WorkRepository.php | 470 +++++++++++++++++++++++ includes/Schema.php | 59 ++- kgv-verein-manager.php | 7 +- readme.txt | 8 +- 7 files changed, 992 insertions(+), 5 deletions(-) create mode 100644 includes/Repositories/WorkRepository.php diff --git a/README.md b/README.md index 440eb5d..79493b1 100644 --- 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.15.6 +Stable tag: 1.16.0 Requires PHP: 7.2 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -43,6 +43,12 @@ Ja, insbesondere fuer Kleingartenvereine und deren Verwaltungsprozesse. == Changelog == += 1.16.0 = +* Neues Modul Arbeitsstunden im Adminbereich mit drei Bereichen: geleistete Arbeiten, Mitgliederuebersicht und Arbeitsarten. +* Pflichtstunden je Mitglied pro Jahr konfigurierbar, inklusive variablem Preis je fehlender Stunde fuer den Aufschlag in der Jahresrechnung. +* Arbeitseintraege mit Datum, Arbeitsart und Notiz sowie Mehrfachzuordnung von Mitgliedern mit individuellen Stundenwerten. +* Neue Datenbanktabellen fuer Arbeitsarten, Jahreseinstellungen, Arbeitseintraege und Stundenzuordnungen je Mitglied. + = 1.15.6 = * Versionsabgleich zwischen Plugin-Header, Konstante und Readme. * WordPress-Readme-Format weiter vereinheitlicht. diff --git a/assets/css/admin.css b/assets/css/admin.css index 4d4fb10..3039e0e 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -300,3 +300,21 @@ break-inside: avoid; } } + +/* Arbeitsstunden sub-table */ +.kgvvm-subtable { + border-collapse: collapse; + width: 100%; + max-width: 480px; +} +.kgvvm-subtable th, +.kgvvm-subtable td { + padding: 4px 8px; + border: 1px solid #ddd; + text-align: left; + vertical-align: middle; +} +.kgvvm-subtable thead th { + background: #f9f9f9; + font-weight: 600; +} diff --git a/includes/Admin/Admin.php b/includes/Admin/Admin.php index 44576c6..c8d06d8 100644 --- a/includes/Admin/Admin.php +++ b/includes/Admin/Admin.php @@ -17,6 +17,7 @@ use KGV\VereinManager\Repositories\MeterRepository; use KGV\VereinManager\Repositories\ParcelRepository; use KGV\VereinManager\Repositories\SectionRepository; use KGV\VereinManager\Repositories\TenantRepository; +use KGV\VereinManager\Repositories\WorkRepository; use KGV\VereinManager\Services\ParcelService; if ( ! defined( 'ABSPATH' ) ) { @@ -43,6 +44,7 @@ class Admin { private $assignments; private $chat; private $costs; + private $work; private $parcel_service; /** @@ -58,6 +60,7 @@ class Admin { $this->assignments = new AssignmentRepository(); $this->chat = new ChatRepository(); $this->costs = new CostRepository(); + $this->work = new WorkRepository(); $this->parcel_service = new ParcelService(); } @@ -101,6 +104,7 @@ class Admin { 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', __( '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' ) ); add_submenu_page( 'kgvvm-dashboard', __( 'Pächter', KGVVM_TEXT_DOMAIN ), __( 'Pächter', KGVVM_TEXT_DOMAIN ), 'edit_paechter', 'kgvvm-paechter', array( $this, 'render_tenants_page' ) ); add_submenu_page( 'kgvvm-dashboard', __( 'Einstellungen', KGVVM_TEXT_DOMAIN ), __( 'Einstellungen', KGVVM_TEXT_DOMAIN ), Roles::SETTINGS_CAP, 'kgvvm-settings', array( $this, 'render_settings_page' ) ); } @@ -229,6 +233,15 @@ class Admin { case 'save_settings': $this->save_settings(); break; + case 'save_work_year_config': + $this->save_work_year_config(); + break; + case 'save_work_job': + $this->save_work_job(); + break; + case 'save_work_log': + $this->save_work_log(); + break; } } @@ -299,6 +312,23 @@ class Admin { case 'export_readings_csv': $this->export_readings_csv(); break; + case 'delete_work_job': + $this->require_cap( 'manage_kleingarten' ); + if ( ! wp_verify_nonce( $nonce, 'kgvvm_delete_work_job_' . $id ) ) { + $this->redirect_with_notice( 'kgvvm-arbeit', 'error', __( 'Der Löschvorgang wurde aus Sicherheitsgründen abgebrochen.', KGVVM_TEXT_DOMAIN ) ); + } + $this->work->delete_job( $id ); + $this->redirect_with_notice( 'kgvvm-arbeit', 'success', __( 'Arbeitsart wurde gelöscht.', KGVVM_TEXT_DOMAIN ), array( 'tab' => 'jobs' ) ); + break; + case 'delete_work_log': + $this->require_cap( 'manage_kleingarten' ); + $year = absint( isset( $_GET['year'] ) ? $_GET['year'] : current_time( 'Y' ) ); + if ( ! wp_verify_nonce( $nonce, 'kgvvm_delete_work_log_' . $id ) ) { + $this->redirect_with_notice( 'kgvvm-arbeit', 'error', __( 'Der Löschvorgang wurde aus Sicherheitsgründen abgebrochen.', KGVVM_TEXT_DOMAIN ), array( 'year' => $year ) ); + } + $this->work->delete_log( $id ); + $this->redirect_with_notice( 'kgvvm-arbeit', 'success', __( 'Arbeitseintrag wurde gelöscht.', KGVVM_TEXT_DOMAIN ), array( 'year' => $year ) ); + break; } } @@ -4050,4 +4080,401 @@ class Admin { return add_query_arg( $args, admin_url( 'admin.php' ) ); } + + // ========================================================================= + // ARBEITSSTUNDEN + // ========================================================================= + + /** + * Save work year configuration. + * + * @return void + */ + private function save_work_year_config() { + $this->require_cap( 'manage_kleingarten' ); + check_admin_referer( 'kgvvm_save_work_year_config' ); + + $year = absint( isset( $_POST['entry_year'] ) ? $_POST['entry_year'] : 0 ); + $required_hours = isset( $_POST['required_hours'] ) ? (float) $_POST['required_hours'] : 0; + $price_per_missing_hour = isset( $_POST['price_per_missing_hour'] ) ? (float) $_POST['price_per_missing_hour'] : 0; + + if ( $year < 2000 || $year > 2100 ) { + $this->redirect_with_notice( 'kgvvm-arbeit', 'error', __( 'Bitte ein gültiges Jahr eingeben.', KGVVM_TEXT_DOMAIN ) ); + } + + $this->work->save_year_config( $year, $required_hours, $price_per_missing_hour ); + $this->redirect_with_notice( 'kgvvm-arbeit', 'success', __( 'Jahreseinstellungen wurden gespeichert.', KGVVM_TEXT_DOMAIN ), array( 'year' => $year ) ); + } + + /** + * Save work job (add/update). + * + * @return void + */ + private function save_work_job() { + $this->require_cap( 'manage_kleingarten' ); + check_admin_referer( 'kgvvm_save_work_job' ); + + $id = absint( isset( $_POST['id'] ) ? $_POST['id'] : 0 ); + $name = sanitize_text_field( wp_unslash( isset( $_POST['job_name'] ) ? $_POST['job_name'] : '' ) ); + + if ( '' === $name ) { + $this->redirect_with_notice( 'kgvvm-arbeit', 'error', __( 'Bitte einen Namen für die Arbeitsart eingeben.', KGVVM_TEXT_DOMAIN ), array( 'tab' => 'jobs' ) ); + } + + if ( $this->work->job_name_exists( $name, $id ) ) { + $this->redirect_with_notice( 'kgvvm-arbeit', 'error', __( 'Eine Arbeitsart mit diesem Namen existiert bereits.', KGVVM_TEXT_DOMAIN ), array( 'tab' => 'jobs' ) ); + } + + $this->work->save_job( + array( + 'name' => $name, + 'description' => isset( $_POST['job_description'] ) ? wp_unslash( $_POST['job_description'] ) : '', + ), + $id + ); + + $this->redirect_with_notice( 'kgvvm-arbeit', 'success', __( 'Arbeitsart wurde gespeichert.', KGVVM_TEXT_DOMAIN ), array( 'tab' => 'jobs' ) ); + } + + /** + * Save work log entry (add/update). + * + * @return void + */ + private function save_work_log() { + $this->require_cap( 'manage_kleingarten' ); + check_admin_referer( 'kgvvm_save_work_log' ); + + $id = absint( isset( $_POST['id'] ) ? $_POST['id'] : 0 ); + $job_id = absint( isset( $_POST['job_id'] ) ? $_POST['job_id'] : 0 ); + $work_date = sanitize_text_field( wp_unslash( isset( $_POST['work_date'] ) ? $_POST['work_date'] : '' ) ); + $year = $work_date ? (int) substr( $work_date, 0, 4 ) : (int) current_time( 'Y' ); + + if ( '' === $work_date ) { + $this->redirect_with_notice( 'kgvvm-arbeit', 'error', __( 'Bitte ein gültiges Datum eingeben.', KGVVM_TEXT_DOMAIN ), array( 'year' => $year ) ); + } + + $raw_members = isset( $_POST['member_hours'] ) ? (array) wp_unslash( $_POST['member_hours'] ) : array(); + $members = array(); + foreach ( $raw_members as $uid => $hours ) { + $uid = absint( $uid ); + $hours = (float) $hours; + if ( $uid > 0 && $hours > 0 ) { + $members[ $uid ] = $hours; + } + } + + if ( empty( $members ) ) { + $this->redirect_with_notice( 'kgvvm-arbeit', 'error', __( 'Bitte mindestens ein Mitglied mit Stundenzahl zuordnen.', KGVVM_TEXT_DOMAIN ), array( 'year' => $year ) ); + } + + $this->work->save_log( + array( + 'job_id' => $job_id, + 'work_date' => $work_date, + 'note' => isset( $_POST['note'] ) ? wp_unslash( $_POST['note'] ) : '', + ), + $id, + $members + ); + + $this->redirect_with_notice( 'kgvvm-arbeit', 'success', __( 'Arbeitseintrag wurde gespeichert.', KGVVM_TEXT_DOMAIN ), array( 'year' => $year ) ); + } + + /** + * Render the Arbeitsstunden admin page. + * + * @return void + */ + public function render_work_page() { + $this->require_cap( 'manage_kleingarten' ); + + $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'logs'; + $selected_year = isset( $_GET['year'] ) ? absint( $_GET['year'] ) : (int) current_time( 'Y' ); + $selected_year = $selected_year > 0 ? $selected_year : (int) current_time( 'Y' ); + $edit_log_id = absint( isset( $_GET['id'] ) ? $_GET['id'] : 0 ); + $edit_job_id = absint( isset( $_GET['job_id'] ) ? $_GET['job_id'] : 0 ); + + $years = $this->work->get_years( $selected_year ); + $year_config = $this->work->get_year_config( $selected_year ); + $jobs = $this->work->get_jobs(); + $all_members = $this->assignments->get_member_users(); + + $edit_log = null; + $edit_job = null; + + if ( $edit_log_id ) { + $edit_log = $this->work->find_log( $edit_log_id ); + } + if ( $edit_job_id ) { + $edit_job = $this->work->find_job( $edit_job_id ); + } + + $logs = $this->work->search_logs( + array( + 'year' => $selected_year, + 'orderby' => isset( $_GET['orderby'] ) ? sanitize_key( wp_unslash( $_GET['orderby'] ) ) : 'work_date', + 'order' => isset( $_GET['order'] ) ? sanitize_key( wp_unslash( $_GET['order'] ) ) : 'DESC', + 's' => isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : '', + ) + ); + $summary = $this->work->get_member_summary( $selected_year, $all_members ); + ?> +
+

+ render_notice(); ?> + + +
+ + +
+ + + +
+
+ + +
+

+
+ + + + + + + + + + + + +
€ / Std.
+ +
+
+ + + + + + +
+

+
+ + + + + + + + + + + + +
' />
+ + + $selected_year, 'tab' => 'jobs' ) ) ); ?>' class='button'> + +
+
+ + + + + + + + + + + + + + + + + + + +
name ); ?>description ); ?> + 'jobs', 'year' => $selected_year, 'job_id' => $job->id ) ) ); ?>'> +  |  + 'delete_work_job', 'id' => $job->id, 'year' => $selected_year, 'tab' => 'jobs' ) ), 'kgvvm_delete_work_job_' . $job->id ) ); ?>' + class='kgvvm-delete-link' onclick='return confirm("")'> +
+ +

+ + + + +
+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
display_name ); ?>completed_hours, 2 ) ); ?>required_hours, 2 ) ); ?>missing_hours > 0 ? '' . esc_html( number_format_i18n( $row->missing_hours, 2 ) ) . '' : esc_html( number_format_i18n( 0, 2 ) ); ?>surcharge > 0 ? '' . esc_html( $this->format_currency( $row->surcharge ) ) . '' : '—'; ?>
+
+ + + + + +
+

+
+ + + + + + + + + + + + + + + + + + + + +
' />
+ +
+ members ) ) { + foreach ( $edit_log->members as $m ) { + $existing_member_hours[ (int) $m->user_id ] = (float) $m->hours; + } + } + ?> + + + + + ID; + $hours_val = isset( $existing_member_hours[ $uid ] ) ? $existing_member_hours[ $uid ] : ''; + ?> + + + + + + +
display_name ); ?>
+

+
+ + + $selected_year ) ) ); ?>' class='button'> + +
+
+ + + + + + + + + + + + + + + + work->get_log_members( $log->id ); ?> + + + + + + + + + +
work_date ) ) ); ?>job_name ?: '—' ); ?> + user_id ); + if ( $u ) : ?> + display_name ); ?>: hours, 2 ) ); ?> Std.
+ + +
note ); ?> + $selected_year, 'id' => $log->id ) ) ); ?>'> +  |  + 'delete_work_log', 'id' => $log->id, 'year' => $selected_year ) ), 'kgvvm_delete_work_log_' . $log->id ) ); ?>' + class='kgvvm-delete-link' onclick='return confirm("")'> +
+ +

+ + + +
+ jobs_table(); + return $this->wpdb->get_results( "SELECT * FROM {$table} ORDER BY name ASC" ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery + } + + /** + * Get a single job by ID. + * + * @param int $id Job ID. + * @return object|null + */ + public function find_job( $id ) { + $table = $this->jobs_table(); + return $this->wpdb->get_row( $this->wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", absint( $id ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + } + + /** + * Save or update a job. + * + * @param array $data Job data. + * @param int $id Optional ID. + * @return int|false + */ + public function save_job( $data, $id = 0 ) { + $table = $this->jobs_table(); + $payload = array( + 'name' => sanitize_text_field( $data['name'] ), + 'description' => isset( $data['description'] ) ? sanitize_textarea_field( $data['description'] ) : '', + 'updated_at' => $this->now(), + ); + $formats = array( '%s', '%s', '%s' ); + + if ( $id > 0 ) { + $this->wpdb->update( $table, $payload, array( 'id' => $id ), $formats, array( '%d' ) ); + return $id; + } + + $payload['created_at'] = $this->now(); + $this->wpdb->insert( $table, $payload, array( '%s', '%s', '%s', '%s' ) ); + return $this->wpdb->insert_id; + } + + /** + * Delete a job and related log members/logs. + * + * @param int $id Job ID. + * @return bool + */ + public function delete_job( $id ) { + $id = absint( $id ); + $logs_table = $this->table; + $mem_table = $this->members_table(); + $jobs_table = $this->jobs_table(); + + // Delete members of all logs for this job. + $log_ids = $this->wpdb->get_col( $this->wpdb->prepare( "SELECT id FROM {$logs_table} WHERE job_id = %d", $id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + foreach ( array_map( 'absint', (array) $log_ids ) as $log_id ) { + $this->wpdb->delete( $mem_table, array( 'log_id' => $log_id ), array( '%d' ) ); + } + // Delete logs. + $this->wpdb->delete( $logs_table, array( 'job_id' => $id ), array( '%d' ) ); + // Delete job. + $result = $this->wpdb->delete( $jobs_table, array( 'id' => $id ), array( '%d' ) ); + return false !== $result; + } + + /** + * Check if a job name already exists (for uniqueness). + * + * @param string $name Job name. + * @param int $exclude_id Exclude this ID. + * @return bool + */ + public function job_name_exists( $name, $exclude_id = 0 ) { + $table = $this->jobs_table(); + $sql = $this->wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE name = %s", sanitize_text_field( $name ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + if ( $exclude_id > 0 ) { + $sql .= $this->wpdb->prepare( ' AND id != %d', absint( $exclude_id ) ); + } + return (int) $this->wpdb->get_var( $sql ) > 0; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + } + + // ------------------------------------------------------------------------- + // Year config + // ------------------------------------------------------------------------- + + /** + * Get config for a specific year (creates if missing). + * + * @param int $year Year. + * @return object|null + */ + public function get_year_config( $year ) { + $year = absint( $year ); + $table = $this->year_config_table(); + $row = $this->wpdb->get_row( $this->wpdb->prepare( "SELECT * FROM {$table} WHERE entry_year = %d", $year ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + return $row; + } + + /** + * Get all available configured years. + * + * @param int $selected_year Optional selected year to always include. + * @return array + */ + public function get_years( $selected_year = 0 ) { + $config_table = $this->year_config_table(); + $log_table = $this->table; + $current_year = (int) current_time( 'Y' ); + + $config_years = $this->wpdb->get_col( "SELECT entry_year FROM {$config_table} ORDER BY entry_year DESC" ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery + $log_years = $this->wpdb->get_col( "SELECT DISTINCT YEAR(work_date) FROM {$log_table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery + + $years = array_map( 'absint', array_merge( (array) $config_years, (array) $log_years ) ); + $years[] = $current_year; + $years[] = $current_year - 1; + $years[] = $current_year + 1; + if ( $selected_year > 0 ) { + $years[] = absint( $selected_year ); + } + + $years = array_values( array_unique( array_filter( $years ) ) ); + rsort( $years, SORT_NUMERIC ); + return $years; + } + + /** + * Save year configuration (upsert). + * + * @param int $year Year. + * @param float $required_hours Required hours per member. + * @param float $price_per_missing_hour Price per missing hour in €. + * @return bool + */ + public function save_year_config( $year, $required_hours, $price_per_missing_hour ) { + $year = absint( $year ); + $required_hours = max( 0, (float) $required_hours ); + $price_per_missing_hour = max( 0, (float) $price_per_missing_hour ); + $table = $this->year_config_table(); + $existing = $this->get_year_config( $year ); + + if ( $existing ) { + $result = $this->wpdb->update( + $table, + array( + 'required_hours' => $required_hours, + 'price_per_missing_hour' => $price_per_missing_hour, + 'updated_at' => $this->now(), + ), + array( 'entry_year' => $year ), + array( '%f', '%f', '%s' ), + array( '%d' ) + ); + return false !== $result; + } + + $result = $this->wpdb->insert( + $table, + array( + 'entry_year' => $year, + 'required_hours' => $required_hours, + 'price_per_missing_hour' => $price_per_missing_hour, + 'created_at' => $this->now(), + 'updated_at' => $this->now(), + ), + array( '%d', '%f', '%f', '%s', '%s' ) + ); + return false !== $result; + } + + // ------------------------------------------------------------------------- + // Work logs + // ------------------------------------------------------------------------- + + /** + * Search work logs. + * + * @param array $args Query arguments (year, user_id, job_id, s, orderby, order). + * @return array + */ + public function search_logs( $args = array() ) { + $year = isset( $args['year'] ) ? absint( $args['year'] ) : 0; + $user_id = isset( $args['user_id'] ) ? absint( $args['user_id'] ) : 0; + $job_id = isset( $args['job_id'] ) ? absint( $args['job_id'] ) : 0; + $search = isset( $args['s'] ) ? sanitize_text_field( wp_unslash( $args['s'] ) ) : ''; + $orderby = $this->sanitize_orderby( + isset( $args['orderby'] ) ? sanitize_key( wp_unslash( $args['orderby'] ) ) : 'work_date', + array( 'work_date', 'job_name', 'updated_at', 'created_at' ), + 'work_date' + ); + $order = $this->sanitize_order( isset( $args['order'] ) ? sanitize_key( wp_unslash( $args['order'] ) ) : 'DESC' ); + + $logs_table = $this->table; + $jobs_table = $this->jobs_table(); + $mem_table = $this->members_table(); + + $sql = "SELECT l.*, COALESCE(j.name, '') AS job_name FROM {$logs_table} l + LEFT JOIN {$jobs_table} j ON j.id = l.job_id WHERE 1=1"; + $params = array(); + + if ( $year > 0 ) { + $sql .= ' AND YEAR(l.work_date) = %d'; + $params[] = $year; + } + + if ( $job_id > 0 ) { + $sql .= ' AND l.job_id = %d'; + $params[] = $job_id; + } + + if ( $user_id > 0 ) { + $sql .= " AND l.id IN (SELECT log_id FROM {$mem_table} WHERE user_id = %d)"; + $params[] = $user_id; + } + + if ( '' !== $search ) { + $like = '%' . $this->wpdb->esc_like( $search ) . '%'; + $sql .= ' AND (j.name LIKE %s OR l.note LIKE %s)'; + $params[] = $like; + $params[] = $like; + } + + $sql .= " ORDER BY l.{$orderby} {$order}, l.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 + } + + /** + * Get a work log with its member assignments. + * + * @param int $id Log ID. + * @return object|null + */ + public function find_log( $id ) { + $row = $this->find( $id ); + if ( $row ) { + $row->members = $this->get_log_members( $id ); + } + return $row; + } + + /** + * Save or update a work log including member assignments. + * + * @param array $data Log data. + * @param int $id Optional log ID. + * @param array $members Array of [user_id => hours]. + * @return int|false + */ + public function save_log( $data, $id = 0, $members = array() ) { + $payload = array( + 'job_id' => absint( $data['job_id'] ), + 'work_date' => sanitize_text_field( $data['work_date'] ), + 'note' => isset( $data['note'] ) ? sanitize_textarea_field( $data['note'] ) : '', + 'updated_at' => $this->now(), + ); + $formats = array( '%d', '%s', '%s', '%s' ); + + if ( $id > 0 ) { + $this->wpdb->update( $this->table, $payload, array( 'id' => $id ), $formats, array( '%d' ) ); + $log_id = $id; + } else { + $payload['created_at'] = $this->now(); + $this->wpdb->insert( $this->table, $payload, array( '%d', '%s', '%s', '%s', '%s' ) ); + $log_id = $this->wpdb->insert_id; + } + + if ( $log_id && is_array( $members ) ) { + $this->sync_log_members( $log_id, $members ); + } + + return $log_id; + } + + /** + * Delete a work log and its member assignments. + * + * @param int $id Log ID. + * @return bool + */ + public function delete_log( $id ) { + $id = absint( $id ); + $this->wpdb->delete( $this->members_table(), array( 'log_id' => $id ), array( '%d' ) ); + $result = $this->wpdb->delete( $this->table, array( 'id' => $id ), array( '%d' ) ); + return false !== $result; + } + + // ------------------------------------------------------------------------- + // Log members + // ------------------------------------------------------------------------- + + /** + * Get member assignments for a log. + * + * @param int $log_id Log ID. + * @return array + */ + public function get_log_members( $log_id ) { + $table = $this->members_table(); + return $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$table} WHERE log_id = %d", absint( $log_id ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + } + + /** + * Sync member assignments for a log. + * + * @param int $log_id Log ID. + * @param array $members Array of [user_id => hours]. + * @return void + */ + public function sync_log_members( $log_id, $members ) { + $log_id = absint( $log_id ); + $table = $this->members_table(); + $now = $this->now(); + + $this->wpdb->delete( $table, array( 'log_id' => $log_id ), array( '%d' ) ); + + foreach ( $members as $user_id => $hours ) { + $user_id = absint( $user_id ); + $hours = max( 0, (float) $hours ); + if ( $user_id < 1 ) { + continue; + } + $this->wpdb->insert( + $table, + array( + 'log_id' => $log_id, + 'user_id' => $user_id, + 'hours' => $hours, + 'created_at' => $now, + ), + array( '%d', '%d', '%f', '%s' ) + ); + } + } + + // ------------------------------------------------------------------------- + // Summary / statistics + // ------------------------------------------------------------------------- + + /** + * Get completed hours per member for a given year. + * + * @param int $year Year. + * @return array Array keyed by user_id with total hours. + */ + public function get_hours_per_member( $year ) { + $year = absint( $year ); + $log_table = $this->table; + $mem_table = $this->members_table(); + + $rows = $this->wpdb->get_results( + $this->wpdb->prepare( + "SELECT m.user_id, SUM(m.hours) AS total_hours + FROM {$mem_table} m + INNER JOIN {$log_table} l ON l.id = m.log_id + WHERE YEAR(l.work_date) = %d + GROUP BY m.user_id", // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $year + ) + ); + + $result = array(); + foreach ( $rows as $row ) { + $result[ absint( $row->user_id ) ] = (float) $row->total_hours; + } + return $result; + } + + /** + * Build per-member work summary for a year. + * + * Returns array of objects with user_id, display_name, completed_hours, + * required_hours, missing_hours, surcharge. + * + * @param int $year Year. + * @param array $all_members Array of WP_User objects. + * @return array + */ + public function get_member_summary( $year, $all_members ) { + $config = $this->get_year_config( $year ); + $required_hours = $config ? (float) $config->required_hours : 0.0; + $price = $config ? (float) $config->price_per_missing_hour : 0.0; + $done_map = $this->get_hours_per_member( $year ); + + $summary = array(); + foreach ( $all_members as $member ) { + $uid = (int) $member->ID; + $completed = isset( $done_map[ $uid ] ) ? $done_map[ $uid ] : 0.0; + $missing = max( 0.0, $required_hours - $completed ); + $surcharge = round( $missing * $price, 2 ); + + $obj = new \stdClass(); + $obj->user_id = $uid; + $obj->display_name = $member->display_name; + $obj->completed_hours = $completed; + $obj->required_hours = $required_hours; + $obj->missing_hours = $missing; + $obj->surcharge = $surcharge; + + $summary[] = $obj; + } + return $summary; + } +} diff --git a/includes/Schema.php b/includes/Schema.php index 82c76d0..56d99df 100644 --- a/includes/Schema.php +++ b/includes/Schema.php @@ -33,7 +33,11 @@ class Schema { 'meter_readings' => $wpdb->prefix . 'kgvvm_meter_readings', 'cost_years' => $wpdb->prefix . 'kgvvm_cost_years', 'cost_rates' => $wpdb->prefix . 'kgvvm_cost_rates', - 'cost_entries' => $wpdb->prefix . 'kgvvm_cost_entries', + 'cost_entries' => $wpdb->prefix . 'kgvvm_cost_entries', + 'work_jobs' => $wpdb->prefix . 'kgvvm_work_jobs', + 'work_year_config' => $wpdb->prefix . 'kgvvm_work_year_config', + 'work_logs' => $wpdb->prefix . 'kgvvm_work_logs', + 'work_log_members' => $wpdb->prefix . 'kgvvm_work_log_members', ); return isset( $map[ $key ] ) ? $map[ $key ] : ''; @@ -205,6 +209,51 @@ class Schema { KEY distribution_type (distribution_type) ) {$charset_collate};"; + $sql[] = "CREATE TABLE " . self::table( 'work_jobs' ) . " ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(190) NOT NULL, + description TEXT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY name (name) + ) {$charset_collate};"; + + $sql[] = "CREATE TABLE " . self::table( 'work_year_config' ) . " ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + entry_year SMALLINT UNSIGNED NOT NULL, + required_hours DECIMAL(8,2) NOT NULL DEFAULT 0.00, + price_per_missing_hour DECIMAL(12,2) NOT NULL DEFAULT 0.00, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY entry_year (entry_year) + ) {$charset_collate};"; + + $sql[] = "CREATE TABLE " . self::table( 'work_logs' ) . " ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + job_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + work_date DATE NOT NULL, + note TEXT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY job_id (job_id), + KEY work_date (work_date) + ) {$charset_collate};"; + + $sql[] = "CREATE TABLE " . self::table( 'work_log_members' ) . " ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + log_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + hours DECIMAL(8,2) NOT NULL DEFAULT 0.00, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY log_user (log_id, user_id), + KEY user_id (user_id), + KEY log_id (log_id) + ) {$charset_collate};"; + foreach ( $sql as $statement ) { dbDelta( $statement ); } @@ -230,6 +279,10 @@ class Schema { self::table( 'cost_years' ), self::table( 'cost_rates' ), self::table( 'cost_entries' ), + self::table( 'work_jobs' ), + self::table( 'work_year_config' ), + self::table( 'work_logs' ), + self::table( 'work_log_members' ), ); foreach ( $tables as $table ) { @@ -248,6 +301,10 @@ class Schema { global $wpdb; $tables = array( + self::table( 'work_log_members' ), + self::table( 'work_logs' ), + self::table( 'work_year_config' ), + self::table( 'work_jobs' ), self::table( 'cost_entries' ), self::table( 'cost_rates' ), self::table( 'cost_years' ), diff --git a/kgv-verein-manager.php b/kgv-verein-manager.php index 51b2848..572d0f1 100644 --- a/kgv-verein-manager.php +++ b/kgv-verein-manager.php @@ -1,11 +1,14 @@