prefix . 'ktc_logs'; $charset_collate = $wpdb->get_charset_collate(); $sql = "CREATE TABLE $table_name ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL, timestamp datetime DEFAULT CURRENT_TIMESTAMP NOT NULL, proposed_timestamp datetime DEFAULT NULL, action_type varchar(20) NOT NULL, notes text, status varchar(20) DEFAULT 'approved' NOT NULL, PRIMARY KEY (id) ) $charset_collate;"; require_once( ABSPATH . 'wp-admin/includes/upgrade.php' ); dbDelta( $sql ); if ( ! get_role( 'ktc_user' ) ) { add_role( 'ktc_user', 'KTC Timeclock User', array( 'read' => true ) ); } $page_slug = 'clock'; $page_check = get_page_by_path($page_slug); if ( ! $page_check ) { $page_id = wp_insert_post( array( 'post_title' => 'Clock', 'post_content' => '[ktc_portal_welcome] [krista_timeclock]', 'post_status' => 'publish', 'post_type' => 'page', 'post_name' => $page_slug )); } else { $page_id = $page_check->ID; } if ( $page_id ) { update_option( 'show_on_front', 'page' ); update_option( 'page_on_front', $page_id ); } } // --- Time Core Methods --- public static function get_ktc_timezone() { return new DateTimeZone( get_option( 'ktc_timezone', 'America/Chicago' ) ); } public static function get_now() { return new DateTime( 'now', self::get_ktc_timezone() ); } public static function get_local_timestamp( $date_string ) { try { $dt = new DateTime( $date_string, self::get_ktc_timezone() ); return $dt->getTimestamp(); } catch ( Exception $e ) { return current_time( 'timestamp', 1 ); } } // --- Action Handlers --- public static function handle_actions() { if ( ! is_user_logged_in() ) return; // Admin Approval Actions if ( isset( $_GET['ktc_admin_action'] ) && current_user_can( 'manage_options' ) ) { self::handle_admin_actions(); return; } // POST Route Guard if ( ! isset( $_POST['ktc_action'] ) ) return; if ( ! isset( $_POST['ktc_nonce'] ) || ! wp_verify_nonce( $_POST['ktc_nonce'], 'ktc_punch_action' ) ) { wp_die( 'Security check failed. Please refresh the page.' ); } $action = $_POST['ktc_action']; switch ( $action ) { case 'punch': self::execute_punch_request(); break; case 'edit_request': self::execute_edit_request(); break; } wp_safe_redirect( home_url('/') ); exit; } /** * THE DOUBLE-IN GUARD (Missing in previous version) */ private static function execute_punch_request() { global $wpdb; $user_id = get_current_user_id(); $table_name = $wpdb->prefix . 'ktc_logs'; // 1. Force a clean check of the LAST recorded action $last_action = $wpdb->get_var( $wpdb->prepare( "SELECT action_type FROM $table_name WHERE user_id = %d ORDER BY id DESC LIMIT 1", $user_id )); // 2. The Toggle Logic: If last was 'clock_in', next is 'clock_out'. // Anything else (including NULL) results in 'clock_in'. $next_action = ( $last_action === 'clock_in' ) ? 'clock_out' : 'clock_in'; // 3. Debug/Audit Trail: Ensure we are sending what we think we are return $wpdb->insert( $table_name, array( 'user_id' => $user_id, 'timestamp' => self::get_now()->format('Y-m-d H:i:s'), 'action_type' => $next_action, 'status' => 'approved' ), array( '%d', '%s', '%s', '%s' ) ); } private static function execute_edit_request() { global $wpdb; $table_name = $wpdb->prefix . 'ktc_logs'; $user_id = get_current_user_id(); $log_id = intval( $_POST['ktc_edit_id'] ); $orig = $wpdb->get_var( $wpdb->prepare( "SELECT timestamp FROM $table_name WHERE id = %d AND user_id = %d", $log_id, $user_id ) ); if ( $orig ) { $tz = self::get_ktc_timezone(); $date_part = (new DateTime($orig, $tz))->format('Y-m-d'); $proposed = $date_part . ' ' . sanitize_text_field( $_POST['ktc_proposed_time'] ) . ':00'; $wpdb->update( $table_name, [ 'proposed_timestamp' => $proposed, 'status' => 'pending_correction', 'notes' => sanitize_textarea_field( $_POST['ktc_edit_notes'] ) ], [ 'id' => $log_id ]); } } private static function handle_admin_actions() { global $wpdb; $table_name = $wpdb->prefix . 'ktc_logs'; $log_id = intval( $_GET['log_id'] ); if ( $_GET['ktc_admin_action'] === 'approve' ) { $wpdb->update( $table_name, [ 'status' => 'approved' ], [ 'id' => $log_id ] ); } else { // Reject: Keep approved status but clear the proposed edit $wpdb->update( $table_name, [ 'status' => 'approved', 'proposed_timestamp' => null ], [ 'id' => $log_id ] ); } wp_safe_redirect( home_url('/') ); exit; } // --- Statistics & Calculations --- public static function get_elapsed_time( $user_id ) { global $wpdb; $table_name = $wpdb->prefix . 'ktc_logs'; $last = $wpdb->get_row( $wpdb->prepare( "SELECT timestamp, action_type FROM $table_name WHERE user_id = %d ORDER BY id DESC LIMIT 1", $user_id )); if ( ! $last || $last->action_type !== 'clock_in' ) return 0; $tz = self::get_ktc_timezone(); $now = self::get_now(); $start = new DateTime($last->timestamp, $tz); return $now->getTimestamp() - $start->getTimestamp(); } public static function get_weekly_total( $user_id ) { global $wpdb; $table_name = $wpdb->prefix . 'ktc_logs'; $now = self::get_now(); $week_start_day = get_option( 'ktc_week_start', 'Monday' ); $start_of_week_ts = ( $now->format('l') === $week_start_day ) ? strtotime( "today midnight", $now->getTimestamp() ) : strtotime( "last $week_start_day midnight", $now->getTimestamp() ); $logs = $wpdb->get_results( $wpdb->prepare( "SELECT timestamp, proposed_timestamp, action_type FROM $table_name WHERE user_id = %d AND timestamp >= %s AND status = 'approved' ORDER BY timestamp ASC", $user_id, wp_date( 'Y-m-d H:i:s', $start_of_week_ts ) )); $total_seconds = 0; $temp_in = null; foreach ( $logs as $log ) { $ts = !empty($log->proposed_timestamp) ? self::get_local_timestamp($log->proposed_timestamp) : self::get_local_timestamp($log->timestamp); if ( $log->action_type === 'clock_in' ) { $temp_in = $ts; } elseif ( $log->action_type === 'clock_out' && $temp_in ) { $total_seconds += ( $ts - $temp_in ); $temp_in = null; } } return round( $total_seconds / 3600, 2 ); } public static function get_user_stats( $user_id ) { global $wpdb; $table_logs = $wpdb->prefix . 'ktc_logs'; $table_off = $wpdb->prefix . 'ktc_timeoff'; $tz = self::get_ktc_timezone(); $month_start = (new DateTime('first day of this month midnight', $tz))->format('Y-m-d H:i:s'); // Month Total Query $logs = $wpdb->get_results( $wpdb->prepare( "SELECT timestamp, proposed_timestamp, action_type FROM $table_logs WHERE user_id = %d AND timestamp >= %s AND status = 'approved' ORDER BY timestamp ASC", $user_id, $month_start )); $total_seconds = 0; $temp_in = null; foreach ( $logs as $log ) { $ts = !empty($log->proposed_timestamp) ? self::get_local_timestamp($log->proposed_timestamp) : self::get_local_timestamp($log->timestamp); if ( $log->action_type === 'clock_in' ) { $temp_in = $ts; } elseif ( $log->action_type === 'clock_out' && $temp_in ) { $total_seconds += ( $ts - $temp_in ); $temp_in = null; } } return [ 'week' => self::get_weekly_total( $user_id ), 'month' => round( $total_seconds / 3600, 2 ), 'pending_tor' => $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $table_off WHERE user_id = %d AND status = 'pending'", $user_id ) ) ?: 0 ]; } /** * Provides the dashboard stats for the Admin view. * Restored to fix the Fatal Error in class-admin.php */ public static function get_admin_pulse() { global $wpdb; $table_logs = $wpdb->prefix . 'ktc_logs'; $table_off = $wpdb->prefix . 'ktc_timeoff'; return [ 'active_now' => $wpdb->get_var("SELECT COUNT(*) FROM $table_logs WHERE action_type = 'clock_in' AND status = 'approved'"), 'pending_edits' => $wpdb->get_var("SELECT COUNT(*) FROM $table_logs WHERE status = 'pending_correction'"), 'total_pending_tor' => $wpdb->get_var("SELECT COUNT(*) FROM $table_off WHERE status = 'pending'") ?: 0 ]; } /** * Formats raw seconds into a human-readable Hh Mm Ss format. * Restored to fix the Fatal Error on line 157 of class-admin.php */ public static function format_seconds( $seconds ) { $hours = floor( $seconds / 3600 ); $minutes = floor( ( $seconds % 3600 ) / 60 ); $seconds = $seconds % 60; $output = ''; if ( $hours > 0 ) $output .= "{$hours}h "; if ( $minutes > 0 ) $output .= "{$minutes}m "; $output .= "{$seconds}s"; return trim( $output ); } /** * Scans for active shifts exceeding the safety threshold. * Triggered hourly via wp_cron. */ public static function scan_for_zombie_shifts() { global $wpdb; $threshold = get_option( 'ktc_alert_threshold', 12 ); $table = $wpdb->prefix . 'ktc_logs'; $active_shifts = $wpdb->get_results(" SELECT t1.user_id, t1.timestamp FROM $table t1 INNER JOIN ( SELECT user_id, MAX(id) as max_id FROM $table GROUP BY user_id ) t2 ON t1.id = t2.max_id WHERE t1.action_type = 'clock_in' "); foreach ( $active_shifts as $shift ) { $start_time = new DateTime( $shift->timestamp, self::get_ktc_timezone() ); $diff_hours = (self::get_now()->getTimestamp() - $start_time->getTimestamp()) / 3600; if ( $diff_hours > $threshold ) { self::dispatch_alerts( $shift->user_id, round($diff_hours, 1) ); } } } private static function dispatch_alerts( $user_id, $hours ) { $user = get_userdata( $user_id ); if ( ! $user ) return; // Cooldown check (once per 12 hours) $last_sent = get_user_meta( $user_id, '_ktc_last_zombie_alert', true ); if ( $last_sent && ( time() - $last_sent < 43200 ) ) return; $subject = "⚠️ Long Shift Alert: " . $user->display_name; $message = "User is still clocked in after $hours hours."; // 1. Email Alert wp_mail( $user->user_email, $subject, $message ); // 2. Teams Alert (if enabled) if ( get_option( 'ktc_enable_teams' ) && get_option( 'ktc_teams_webhook' ) ) { self::send_to_teams( $user->display_name, $hours ); } update_user_meta( $user_id, '_ktc_last_zombie_alert', time() ); } private static function send_to_teams( $name, $hours ) { $webhook_url = get_option( 'ktc_teams_webhook' ); $payload = [ "@type" => "MessageCard", "@context" => "http://schema.org/extensions", "themeColor" => "d32f2f", "summary" => "Zombie Shift Detected", "sections" => [[ "activityTitle" => "⚠️ Zombie Shift Alert", "activitySubtitle" => "Threshold Exceeded", "facts" => [ ["name" => "User:", "value" => $name], ["name" => "Duration:", "value" => $hours . " hours"], ["name" => "Status:", "value" => "Flagged for Correction"] ], "markdown" => true ]] ]; wp_remote_post( $webhook_url, [ 'body' => json_encode($payload), 'headers' => ['Content-Type' => 'application/json'], ]); } }