Baseline: The current state of the mess
This commit is contained in:
Executable
+158
@@ -0,0 +1,158 @@
|
|||||||
|
|
||||||
|
function ktc_open_edit_modal(id, ts, isToday) {
|
||||||
|
document.getElementById("ktc_edit_id").value=id;
|
||||||
|
const origTimeDisplay = document.getElementById("ktc_modal_orig_time");
|
||||||
|
if (origTimeDisplay) { origTimeDisplay.innerText = "Original: " + ts; }
|
||||||
|
document.getElementById("ktc_edit_modal").style.display="block";
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Guardrail 1: Punch Lockout ---
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const btn = document.getElementById('ktc-punch-button');
|
||||||
|
const timerDiv = document.getElementById('ktc-timer');
|
||||||
|
const countdown = document.getElementById('ktc-countdown');
|
||||||
|
const form = document.getElementById('ktc-punch-form');
|
||||||
|
|
||||||
|
function updateTimer() {
|
||||||
|
const expiry = localStorage.getItem('ktc_lockout_expiry');
|
||||||
|
if (expiry) {
|
||||||
|
const now = Date.now();
|
||||||
|
const timeLeft = Math.ceil((expiry - now) / 1000);
|
||||||
|
|
||||||
|
if (timeLeft > 0) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.style.opacity = '0.5';
|
||||||
|
btn.style.cursor = 'not-allowed';
|
||||||
|
timerDiv.style.display = 'block';
|
||||||
|
countdown.innerText = timeLeft;
|
||||||
|
setTimeout(updateTimer, 1000); // Check again in 1s
|
||||||
|
} else {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.style.opacity = '1';
|
||||||
|
btn.style.cursor = 'pointer';
|
||||||
|
timerDiv.style.display = 'none';
|
||||||
|
localStorage.removeItem('ktc_lockout_expiry');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial check on page load
|
||||||
|
updateTimer();
|
||||||
|
|
||||||
|
form?.addEventListener('submit', function(e) {
|
||||||
|
// Set expiry for 60 seconds from now
|
||||||
|
const expiryTime = Date.now() + 60000;
|
||||||
|
localStorage.setItem('ktc_lockout_expiry', expiryTime);
|
||||||
|
// The page will now refresh, and updateTimer() will catch it on reload
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Guardrail 2: Modal Future-Time Validation ---
|
||||||
|
document.getElementById('ktc_proposed_time')?.addEventListener('input', function() {
|
||||||
|
const submitBtn = this.form.querySelector('button[type="submit"]');
|
||||||
|
// If the input has a 'max' attribute and value exceeds it
|
||||||
|
if (this.max && this.value > this.max) {
|
||||||
|
this.style.borderColor = 'red';
|
||||||
|
this.style.backgroundColor = '#fff0f0';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.style.opacity = '0.5';
|
||||||
|
} else {
|
||||||
|
this.style.borderColor = '#ccc';
|
||||||
|
this.style.backgroundColor = '#fff';
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.style.opacity = '1';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function ktc_open_edit_modal(id, ts, isToday) {
|
||||||
|
document.getElementById("ktc_edit_id").value = id;
|
||||||
|
document.getElementById("ktc_modal_orig_time").innerText = "Original: " + ts;
|
||||||
|
|
||||||
|
const timeInput = document.getElementById("ktc_proposed_time");
|
||||||
|
if (isToday) {
|
||||||
|
// Set max to current Chicago time (HH:MM)
|
||||||
|
const now = new Date().toLocaleTimeString('en-GB', {
|
||||||
|
timeZone: ktcSovereignTZ,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
timeInput.setAttribute('max', now);
|
||||||
|
} else {
|
||||||
|
timeInput.removeAttribute('max');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("ktc_edit_modal").style.display = "block";
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Guardrail 3: The Live Heartbeat ---
|
||||||
|
function ktc_start_heartbeat() {
|
||||||
|
const elapsedElement = document.getElementById('ktc-live-elapsed');
|
||||||
|
if (!elapsedElement) return;
|
||||||
|
|
||||||
|
// Pull the initial seconds we injected via PHP
|
||||||
|
let totalSeconds = parseInt(elapsedElement.getAttribute('data-seconds'));
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
totalSeconds++;
|
||||||
|
|
||||||
|
// Math to format seconds into "Xh Ym Zs" or "Xh Ym"
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
// Update the display (Adding seconds for that "Live" feel)
|
||||||
|
elapsedElement.innerText = `${hours}h ${minutes}m ${seconds}s`;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consolidated KTC Heartbeat: Handles Real-Time Clock & Title Teleportation
|
||||||
|
*/
|
||||||
|
function ktc_init_digital_clock() {
|
||||||
|
const clock = document.getElementById('ktc-digital-clock');
|
||||||
|
if (!clock) return;
|
||||||
|
|
||||||
|
// Use a single interval to handle both time updates and UI placement
|
||||||
|
setInterval(() => {
|
||||||
|
// 1. Generate the time string using our Sovereign Timezone
|
||||||
|
const tz = typeof ktcSovereignTZ !== 'undefined' ? ktcSovereignTZ : 'America/Chicago';
|
||||||
|
const timeString = new Date().toLocaleTimeString('en-US', {
|
||||||
|
timeZone: tz,
|
||||||
|
hour12: true,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Update the display
|
||||||
|
clock.innerText = timeString;
|
||||||
|
|
||||||
|
// 3. One-time Teleport Check
|
||||||
|
// We only attempt to move it if it's not already inside the 'ktc-clock-target'
|
||||||
|
const pageTitle = document.querySelector('.entry-title, h1.page-title, h1');
|
||||||
|
const isAlreadyTeleported = document.getElementById('ktc-clock-target');
|
||||||
|
|
||||||
|
if (pageTitle && !isAlreadyTeleported) {
|
||||||
|
const target = document.createElement('span');
|
||||||
|
target.id = 'ktc-clock-target';
|
||||||
|
target.style.marginLeft = '20px';
|
||||||
|
target.style.fontSize = '0.6em';
|
||||||
|
target.style.verticalAlign = 'middle';
|
||||||
|
target.style.color = '#888'; // Subtle color for the clock in the title
|
||||||
|
|
||||||
|
pageTitle.appendChild(target);
|
||||||
|
target.appendChild(clock); // Physically moves the element in the DOM
|
||||||
|
|
||||||
|
// Cleanup the source container if it exists
|
||||||
|
const sourceContainer = document.getElementById('ktc-clock-teleport-source');
|
||||||
|
if (sourceContainer) sourceContainer.remove();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
// Initialize on DOM load
|
||||||
|
document.addEventListener('DOMContentLoaded', ktc_init_digital_clock);
|
||||||
|
|
||||||
|
// Initialize on load
|
||||||
|
document.addEventListener('DOMContentLoaded', ktc_start_heartbeat);
|
||||||
|
|
||||||
|
|
||||||
Executable
+196
@@ -0,0 +1,196 @@
|
|||||||
|
|
||||||
|
|
||||||
|
// --- Guardrail 1: Punch Lockout ---
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const btn = document.getElementById('ktc-punch-button');
|
||||||
|
const timerDiv = document.getElementById('ktc-timer');
|
||||||
|
const countdown = document.getElementById('ktc-countdown');
|
||||||
|
const form = document.getElementById('ktc-punch-form');
|
||||||
|
|
||||||
|
// Initial check on page load
|
||||||
|
updateTimer();
|
||||||
|
|
||||||
|
function updateTimer() {
|
||||||
|
const expiry = localStorage.getItem('ktc_lockout_expiry');
|
||||||
|
if (expiry) {
|
||||||
|
const now = Date.now();
|
||||||
|
const timeLeft = Math.ceil((expiry - now) / 1000);
|
||||||
|
|
||||||
|
if (timeLeft > 0) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.style.opacity = '0.5';
|
||||||
|
btn.style.cursor = 'not-allowed';
|
||||||
|
timerDiv.style.display = 'block';
|
||||||
|
countdown.innerText = timeLeft;
|
||||||
|
setTimeout(updateTimer, 1000); // Check again in 1s
|
||||||
|
} else {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.style.opacity = '1';
|
||||||
|
btn.style.cursor = 'pointer';
|
||||||
|
timerDiv.style.display = 'none';
|
||||||
|
localStorage.removeItem('ktc_lockout_expiry');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- New Listener for Admin Surgical Edits ---
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
// Check if the clicked element (or its parent) has our trigger class
|
||||||
|
const trigger = e.target.closest('.ktc-edit-trigger');
|
||||||
|
|
||||||
|
if (trigger) {
|
||||||
|
e.preventDefault(); // Stop the '#' jump
|
||||||
|
|
||||||
|
// Pull the data attributes we set in PHP
|
||||||
|
const logId = trigger.getAttribute('data-log-id');
|
||||||
|
const timestamp = trigger.getAttribute('data-current-time');
|
||||||
|
|
||||||
|
// Determine if it's "Today" for the future-time guardrail
|
||||||
|
// Simple check: does the date string match today's YYYY-MM-DD?
|
||||||
|
const todayStr = new Date().toISOString().split('T')[0];
|
||||||
|
const isToday = timestamp.includes(todayStr);
|
||||||
|
|
||||||
|
// Fire your existing function
|
||||||
|
ktc_open_edit_modal(logId, timestamp, isToday);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = document.querySelector('body');
|
||||||
|
if (body) {
|
||||||
|
body.addEventListener('click', function(e) {
|
||||||
|
// Check if the clicked element is our edit link
|
||||||
|
if (e.target && e.target.classList.contains('ktc-edit-trigger')) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const logId = e.target.getAttribute('data-log-id');
|
||||||
|
const currentTime = e.target.getAttribute('data-current-time');
|
||||||
|
|
||||||
|
// Extract HH:MM for the time input (expects 24hr format HH:MM)
|
||||||
|
// Assuming timestamp format: "2026-05-04 14:30:00"
|
||||||
|
const timeParts = currentTime.split(' ');
|
||||||
|
const rawTime = timeParts.length > 1 ? timeParts[1].substring(0, 5) : '';
|
||||||
|
|
||||||
|
// Call the existing modal function
|
||||||
|
ktc_open_edit_modal(logId, currentTime, true, rawTime);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
form?.addEventListener('submit', function(e) {
|
||||||
|
// Set expiry for 60 seconds from now
|
||||||
|
const expiryTime = Date.now() + 60000;
|
||||||
|
localStorage.setItem('ktc_lockout_expiry', expiryTime);
|
||||||
|
// The page will now refresh, and updateTimer() will catch it on reload
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Guardrail 2: Modal Future-Time Validation ---
|
||||||
|
document.getElementById('ktc_proposed_time')?.addEventListener('input', function() {
|
||||||
|
const submitBtn = this.form.querySelector('button[type="submit"]');
|
||||||
|
// If the input has a 'max' attribute and value exceeds it
|
||||||
|
if (this.max && this.value > this.max) {
|
||||||
|
this.style.borderColor = 'red';
|
||||||
|
this.style.backgroundColor = '#fff0f0';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.style.opacity = '0.5';
|
||||||
|
} else {
|
||||||
|
this.style.borderColor = '#ccc';
|
||||||
|
this.style.backgroundColor = '#fff';
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.style.opacity = '1';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function ktc_open_edit_modal(id, ts, isToday) {
|
||||||
|
document.getElementById("ktc_edit_id").value = id;
|
||||||
|
document.getElementById("ktc_modal_orig_time").innerText = "Original: " + ts;
|
||||||
|
|
||||||
|
const timeInput = document.getElementById("ktc_proposed_time");
|
||||||
|
if (isToday) {
|
||||||
|
// Set max to current Chicago time (HH:MM)
|
||||||
|
const now = new Date().toLocaleTimeString('en-GB', {
|
||||||
|
timeZone: ktcSovereignTZ,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
timeInput.setAttribute('max', now);
|
||||||
|
} else {
|
||||||
|
timeInput.removeAttribute('max');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("ktc_edit_modal").style.display = "block";
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Guardrail 3: The Live Heartbeat ---
|
||||||
|
function ktc_start_heartbeat() {
|
||||||
|
const elapsedElement = document.getElementById('ktc-live-elapsed');
|
||||||
|
if (!elapsedElement) return;
|
||||||
|
|
||||||
|
// Pull the initial seconds we injected via PHP
|
||||||
|
let totalSeconds = parseInt(elapsedElement.getAttribute('data-seconds'));
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
totalSeconds++;
|
||||||
|
|
||||||
|
// Math to format seconds into "Xh Ym Zs" or "Xh Ym"
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
// Update the display (Adding seconds for that "Live" feel)
|
||||||
|
elapsedElement.innerText = `${hours}h ${minutes}m ${seconds}s`;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consolidated KTC Heartbeat: Handles Real-Time Clock & Title Teleportation
|
||||||
|
*/
|
||||||
|
function ktc_init_digital_clock() {
|
||||||
|
const clock = document.getElementById('ktc-digital-clock');
|
||||||
|
if (!clock) return;
|
||||||
|
|
||||||
|
// Use a single interval to handle both time updates and UI placement
|
||||||
|
setInterval(() => {
|
||||||
|
// 1. Generate the time string using our Sovereign Timezone
|
||||||
|
const tz = typeof ktcSovereignTZ !== 'undefined' ? ktcSovereignTZ : 'America/Chicago';
|
||||||
|
const timeString = new Date().toLocaleTimeString('en-US', {
|
||||||
|
timeZone: tz,
|
||||||
|
hour12: true,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Update the display
|
||||||
|
clock.innerText = timeString;
|
||||||
|
|
||||||
|
// 3. One-time Teleport Check
|
||||||
|
// We only attempt to move it if it's not already inside the 'ktc-clock-target'
|
||||||
|
const pageTitle = document.querySelector('.entry-title, h1.page-title, h1');
|
||||||
|
const isAlreadyTeleported = document.getElementById('ktc-clock-target');
|
||||||
|
|
||||||
|
if (pageTitle && !isAlreadyTeleported) {
|
||||||
|
const target = document.createElement('span');
|
||||||
|
target.id = 'ktc-clock-target';
|
||||||
|
target.style.marginLeft = '20px';
|
||||||
|
target.style.fontSize = '0.6em';
|
||||||
|
target.style.verticalAlign = 'middle';
|
||||||
|
target.style.color = '#888'; // Subtle color for the clock in the title
|
||||||
|
|
||||||
|
pageTitle.appendChild(target);
|
||||||
|
target.appendChild(clock); // Physically moves the element in the DOM
|
||||||
|
|
||||||
|
// Cleanup the source container if it exists
|
||||||
|
const sourceContainer = document.getElementById('ktc-clock-teleport-source');
|
||||||
|
if (sourceContainer) sourceContainer.remove();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
// Initialize on DOM load
|
||||||
|
document.addEventListener('DOMContentLoaded', ktc_init_digital_clock);
|
||||||
|
|
||||||
|
// Initialize on load
|
||||||
|
document.addEventListener('DOMContentLoaded', ktc_start_heartbeat);
|
||||||
|
|
||||||
|
|
||||||
Executable
+157
@@ -0,0 +1,157 @@
|
|||||||
|
|
||||||
|
function ktc_open_edit_modal(id, ts, isToday) {
|
||||||
|
document.getElementById("ktc_edit_id").value=id;
|
||||||
|
const origTimeDisplay = document.getElementById("ktc_modal_orig_time");
|
||||||
|
if (origTimeDisplay) { origTimeDisplay.innerText = "Original: " + ts; }
|
||||||
|
document.getElementById("ktc_edit_modal").style.display="block";
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Guardrail 1: Punch Lockout ---
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const btn = document.getElementById('ktc-punch-button');
|
||||||
|
const timerDiv = document.getElementById('ktc-timer');
|
||||||
|
const countdown = document.getElementById('ktc-countdown');
|
||||||
|
const form = document.getElementById('ktc-punch-form');
|
||||||
|
|
||||||
|
function updateTimer() {
|
||||||
|
const expiry = localStorage.getItem('ktc_lockout_expiry');
|
||||||
|
if (expiry) {
|
||||||
|
const now = Date.now();
|
||||||
|
const timeLeft = Math.ceil((expiry - now) / 1000);
|
||||||
|
|
||||||
|
if (timeLeft > 0) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.style.opacity = '0.5';
|
||||||
|
btn.style.cursor = 'not-allowed';
|
||||||
|
timerDiv.style.display = 'block';
|
||||||
|
countdown.innerText = timeLeft;
|
||||||
|
setTimeout(updateTimer, 1000); // Check again in 1s
|
||||||
|
} else {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.style.opacity = '1';
|
||||||
|
btn.style.cursor = 'pointer';
|
||||||
|
timerDiv.style.display = 'none';
|
||||||
|
localStorage.removeItem('ktc_lockout_expiry');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial check on page load
|
||||||
|
updateTimer();
|
||||||
|
|
||||||
|
form?.addEventListener('submit', function(e) {
|
||||||
|
// Set expiry for 60 seconds from now
|
||||||
|
const expiryTime = Date.now() + 60000;
|
||||||
|
localStorage.setItem('ktc_lockout_expiry', expiryTime);
|
||||||
|
// The page will now refresh, and updateTimer() will catch it on reload
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Guardrail 2: Modal Future-Time Validation ---
|
||||||
|
document.getElementById('ktc_proposed_time')?.addEventListener('input', function() {
|
||||||
|
const submitBtn = this.form.querySelector('button[type="submit"]');
|
||||||
|
// If the input has a 'max' attribute and value exceeds it
|
||||||
|
if (this.max && this.value > this.max) {
|
||||||
|
this.style.borderColor = 'red';
|
||||||
|
this.style.backgroundColor = '#fff0f0';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.style.opacity = '0.5';
|
||||||
|
} else {
|
||||||
|
this.style.borderColor = '#ccc';
|
||||||
|
this.style.backgroundColor = '#fff';
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.style.opacity = '1';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function ktc_open_edit_modal(id, ts, isToday) {
|
||||||
|
document.getElementById("ktc_edit_id").value = id;
|
||||||
|
document.getElementById("ktc_modal_orig_time").innerText = "Original: " + ts;
|
||||||
|
|
||||||
|
const timeInput = document.getElementById("ktc_proposed_time");
|
||||||
|
if (isToday) {
|
||||||
|
// Set max to current Chicago time (HH:MM)
|
||||||
|
const now = new Date().toLocaleTimeString('en-GB', {
|
||||||
|
timeZone: ktcSovereignTZ,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
timeInput.setAttribute('max', now);
|
||||||
|
} else {
|
||||||
|
timeInput.removeAttribute('max');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("ktc_edit_modal").style.display = "block";
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Guardrail 3: The Live Heartbeat ---
|
||||||
|
function ktc_start_heartbeat() {
|
||||||
|
const elapsedElement = document.getElementById('ktc-live-elapsed');
|
||||||
|
if (!elapsedElement) return;
|
||||||
|
|
||||||
|
// Pull the initial seconds we injected via PHP
|
||||||
|
let totalSeconds = parseInt(elapsedElement.getAttribute('data-seconds'));
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
totalSeconds++;
|
||||||
|
|
||||||
|
// Math to format seconds into "Xh Ym Zs" or "Xh Ym"
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
// Update the display (Adding seconds for that "Live" feel)
|
||||||
|
elapsedElement.innerText = `${hours}h ${minutes}m ${seconds}s`;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ktc_run_digital_clock() {
|
||||||
|
const clockElement = document.getElementById('ktc-digital-clock');
|
||||||
|
if (!clockElement) return;
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
const now = new Date().toLocaleTimeString('en-US', {
|
||||||
|
timeZone: typeof ktcSovereignTZ !== 'undefined' ? ktcSovereignTZ : 'America/Chicago',
|
||||||
|
hour12: true,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
});
|
||||||
|
clockElement.innerText = now;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
document.addEventListener('DOMContentLoaded', ktc_run_digital_clock);
|
||||||
|
|
||||||
|
function updateDigitalClock() {
|
||||||
|
const clock = document.getElementById('ktc-digital-clock');
|
||||||
|
if (!clock) return;
|
||||||
|
|
||||||
|
const tz = typeof ktcSovereignTZ !== 'undefined' ? ktcSovereignTZ : 'America/Chicago';
|
||||||
|
const timeString = new Date().toLocaleTimeString('en-US', {
|
||||||
|
timeZone: tz,
|
||||||
|
hour12: true,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
});
|
||||||
|
|
||||||
|
clock.innerText = timeString;
|
||||||
|
|
||||||
|
// Teleport to Page Title (h1) if not already there
|
||||||
|
const pageTitle = document.querySelector('.entry-title, h1.page-title');
|
||||||
|
if (pageTitle && !document.getElementById('ktc-clock-target')) {
|
||||||
|
const target = document.createElement('span');
|
||||||
|
target.id = 'ktc-clock-target';
|
||||||
|
target.style.marginLeft = '20px';
|
||||||
|
target.style.fontSize = '0.6em';
|
||||||
|
target.style.verticalAlign = 'middle';
|
||||||
|
pageTitle.appendChild(target);
|
||||||
|
target.appendChild(clock); // Moves the element in the DOM
|
||||||
|
document.getElementById('ktc-clock-teleport-source').remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on load
|
||||||
|
document.addEventListener('DOMContentLoaded', ktc_start_heartbeat);
|
||||||
|
|
||||||
|
|
||||||
Executable
+370
@@ -0,0 +1,370 @@
|
|||||||
|
<?php
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) exit;
|
||||||
|
|
||||||
|
class KTC_Admin {
|
||||||
|
|
||||||
|
public static function enqueue_scripts() {
|
||||||
|
|
||||||
|
// Only load on the Clock page/Front page to keep the rest of WP light
|
||||||
|
//if ( ! is_front_page() && ! is_page('clock') ) { return; }
|
||||||
|
|
||||||
|
wp_enqueue_script(
|
||||||
|
'ktc-app-js',
|
||||||
|
plugins_url( 'assets/ktc-app.js', dirname( __FILE__ ) ),
|
||||||
|
array(),
|
||||||
|
'1.1.0',
|
||||||
|
true //load in footer
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pass PHP data to JS (like our Sovereign Timezone or current shift state)
|
||||||
|
wp_localize_script( 'ktc-app-js', 'ktcData', array(
|
||||||
|
'ajaxurl' => admin_url( 'admin-ajax.php' ),
|
||||||
|
'lockoutTime' => 60 // Seconds for the button cooldown
|
||||||
|
));
|
||||||
|
|
||||||
|
$tz_string = get_option( 'ktc_timezone', 'America/Chicago' );
|
||||||
|
wp_add_inline_script( 'ktc-app-js', "const ktcSovereignTZ = '{$tz_string}';", 'before' );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function init() {
|
||||||
|
add_shortcode( 'krista_timeclock', array( __CLASS__, 'render_timeclock' ) );
|
||||||
|
add_shortcode( 'ktc_portal_welcome', array( __CLASS__, 'render_welcome' ) );
|
||||||
|
add_action( 'wp_footer', array( __CLASS__, 'inject_ui_scripts' ) );
|
||||||
|
add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_scripts' ) );
|
||||||
|
add_action( 'admin_menu', array( __CLASS__, 'register_settings_page' ) );
|
||||||
|
|
||||||
|
add_action( 'admin_init', function() {
|
||||||
|
if ( ! current_user_can('manage_options') && ! defined('DOING_AJAX') && $GLOBALS['pagenow'] !== 'profile.php' && current_user_can('ktc_user') ) {
|
||||||
|
wp_safe_redirect( home_url('/') ); exit;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
add_action( 'admin_init', function() {
|
||||||
|
add_settings_section( 'ktc_main_section', 'General Configuration', null, 'ktc-settings' );
|
||||||
|
add_settings_field( 'ktc_week_start_field', 'Week Starts On', function() {
|
||||||
|
$val = get_option('ktc_week_start', 'Monday');
|
||||||
|
echo "<select name='ktc_week_start'>
|
||||||
|
<option value='Monday' ".selected($val, 'Monday', false).">Monday</option>
|
||||||
|
<option value='Sunday' ".selected($val, 'Sunday', false).">Sunday</option>
|
||||||
|
</select>";
|
||||||
|
}, 'ktc-settings', 'ktc_main_section' );
|
||||||
|
add_settings_field( 'ktc_timezone_field', 'Sovereign Timezone', array( __CLASS__, 'render_timezone_field' ), 'ktc-settings', 'ktc_main_section' );
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a unified dashboard header.
|
||||||
|
* Consolidates Admin 'Pulse' and User 'Stats' based on capability.
|
||||||
|
*/
|
||||||
|
public static function render_welcome() {
|
||||||
|
if ( ! is_user_logged_in() ) return 'Please log in to continue';
|
||||||
|
|
||||||
|
$user = wp_get_current_user();
|
||||||
|
$is_admin = current_user_can('manage_options');
|
||||||
|
|
||||||
|
// Everyone gets Personal Stats
|
||||||
|
$stats = KTC_Logic::get_user_stats( $user->ID );
|
||||||
|
$user_stat_data = [
|
||||||
|
['label' => 'This Week', 'val' => $stats['week'] . ' hrs', 'color' => '#2196F3'],
|
||||||
|
['label' => 'This Month', 'val' => $stats['month'] . ' hrs', 'color' => '#4CAF50'],
|
||||||
|
['label' => 'My Pending TORs', 'val' => $stats['pending_tor'], 'color' => '#FF9800']
|
||||||
|
];
|
||||||
|
|
||||||
|
ob_start(); ?>
|
||||||
|
|
||||||
|
<!-- PRIMARY CONTAINER: User Personal Stats -->
|
||||||
|
<div class="ktc-welcome-container" style="background: #fff; border: 1px solid #e5e5e5; border-radius: 8px; padding: 25px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); font-family: sans-serif;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="margin:0; color: #333;">Welcome, <?php echo esc_html( $user->display_name ); ?></h2>
|
||||||
|
<p style="color: #666; margin: 5px 0 0 0; font-size: 13px;">Personal Time Tracking Overview</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap:15px;">
|
||||||
|
<?php foreach ( $user_stat_data as $stat ) : ?>
|
||||||
|
<div style="background:#f9f9f9; padding:15px; border-radius:8px; border-left: 4px solid <?php echo $stat['color']; ?>;">
|
||||||
|
<small style="color:#666; text-transform:uppercase; font-size:10px; font-weight:bold;"><?php echo $stat['label']; ?></small><br>
|
||||||
|
<span style="font-size:20px; font-weight:bold; color:#222;"><?php echo $stat['val']; ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SECONDARY CONTAINER: Admin System Pulse -->
|
||||||
|
<?php if ( $is_admin ) :
|
||||||
|
$pulse = KTC_Logic::get_admin_pulse();
|
||||||
|
$admin_stat_data = [
|
||||||
|
['label' => 'Active Now', 'val' => $pulse['active_now'], 'color' => '#4CAF50'],
|
||||||
|
['label' => 'Pending Edits', 'val' => $pulse['pending_edits'], 'color' => '#2196F3'],
|
||||||
|
['label' => 'System TORs', 'val' => $pulse['total_pending_tor'], 'color' => '#FF9800']
|
||||||
|
];
|
||||||
|
?>
|
||||||
|
<div class="ktc-admin-pulse-container" style="background: #2c3338; border-radius: 8px; padding: 20px; margin-bottom: 30px; color: #eee; font-family: sans-serif;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||||
|
<h3 style="margin:0; font-size: 14px; text-transform: uppercase; letter-spacing: 1px;">System Pulse</h3>
|
||||||
|
<span style="background: #edb120; color: #000; font-size: 9px; padding: 2px 8px; border-radius: 4px; font-weight: bold;">ADMIN CONSOLE</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap:15px;">
|
||||||
|
<?php foreach ( $admin_stat_data as $stat ) : ?>
|
||||||
|
<div style="background:rgba(255,255,255,0.05); padding:12px; border-radius:6px; border-top: 2px solid <?php echo $stat['color']; ?>;">
|
||||||
|
<small style="color:#aaa; text-transform:uppercase; font-size:9px; font-weight:bold;"><?php echo $stat['label']; ?></small><br>
|
||||||
|
<span style="font-size:18px; font-weight:bold; color:#fff;"><?php echo $stat['val']; ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 15px; display: flex; gap: 10px;">
|
||||||
|
<a href="<?php echo admin_url('admin.php?page=ktc-settings'); ?>" style="text-decoration:none; color:#bbb; font-size:11px; background: rgba(255,255,255,0.1); padding: 5px 10px; border-radius: 4px;">🛠️ System Settings</a>
|
||||||
|
<a href="<?php echo admin_url('profile.php'); ?>" style="text-decoration:none; color:#bbb; font-size:11px; background: rgba(255,255,255,0.1); padding: 5px 10px; border-radius: 4px;">⚙️ Global Profile</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Action Bar (Logout, etc) -->
|
||||||
|
<div style="display:flex; gap:10px; margin-bottom: 30px;">
|
||||||
|
<a href="<?php echo wp_logout_url( home_url() ); ?>" style="text-decoration:none; color:#d32f2f; font-size:13px; border:1px solid #d32f2f; padding:8px 15px; border-radius:4px;">🚪 Logout</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Real time clock source -->
|
||||||
|
<div id="ktc-clock-teleport-source" style="display:none;">
|
||||||
|
<div class="ktc-digital-clock" id="ktc-digital-clock">00:00:00</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function stat_box($label, $val, $color) {
|
||||||
|
return "<div style='background:#f9f9f9; padding:15px; border-radius:8px; border-left: 4px solid $color;'>
|
||||||
|
<small style='color:#666; text-transform:uppercase; font-size:10px; font-weight:bold;'>$label</small><br>
|
||||||
|
<span style='font-size:20px; font-weight:bold; color:#222;'>$val</span></div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function render_timeclock() {
|
||||||
|
if ( ! is_user_logged_in() ) return '<p>Please log in.</p>';
|
||||||
|
global $wpdb;
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$table_name = $wpdb->prefix . 'ktc_logs';
|
||||||
|
|
||||||
|
$status = $wpdb->get_var( $wpdb->prepare( "SELECT action_type FROM $table_name WHERE user_id = %d ORDER BY timestamp DESC LIMIT 1", $user_id ) ) ?: 'clock_out';
|
||||||
|
$btn_color = ($status === 'clock_in') ? '#d9534f' : '#5cb85c';
|
||||||
|
$next_action = ($status === 'clock_in') ? 'clock_out' : 'clock_in';
|
||||||
|
|
||||||
|
ob_start(); ?>
|
||||||
|
<div style="background: #f4f4f4; padding: 15px; border-radius: 5px; margin-bottom: 20px; display: flex; justify-content: space-between;">
|
||||||
|
<?php if ( $status === 'clock_in' ): $elapsed = KTC_Logic::get_elapsed_time($user_id); ?>
|
||||||
|
<div><strong>Current Shift:</strong> <span id="ktc-live-elapsed" data-seconds="<?php echo $elapsed; ?>"><?php echo KTC_Logic::format_seconds($elapsed); ?></span></div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div><strong>Status:</strong> <span style="color:#666;">Off the Clock</span></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div><strong>Weekly Total:</strong> <?php echo KTC_Logic::get_weekly_total($user_id); ?> hrs</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" id="ktc-punch-form">
|
||||||
|
<?php wp_nonce_field( 'ktc_punch_action', 'ktc_nonce' ); ?>
|
||||||
|
<input type="hidden" name="ktc_action" value="punch">
|
||||||
|
<button type="submit" id="ktc-punch-button" style="background-color:<?php echo $btn_color; ?>; color:white; padding: 15px 30px; border:none; border-radius:5px; cursor:pointer; font-size: 18px;">
|
||||||
|
<?php echo ucfirst(str_replace('_',' ',$next_action)); ?>
|
||||||
|
</button>
|
||||||
|
<div id="ktc-timer" style="margin:5px 0 10px 5px; font-size:12px; color:#999; display:none;">Processing... <span id="ktc-countdown">60</span>s</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$history = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM $table_name WHERE user_id = %d ORDER BY timestamp DESC LIMIT 10", $user_id ) );
|
||||||
|
if ( $history ): ?>
|
||||||
|
<h3>Recent Activity</h3>
|
||||||
|
<table style="width:100%; border-collapse: collapse; margin-top: 20px;">
|
||||||
|
<?php foreach ( $history as $log ):
|
||||||
|
$ts_local = KTC_Logic::get_local_timestamp($log->timestamp);
|
||||||
|
$orig_time = wp_date( 'D, M j, g:i a', $ts_local );
|
||||||
|
$is_today = (wp_date('Y-m-d', $ts_local) === (new DateTime('now', new DateTimeZone(KTC_TIMEZONE)))->format('Y-m-d')) ? 'true' : 'false';
|
||||||
|
?>
|
||||||
|
<tr style="border-bottom: 1px solid #eee;">
|
||||||
|
<td style="padding: 10px 0;"><?php echo $orig_time; ?></td>
|
||||||
|
<td><?php echo ucfirst(str_replace('_',' ',$log->action_type)); ?></td>
|
||||||
|
<td style="text-align:right;"><button type="button" onclick='ktc_open_edit_modal(<?php echo $log->id; ?>, "<?php echo $orig_time; ?>", <?php echo $is_today; ?>)' style="...">Edit</button></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ( current_user_can('manage_options') ) echo self::render_admin_queue(); ?>
|
||||||
|
<?php return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function render_admin_queue() {
|
||||||
|
global $wpdb;
|
||||||
|
$pending = $wpdb->get_results("SELECT * FROM {$wpdb->prefix}ktc_logs WHERE status = 'pending_correction' ORDER BY timestamp DESC");
|
||||||
|
if ( ! $pending ) return '';
|
||||||
|
ob_start(); ?>
|
||||||
|
<div style="margin-top:40px; border:2px solid #0073aa; padding:20px; border-radius:8px;">
|
||||||
|
<h3>Admin: Correction Queue</h3>
|
||||||
|
<?php foreach($pending as $req): $u = get_userdata($req->user_id); ?>
|
||||||
|
<div style="display:flex; justify-content:space-between; padding:10px 0; border-bottom:1px solid #eee;">
|
||||||
|
<span><strong><?php echo $u->display_name; ?></strong>: <?php echo wp_date('g:i a', KTC_Logic::get_local_timestamp($req->proposed_timestamp)); ?></span>
|
||||||
|
<div>
|
||||||
|
<a href="?ktc_admin_action=approve&log_id=<?php echo $req->id; ?>" style="background:#5cb85c; color:#fff; padding:5px; text-decoration:none;">Approve</a>
|
||||||
|
<a href="?ktc_admin_action=reject&log_id=<?php echo $req->id; ?>" style="background:#d9534f; color:#fff; padding:5px; text-decoration:none;">Reject</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* public static function inject_ui_scripts() {
|
||||||
|
if ( ! is_front_page() && ! is_page('clock') ) return; ?>
|
||||||
|
<div id="ktc_edit_modal" style="display:none; position:fixed; z-index:9999; left:0; top:0; width:100%; height:100%; background:rgba(0,0,0,0.6);">
|
||||||
|
<div style="background:#fff; margin:10% auto; padding:25px; border-radius:8px; width:320px;">
|
||||||
|
<h3>Request Correction</h3>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="ktc_edit_id" id="ktc_edit_id">
|
||||||
|
<input type="time" name="ktc_proposed_time" required style="width:100%;">
|
||||||
|
<textarea name="ktc_edit_notes" style="width:100%; margin-top:10px;"></textarea>
|
||||||
|
<button type="submit" style="background:#5cb85c; color:#fff; border:none; padding:10px 20px; margin-top:10px;">Submit</button>
|
||||||
|
<button type="button" onclick="document.getElementById('ktc_edit_modal').style.display='none'">Cancel</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php }
|
||||||
|
*/
|
||||||
|
public static function inject_ui_scripts() {
|
||||||
|
if ( ! is_front_page() && ! is_page('clock') ) return; ?>
|
||||||
|
|
||||||
|
<div id="ktc_edit_modal" style="display:none; position:fixed; z-index:9999; left:0; top:0; width:100%; height:100%; background:rgba(0,0,0,0.6);">
|
||||||
|
<div style="background:#fff; margin:10% auto; padding:25px; border-radius:8px; width:320px;">
|
||||||
|
<h3>Request Correction</h3>
|
||||||
|
|
||||||
|
<!-- THE MISSING LINK: Add this line -->
|
||||||
|
<p id="ktc_modal_orig_time" style="color: #666; font-size: 0.9em; margin-bottom: 15px;"></p>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<?php wp_nonce_field( 'ktc_punch_action', 'ktc_nonce' ); ?>
|
||||||
|
<input type="hidden" name="ktc_action" value="edit_request">
|
||||||
|
<input type="hidden" name="ktc_edit_id" id="ktc_edit_id">
|
||||||
|
|
||||||
|
<label>Proposed Time:</label>
|
||||||
|
<input type="time" name="ktc_proposed_time" id="ktc_proposed_time" required style="width:100%;">
|
||||||
|
|
||||||
|
<label style="display:block; margin-top:10px;">Reason/Notes:</label>
|
||||||
|
<textarea name="ktc_edit_notes" style="width:100%;"></textarea>
|
||||||
|
|
||||||
|
<button type="submit" style="background:#5cb85c; color:#fff; border:none; padding:10px 20px; margin-top:15px; cursor:pointer;">Submit</button>
|
||||||
|
<button type="button" onclick="document.getElementById('ktc_edit_modal').style.display='none'" style="background:#ccc; border:none; padding:10px 20px; margin-top:15px; cursor:pointer;">Cancel</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php }
|
||||||
|
|
||||||
|
public static function register_settings_page() {
|
||||||
|
// 1. Main Menu Page
|
||||||
|
add_menu_page(
|
||||||
|
'KTC Settings',
|
||||||
|
'KTC Clock',
|
||||||
|
'manage_options',
|
||||||
|
'ktc-settings',
|
||||||
|
array( 'KTC_Admin', 'render_settings_content' ),
|
||||||
|
'dashicons-clock',
|
||||||
|
85
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Register Settings
|
||||||
|
register_setting( 'ktc_settings_group', 'ktc_timezone' );
|
||||||
|
register_setting( 'ktc_settings_group', 'ktc_week_start' );
|
||||||
|
register_setting( 'ktc_settings_group', 'ktc_alert_threshold', ['type' => 'integer', 'default' => 12] );
|
||||||
|
register_setting( 'ktc_settings_group', 'ktc_enable_teams', ['type' => 'boolean', 'default' => 0] );
|
||||||
|
register_setting( 'ktc_settings_group', 'ktc_teams_webhook', ['type' => 'string'] );
|
||||||
|
|
||||||
|
// 3. Section
|
||||||
|
add_settings_section(
|
||||||
|
'ktc_main_section',
|
||||||
|
'Core Configuration',
|
||||||
|
'__return_false',
|
||||||
|
'ktc-settings'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Fields - Triple check that these method names exist in this class
|
||||||
|
add_settings_field(
|
||||||
|
'ktc_timezone_field',
|
||||||
|
'Timezone',
|
||||||
|
array( __CLASS__, 'render_timezone_field' ),
|
||||||
|
'ktc-settings',
|
||||||
|
'ktc_main_section'
|
||||||
|
);
|
||||||
|
|
||||||
|
add_settings_field(
|
||||||
|
'ktc_alert_threshold_field',
|
||||||
|
'Zombie Alert Threshold (Hours)',
|
||||||
|
array( __CLASS__, 'render_threshold_field' ),
|
||||||
|
'ktc-settings',
|
||||||
|
'ktc_main_section'
|
||||||
|
);
|
||||||
|
|
||||||
|
add_settings_field(
|
||||||
|
'ktc_teams_config_field',
|
||||||
|
'MS Teams Integration',
|
||||||
|
array( 'KTC_Admin', 'render_teams_fields' ),
|
||||||
|
'ktc-settings',
|
||||||
|
'ktc_main_section'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function render_settings_content() {
|
||||||
|
?>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>Krista Time Clock Settings</h1>
|
||||||
|
<form method="post" action="options.php">
|
||||||
|
<?php
|
||||||
|
settings_fields( 'ktc_settings_group' );
|
||||||
|
do_settings_sections( 'ktc-settings' );
|
||||||
|
submit_button();
|
||||||
|
?>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the Threshold numeric input
|
||||||
|
*/
|
||||||
|
public static function render_threshold_field() {
|
||||||
|
$val = get_option( 'ktc_alert_threshold', 12 );
|
||||||
|
echo '<input type="number" name="ktc_alert_threshold" value="' . esc_attr( $val ) . '" class="small-text"> ';
|
||||||
|
echo '<p class="description">Hours before a shift is flagged as a "Zombie Shift".</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the Teams Toggle and Webhook URL
|
||||||
|
*/
|
||||||
|
public static function render_teams_fields() {
|
||||||
|
$enabled = get_option( 'ktc_enable_teams', 0 );
|
||||||
|
$webhook = get_option( 'ktc_teams_webhook', '' );
|
||||||
|
?>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="ktc_enable_teams" value="1" <?php checked( $enabled, 1 ); ?>>
|
||||||
|
Enable Teams Notifications
|
||||||
|
</label>
|
||||||
|
<br><br>
|
||||||
|
<input type="text" name="ktc_teams_webhook" value="<?php echo esc_attr( $webhook ); ?>" class="regular-text" placeholder="https://outlook.office.com/webhook/...">
|
||||||
|
<p class="description">Enter your Microsoft Teams Incoming Webhook URL.</p>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function render_timezone_field() {
|
||||||
|
$current_tz = get_option( 'ktc_timezone', 'America/Chicago' );
|
||||||
|
$tz_list = DateTimeZone::listIdentifiers();
|
||||||
|
?>
|
||||||
|
<select name="ktc_timezone">
|
||||||
|
<?php foreach ( $tz_list as $tz ) : ?>
|
||||||
|
<option value="<?php echo esc_attr( $tz ); ?>" <?php selected( $current_tz, $tz ); ?>>
|
||||||
|
<?php echo esc_html( $tz ); ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<p class="description">This defines the legal "Source of Truth" for all timestamps, bypassing server or WP site drift.</p>
|
||||||
|
<?php }
|
||||||
|
|
||||||
|
}
|
||||||
Executable
+370
@@ -0,0 +1,370 @@
|
|||||||
|
<?php
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) exit;
|
||||||
|
|
||||||
|
class KTC_Admin {
|
||||||
|
|
||||||
|
public static function enqueue_scripts() {
|
||||||
|
|
||||||
|
// Only load on the Clock page/Front page to keep the rest of WP light
|
||||||
|
//if ( ! is_front_page() && ! is_page('clock') ) { return; }
|
||||||
|
|
||||||
|
wp_enqueue_script(
|
||||||
|
'ktc-app-js',
|
||||||
|
plugins_url( 'assets/ktc-app.js', dirname( __FILE__ ) ),
|
||||||
|
array(),
|
||||||
|
'1.1.0',
|
||||||
|
true //load in footer
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pass PHP data to JS (like our Sovereign Timezone or current shift state)
|
||||||
|
wp_localize_script( 'ktc-app-js', 'ktcData', array(
|
||||||
|
'ajaxurl' => admin_url( 'admin-ajax.php' ),
|
||||||
|
'lockoutTime' => 60 // Seconds for the button cooldown
|
||||||
|
));
|
||||||
|
|
||||||
|
$tz_string = get_option( 'ktc_timezone', 'America/Chicago' );
|
||||||
|
wp_add_inline_script( 'ktc-app-js', "const ktcSovereignTZ = '{$tz_string}';", 'before' );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function init() {
|
||||||
|
add_shortcode( 'krista_timeclock', array( __CLASS__, 'render_timeclock' ) );
|
||||||
|
add_shortcode( 'ktc_portal_welcome', array( __CLASS__, 'render_welcome' ) );
|
||||||
|
add_action( 'wp_footer', array( __CLASS__, 'inject_ui_scripts' ) );
|
||||||
|
add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_scripts' ) );
|
||||||
|
add_action( 'admin_menu', array( __CLASS__, 'register_settings_page' ) );
|
||||||
|
|
||||||
|
add_action( 'admin_init', function() {
|
||||||
|
if ( ! current_user_can('manage_options') && ! defined('DOING_AJAX') && $GLOBALS['pagenow'] !== 'profile.php' && current_user_can('ktc_user') ) {
|
||||||
|
wp_safe_redirect( home_url('/') ); exit;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
add_action( 'admin_init', function() {
|
||||||
|
add_settings_section( 'ktc_main_section', 'General Configuration', null, 'ktc-settings' );
|
||||||
|
add_settings_field( 'ktc_week_start_field', 'Week Starts On', function() {
|
||||||
|
$val = get_option('ktc_week_start', 'Monday');
|
||||||
|
echo "<select name='ktc_week_start'>
|
||||||
|
<option value='Monday' ".selected($val, 'Monday', false).">Monday</option>
|
||||||
|
<option value='Sunday' ".selected($val, 'Sunday', false).">Sunday</option>
|
||||||
|
</select>";
|
||||||
|
}, 'ktc-settings', 'ktc_main_section' );
|
||||||
|
add_settings_field( 'ktc_timezone_field', 'Sovereign Timezone', array( __CLASS__, 'render_timezone_field' ), 'ktc-settings', 'ktc_main_section' );
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a unified dashboard header.
|
||||||
|
* Consolidates Admin 'Pulse' and User 'Stats' based on capability.
|
||||||
|
*/
|
||||||
|
public static function render_welcome() {
|
||||||
|
if ( ! is_user_logged_in() ) return 'Please log in to continue';
|
||||||
|
|
||||||
|
$user = wp_get_current_user();
|
||||||
|
$is_admin = current_user_can('manage_options');
|
||||||
|
|
||||||
|
// Everyone gets Personal Stats
|
||||||
|
$stats = KTC_Logic::get_user_stats( $user->ID );
|
||||||
|
$user_stat_data = [
|
||||||
|
['label' => 'This Week', 'val' => $stats['week'] . ' hrs', 'color' => '#2196F3'],
|
||||||
|
['label' => 'This Month', 'val' => $stats['month'] . ' hrs', 'color' => '#4CAF50'],
|
||||||
|
['label' => 'My Pending TORs', 'val' => $stats['pending_tor'], 'color' => '#FF9800']
|
||||||
|
];
|
||||||
|
|
||||||
|
ob_start(); ?>
|
||||||
|
|
||||||
|
<!-- PRIMARY CONTAINER: User Personal Stats -->
|
||||||
|
<div class="ktc-welcome-container" style="background: #fff; border: 1px solid #e5e5e5; border-radius: 8px; padding: 25px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); font-family: sans-serif;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="margin:0; color: #333;">Welcome, <?php echo esc_html( $user->display_name ); ?></h2>
|
||||||
|
<p style="color: #666; margin: 5px 0 0 0; font-size: 13px;">Personal Time Tracking Overview</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap:15px;">
|
||||||
|
<?php foreach ( $user_stat_data as $stat ) : ?>
|
||||||
|
<div style="background:#f9f9f9; padding:15px; border-radius:8px; border-left: 4px solid <?php echo $stat['color']; ?>;">
|
||||||
|
<small style="color:#666; text-transform:uppercase; font-size:10px; font-weight:bold;"><?php echo $stat['label']; ?></small><br>
|
||||||
|
<span style="font-size:20px; font-weight:bold; color:#222;"><?php echo $stat['val']; ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SECONDARY CONTAINER: Admin System Pulse -->
|
||||||
|
<?php if ( $is_admin ) :
|
||||||
|
$pulse = KTC_Logic::get_admin_pulse();
|
||||||
|
$admin_stat_data = [
|
||||||
|
['label' => 'Active Now', 'val' => $pulse['active_now'], 'color' => '#4CAF50'],
|
||||||
|
['label' => 'Pending Edits', 'val' => $pulse['pending_edits'], 'color' => '#2196F3'],
|
||||||
|
['label' => 'System TORs', 'val' => $pulse['total_pending_tor'], 'color' => '#FF9800']
|
||||||
|
];
|
||||||
|
?>
|
||||||
|
<div class="ktc-admin-pulse-container" style="background: #2c3338; border-radius: 8px; padding: 20px; margin-bottom: 30px; color: #eee; font-family: sans-serif;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||||
|
<h3 style="margin:0; font-size: 14px; text-transform: uppercase; letter-spacing: 1px;">System Pulse</h3>
|
||||||
|
<span style="background: #edb120; color: #000; font-size: 9px; padding: 2px 8px; border-radius: 4px; font-weight: bold;">ADMIN CONSOLE</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap:15px;">
|
||||||
|
<?php foreach ( $admin_stat_data as $stat ) : ?>
|
||||||
|
<div style="background:rgba(255,255,255,0.05); padding:12px; border-radius:6px; border-top: 2px solid <?php echo $stat['color']; ?>;">
|
||||||
|
<small style="color:#aaa; text-transform:uppercase; font-size:9px; font-weight:bold;"><?php echo $stat['label']; ?></small><br>
|
||||||
|
<span style="font-size:18px; font-weight:bold; color:#fff;"><?php echo $stat['val']; ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 15px; display: flex; gap: 10px;">
|
||||||
|
<a href="<?php echo admin_url('admin.php?page=ktc-settings'); ?>" style="text-decoration:none; color:#bbb; font-size:11px; background: rgba(255,255,255,0.1); padding: 5px 10px; border-radius: 4px;">🛠️ System Settings</a>
|
||||||
|
<a href="<?php echo admin_url('profile.php'); ?>" style="text-decoration:none; color:#bbb; font-size:11px; background: rgba(255,255,255,0.1); padding: 5px 10px; border-radius: 4px;">⚙️ Global Profile</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Action Bar (Logout, etc) -->
|
||||||
|
<div style="display:flex; gap:10px; margin-bottom: 30px;">
|
||||||
|
<a href="<?php echo wp_logout_url( home_url() ); ?>" style="text-decoration:none; color:#d32f2f; font-size:13px; border:1px solid #d32f2f; padding:8px 15px; border-radius:4px;">🚪 Logout</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Real time clock source -->
|
||||||
|
<div id="ktc-clock-teleport-source" style="display:none;">
|
||||||
|
<div class="ktc-digital-clock" id="ktc-digital-clock">00:00:00</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function stat_box($label, $val, $color) {
|
||||||
|
return "<div style='background:#f9f9f9; padding:15px; border-radius:8px; border-left: 4px solid $color;'>
|
||||||
|
<small style='color:#666; text-transform:uppercase; font-size:10px; font-weight:bold;'>$label</small><br>
|
||||||
|
<span style='font-size:20px; font-weight:bold; color:#222;'>$val</span></div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function render_timeclock() {
|
||||||
|
if ( ! is_user_logged_in() ) return '<p>Please log in.</p>';
|
||||||
|
global $wpdb;
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
$table_name = $wpdb->prefix . 'ktc_logs';
|
||||||
|
|
||||||
|
$status = $wpdb->get_var( $wpdb->prepare( "SELECT action_type FROM $table_name WHERE user_id = %d ORDER BY timestamp DESC LIMIT 1", $user_id ) ) ?: 'clock_out';
|
||||||
|
$btn_color = ($status === 'clock_in') ? '#d9534f' : '#5cb85c';
|
||||||
|
$next_action = ($status === 'clock_in') ? 'clock_out' : 'clock_in';
|
||||||
|
|
||||||
|
ob_start(); ?>
|
||||||
|
<div style="background: #f4f4f4; padding: 15px; border-radius: 5px; margin-bottom: 20px; display: flex; justify-content: space-between;">
|
||||||
|
<?php if ( $status === 'clock_in' ): $elapsed = KTC_Logic::get_elapsed_time($user_id); ?>
|
||||||
|
<div><strong>Current Shift:</strong> <span id="ktc-live-elapsed" data-seconds="<?php echo $elapsed; ?>"><?php echo KTC_Logic::format_seconds($elapsed); ?></span></div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div><strong>Status:</strong> <span style="color:#666;">Off the Clock</span></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div><strong>Weekly Total:</strong> <?php echo KTC_Logic::get_weekly_total($user_id); ?> hrs</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" id="ktc-punch-form">
|
||||||
|
<?php wp_nonce_field( 'ktc_punch_action', 'ktc_nonce' ); ?>
|
||||||
|
<input type="hidden" name="ktc_action" value="punch">
|
||||||
|
<button type="submit" id="ktc-punch-button" style="background-color:<?php echo $btn_color; ?>; color:white; padding: 15px 30px; border:none; border-radius:5px; cursor:pointer; font-size: 18px;">
|
||||||
|
<?php echo ucfirst(str_replace('_',' ',$next_action)); ?>
|
||||||
|
</button>
|
||||||
|
<div id="ktc-timer" style="margin:5px 0 10px 5px; font-size:12px; color:#999; display:none;">Processing... <span id="ktc-countdown">60</span>s</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$history = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM $table_name WHERE user_id = %d ORDER BY timestamp DESC LIMIT 10", $user_id ) );
|
||||||
|
if ( $history ): ?>
|
||||||
|
<h3>Recent Activity</h3>
|
||||||
|
<table style="width:100%; border-collapse: collapse; margin-top: 20px;">
|
||||||
|
<?php foreach ( $history as $log ):
|
||||||
|
$ts_local = KTC_Logic::get_local_timestamp($log->timestamp);
|
||||||
|
$orig_time = wp_date( 'D, M j, g:i a', $ts_local );
|
||||||
|
$is_today = (wp_date('Y-m-d', $ts_local) === (new DateTime('now', new DateTimeZone(KTC_TIMEZONE)))->format('Y-m-d')) ? 'true' : 'false';
|
||||||
|
?>
|
||||||
|
<tr style="border-bottom: 1px solid #eee;">
|
||||||
|
<td style="padding: 10px 0;"><?php echo $orig_time; ?></td>
|
||||||
|
<td><?php echo ucfirst(str_replace('_',' ',$log->action_type)); ?></td>
|
||||||
|
<td style="text-align:right;"><button type="button" onclick='ktc_open_edit_modal(<?php echo $log->id; ?>, "<?php echo $orig_time; ?>", <?php echo $is_today; ?>)' style="...">Edit</button></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ( current_user_can('manage_options') ) echo self::render_admin_queue(); ?>
|
||||||
|
<?php return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function render_admin_queue() {
|
||||||
|
global $wpdb;
|
||||||
|
$pending = $wpdb->get_results("SELECT * FROM {$wpdb->prefix}ktc_logs WHERE status = 'pending_correction' ORDER BY timestamp DESC");
|
||||||
|
if ( ! $pending ) return '';
|
||||||
|
ob_start(); ?>
|
||||||
|
<div style="margin-top:40px; border:2px solid #0073aa; padding:20px; border-radius:8px;">
|
||||||
|
<h3>Admin: Correction Queue</h3>
|
||||||
|
<?php foreach($pending as $req): $u = get_userdata($req->user_id); ?>
|
||||||
|
<div style="display:flex; justify-content:space-between; padding:10px 0; border-bottom:1px solid #eee;">
|
||||||
|
<span><strong><?php echo $u->display_name; ?></strong>: <?php echo wp_date('g:i a', KTC_Logic::get_local_timestamp($req->proposed_timestamp)); ?></span>
|
||||||
|
<div>
|
||||||
|
<a href="?ktc_admin_action=approve&log_id=<?php echo $req->id; ?>" style="background:#5cb85c; color:#fff; padding:5px; text-decoration:none;">Approve</a>
|
||||||
|
<a href="?ktc_admin_action=reject&log_id=<?php echo $req->id; ?>" style="background:#d9534f; color:#fff; padding:5px; text-decoration:none;">Reject</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* public static function inject_ui_scripts() {
|
||||||
|
if ( ! is_front_page() && ! is_page('clock') ) return; ?>
|
||||||
|
<div id="ktc_edit_modal" style="display:none; position:fixed; z-index:9999; left:0; top:0; width:100%; height:100%; background:rgba(0,0,0,0.6);">
|
||||||
|
<div style="background:#fff; margin:10% auto; padding:25px; border-radius:8px; width:320px;">
|
||||||
|
<h3>Request Correction</h3>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="ktc_edit_id" id="ktc_edit_id">
|
||||||
|
<input type="time" name="ktc_proposed_time" required style="width:100%;">
|
||||||
|
<textarea name="ktc_edit_notes" style="width:100%; margin-top:10px;"></textarea>
|
||||||
|
<button type="submit" style="background:#5cb85c; color:#fff; border:none; padding:10px 20px; margin-top:10px;">Submit</button>
|
||||||
|
<button type="button" onclick="document.getElementById('ktc_edit_modal').style.display='none'">Cancel</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php }
|
||||||
|
*/
|
||||||
|
public static function inject_ui_scripts() {
|
||||||
|
if ( ! is_front_page() && ! is_page('clock') ) return; ?>
|
||||||
|
|
||||||
|
<div id="ktc_edit_modal" style="display:none; position:fixed; z-index:9999; left:0; top:0; width:100%; height:100%; background:rgba(0,0,0,0.6);">
|
||||||
|
<div style="background:#fff; margin:10% auto; padding:25px; border-radius:8px; width:320px;">
|
||||||
|
<h3>Request Correction</h3>
|
||||||
|
|
||||||
|
<!-- THE MISSING LINK: Add this line -->
|
||||||
|
<p id="ktc_modal_orig_time" style="color: #666; font-size: 0.9em; margin-bottom: 15px;"></p>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<?php wp_nonce_field( 'ktc_punch_action', 'ktc_nonce' ); ?>
|
||||||
|
<input type="hidden" name="ktc_action" value="edit_request">
|
||||||
|
<input type="hidden" name="ktc_edit_id" id="ktc_edit_id">
|
||||||
|
|
||||||
|
<label>Proposed Time:</label>
|
||||||
|
<input type="time" name="ktc_proposed_time" id="ktc_proposed_time" required style="width:100%;">
|
||||||
|
|
||||||
|
<label style="display:block; margin-top:10px;">Reason/Notes:</label>
|
||||||
|
<textarea name="ktc_edit_notes" style="width:100%;"></textarea>
|
||||||
|
|
||||||
|
<button type="submit" style="background:#5cb85c; color:#fff; border:none; padding:10px 20px; margin-top:15px; cursor:pointer;">Submit</button>
|
||||||
|
<button type="button" onclick="document.getElementById('ktc_edit_modal').style.display='none'" style="background:#ccc; border:none; padding:10px 20px; margin-top:15px; cursor:pointer;">Cancel</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php }
|
||||||
|
|
||||||
|
public static function register_settings_page() {
|
||||||
|
// 1. Main Menu Page
|
||||||
|
add_menu_page(
|
||||||
|
'KTC Settings',
|
||||||
|
'KTC Clock',
|
||||||
|
'manage_options',
|
||||||
|
'ktc-settings',
|
||||||
|
array( 'KTC_Admin', 'render_settings_content' ),
|
||||||
|
'dashicons-clock',
|
||||||
|
85
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Register Settings
|
||||||
|
register_setting( 'ktc_settings_group', 'ktc_timezone' );
|
||||||
|
register_setting( 'ktc_settings_group', 'ktc_week_start' );
|
||||||
|
register_setting( 'ktc_settings_group', 'ktc_alert_threshold', ['type' => 'integer', 'default' => 12] );
|
||||||
|
register_setting( 'ktc_settings_group', 'ktc_enable_teams', ['type' => 'boolean', 'default' => 0] );
|
||||||
|
register_setting( 'ktc_settings_group', 'ktc_teams_webhook', ['type' => 'string'] );
|
||||||
|
|
||||||
|
// 3. Section
|
||||||
|
add_settings_section(
|
||||||
|
'ktc_main_section',
|
||||||
|
'Core Configuration',
|
||||||
|
'__return_false',
|
||||||
|
'ktc-settings'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Fields - Triple check that these method names exist in this class
|
||||||
|
add_settings_field(
|
||||||
|
'ktc_timezone_field',
|
||||||
|
'Timezone',
|
||||||
|
array( __CLASS__, 'render_timezone_field' ),
|
||||||
|
'ktc-settings',
|
||||||
|
'ktc_main_section'
|
||||||
|
);
|
||||||
|
|
||||||
|
add_settings_field(
|
||||||
|
'ktc_alert_threshold_field',
|
||||||
|
'Zombie Alert Threshold (Hours)',
|
||||||
|
array( __CLASS__, 'render_threshold_field' ),
|
||||||
|
'ktc-settings',
|
||||||
|
'ktc_main_section'
|
||||||
|
);
|
||||||
|
|
||||||
|
add_settings_field(
|
||||||
|
'ktc_teams_config_field',
|
||||||
|
'MS Teams Integration',
|
||||||
|
array( __CLASS__, 'render_teams_fields' ),
|
||||||
|
'ktc-settings',
|
||||||
|
'ktc_main_section'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function render_settings_content() {
|
||||||
|
?>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>Krista Time Clock Settings</h1>
|
||||||
|
<form method="post" action="options.php">
|
||||||
|
<?php
|
||||||
|
settings_fields( 'ktc_settings_group' );
|
||||||
|
do_settings_sections( 'ktc-settings' );
|
||||||
|
submit_button();
|
||||||
|
?>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the Threshold numeric input
|
||||||
|
*/
|
||||||
|
public static function render_threshold_field() {
|
||||||
|
$val = get_option( 'ktc_alert_threshold', 12 );
|
||||||
|
echo '<input type="number" name="ktc_alert_threshold" value="' . esc_attr( $val ) . '" class="small-text"> ';
|
||||||
|
echo '<p class="description">Hours before a shift is flagged as a "Zombie Shift".</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the Teams Toggle and Webhook URL
|
||||||
|
*/
|
||||||
|
public static function render_teams_fields() {
|
||||||
|
$enabled = get_option( 'ktc_enable_teams', 0 );
|
||||||
|
$webhook = get_option( 'ktc_teams_webhook', '' );
|
||||||
|
?>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="ktc_enable_teams" value="1" <?php checked( $enabled, 1 ); ?>>
|
||||||
|
Enable Teams Notifications
|
||||||
|
</label>
|
||||||
|
<br><br>
|
||||||
|
<input type="text" name="ktc_teams_webhook" value="<?php echo esc_attr( $webhook ); ?>" class="regular-text" placeholder="https://outlook.office.com/webhook/...">
|
||||||
|
<p class="description">Enter your Microsoft Teams Incoming Webhook URL.</p>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function render_timezone_field() {
|
||||||
|
$current_tz = get_option( 'ktc_timezone', 'America/Chicago' );
|
||||||
|
$tz_list = DateTimeZone::listIdentifiers();
|
||||||
|
?>
|
||||||
|
<select name="ktc_timezone">
|
||||||
|
<?php foreach ( $tz_list as $tz ) : ?>
|
||||||
|
<option value="<?php echo esc_attr( $tz ); ?>" <?php selected( $current_tz, $tz ); ?>>
|
||||||
|
<?php echo esc_html( $tz ); ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<p class="description">This defines the legal "Source of Truth" for all timestamps, bypassing server or WP site drift.</p>
|
||||||
|
<?php }
|
||||||
|
|
||||||
|
}
|
||||||
Executable
+377
@@ -0,0 +1,377 @@
|
|||||||
|
<?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'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Executable
+377
@@ -0,0 +1,377 @@
|
|||||||
|
<?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'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
/*
|
||||||
|
Plugin Name: Krista Time Clock
|
||||||
|
Description: Internal time tracking system. Localized to Chicago Time.
|
||||||
|
Version: 0.6.0
|
||||||
|
Author: Krista Computers
|
||||||
|
*/
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) exit;
|
||||||
|
|
||||||
|
define( 'KTC_PATH', plugin_dir_path( __FILE__ ) );
|
||||||
|
define( 'KTC_TIMEZONE', 'America/Chicago' );
|
||||||
|
|
||||||
|
// Load modular components
|
||||||
|
require_once KTC_PATH . 'includes/class-logic.php';
|
||||||
|
require_once KTC_PATH . 'includes/class-admin.php';
|
||||||
|
|
||||||
|
// Activation & Installation
|
||||||
|
register_activation_hook( __FILE__, array( 'KTC_Logic', 'install' ) );
|
||||||
|
|
||||||
|
// Initialize the Orchestration
|
||||||
|
add_action( 'plugins_loaded', function() {
|
||||||
|
KTC_Logic::init();
|
||||||
|
KTC_Admin::init();
|
||||||
|
});
|
||||||
|
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
/*
|
||||||
|
Plugin Name: Krista Time Clock
|
||||||
|
Description: Modularized time tracking system. Localized to Chicago Time.
|
||||||
|
Version: 0.6.0
|
||||||
|
Author: Krista Computers
|
||||||
|
*/
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) exit;
|
||||||
|
|
||||||
|
// 1. Constants
|
||||||
|
define( 'KTC_VERSION', '0.6.0' );
|
||||||
|
define( 'KTC_PATH', plugin_dir_path( __FILE__ ) );
|
||||||
|
define( 'KTC_TIMEZONE', 'America/Chicago' );
|
||||||
|
|
||||||
|
// 2. Resource Loading
|
||||||
|
require_once KTC_PATH . 'includes/class-logic.php';
|
||||||
|
require_once KTC_PATH . 'includes/class-admin.php';
|
||||||
|
|
||||||
|
// 3. Initialization
|
||||||
|
register_activation_hook( __FILE__, array( 'KTC_Logic', 'install' ) );
|
||||||
|
|
||||||
|
add_action( 'plugins_loaded', function() {
|
||||||
|
KTC_Logic::init();
|
||||||
|
KTC_Admin::init();
|
||||||
|
});
|
||||||
Executable
+32
@@ -0,0 +1,32 @@
|
|||||||
|
function ktc_sync_ldap_display_name($user_id, $username, $password) {
|
||||||
|
$ldap_server = "ldap://your.ad.server"; // Or IP
|
||||||
|
$ldap_dn = "CN=ServiceAccount,OU=Users,DC=krista,DC=local";
|
||||||
|
$ldap_pass = "YourPassword";
|
||||||
|
$base_dn = "OU=Users,DC=krista,DC=local";
|
||||||
|
|
||||||
|
$ds = ldap_connect($ldap_server);
|
||||||
|
ldap_set_option($ds, LDAP_OPT_PROTOCOL_VERSION, 3);
|
||||||
|
ldap_set_option($ds, LDAP_OPT_REFERRALS, 0);
|
||||||
|
|
||||||
|
if (ldap_bind($ds, $ldap_dn, $ldap_pass)) {
|
||||||
|
// Search by email (which is the WP username)
|
||||||
|
$filter = "(mail=$username)";
|
||||||
|
$sr = ldap_search($ds, $base_dn, $filter, array("displayname", "mail"));
|
||||||
|
$info = ldap_get_entries($ds, $sr);
|
||||||
|
|
||||||
|
if ($info["count"] > 0) {
|
||||||
|
$friendly_name = $info[0]["displayname"][0];
|
||||||
|
|
||||||
|
// Update WordPress User
|
||||||
|
wp_update_user(array(
|
||||||
|
'ID' => $user_id,
|
||||||
|
'display_name' => $friendly_name,
|
||||||
|
'nickname' => $friendly_name
|
||||||
|
));
|
||||||
|
|
||||||
|
// Force WP to use Display Name publicly
|
||||||
|
update_user_meta($user_id, 'display_name', $friendly_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ldap_unbind($ds);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user