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; } }