378 lines
14 KiB
PHP
Executable File
378 lines
14 KiB
PHP
Executable File
<?php
|
|
if ( ! defined( 'ABSPATH' ) ) exit;
|
|
|
|
class KTC_Logic {
|
|
|
|
public static function init() {
|
|
// Use template_redirect for front-end and admin_init for back-end
|
|
add_action( 'template_redirect', array( __CLASS__, 'handle_actions' ) );
|
|
add_action( 'admin_init', array( __CLASS__, 'handle_actions' ), 5 );
|
|
|
|
// Standard WP-Cron
|
|
if ( ! wp_next_scheduled( 'ktc_zombie_shift_check' ) ) { wp_schedule_event( time(), 'hourly', 'ktc_zombie_shift_check' ); }
|
|
add_action( 'ktc_zombie_shift_check', array( __CLASS__, 'scan_for_zombie_shifts' ) );
|
|
|
|
// Manual Trigger for System Cron (e.g., /usr/local/bin/php wp-cli.phar eval "KTC_Logic::scan_for_zombie_shifts();")
|
|
// Or via a GET request: yoursite.com/?ktc_manual_cron=[secret_key]
|
|
add_action( 'init', function() {
|
|
if ( isset($_GET['ktc_manual_cron']) && $_GET['ktc_manual_cron'] === get_option('ktc_cron_secret') ) {
|
|
self::scan_for_zombie_shifts();
|
|
exit('Cron Executed');
|
|
}
|
|
});
|
|
// END init()
|
|
}
|
|
|
|
public static function install() {
|
|
global $wpdb;
|
|
$table_name = $wpdb->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'],
|
|
]);
|
|
}
|
|
|
|
}
|