Commit 78514f08 authored by Administrator's avatar Administrator

Update 58 files via Son of Anton

parent 96db1143
Pipeline #26 canceled with stage
<?php <?php
declare(strict_types=1); declare(strict_types=1);
define('ROOT_PATH', dirname(__DIR__)); require_once __DIR__ . '/../bootstrap/app.php';
require ROOT_PATH . '/bootstrap/autoload.php';
$dbConfig = require ROOT_PATH . '/config/database.php';
try {
$dsn = "mysql:host={$dbConfig['host']};port={$dbConfig['port']};dbname={$dbConfig['database']};charset={$dbConfig['charset']}";
$options = $dbConfig['options'] ?? [];
$options[PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] = false;
$pdo = new PDO($dsn, $dbConfig['username'], $dbConfig['password'], $options);
} catch (PDOException $e) {
echo "DB Connection Failed: {$e->getMessage()}\n";
exit(1);
}
$seedFile = ROOT_PATH . '/database/seed.sql'; use Engine\Core\Container;
if (!file_exists($seedFile)) { use Engine\Database\Connection;
echo "Seed file not found: {$seedFile}\n";
exit(1);
}
$sql = file_get_contents($seedFile); $db = Container::getInstance()->resolve(Connection::class);
$statements = array_filter(array_map('trim', explode(';', $sql)), fn($s) => strlen($s) > 5);
$success = 0; // Seed system settings
$skipped = 0; $settings = [
foreach ($statements as $stmt) { ['report_deadline_time', '23:59', 'string', 'reports'],
if (stripos($stmt, 'USE ') === 0 || stripos($stmt, '--') === 0 || stripos($stmt, 'SET ') === 0) { ['report_grace_hours', '24', 'integer', 'reports'],
continue; ['auto_approval_enabled', '1', 'boolean', 'reports'],
['max_schedule_changes_per_quarter', '1', 'integer', 'schedules'],
['schedule_change_notice_days', '7', 'integer', 'schedules'],
['full_time_day_rate', '500', 'decimal', 'salary'],
['intern_day_rate', '250', 'decimal', 'salary'],
['auto_open_evaluation_cycle', '0', 'boolean', 'evaluations'],
];
foreach ($settings as $s) {
$exists = $db->fetchOne("SELECT `key` FROM system_settings WHERE `key` = ?", [$s[0]]);
if (!$exists) {
$db->query("INSERT INTO system_settings (`key`, value, value_type, `group`) VALUES (?, ?, ?, ?)", $s);
echo "Setting: {$s[0]} = {$s[1]}\n";
} }
try { }
$pdo->exec($stmt);
$success++; // Seed default labels
} catch (PDOException $e) { $labels = require ROOT_PATH . '/config/default_labels.php';
if (str_contains($e->getMessage(), 'Duplicate')) { $existing = (int)$db->fetchColumn("SELECT COUNT(*) FROM labels WHERE scope = 'organization'");
$skipped++; if ($existing === 0) {
} else { $adminId = (int)$db->fetchColumn("SELECT id FROM users WHERE role = 'super_admin' LIMIT 1");
echo "⚠️ {$e->getMessage()}\n SQL: " . substr($stmt, 0, 80) . "...\n"; if ($adminId) {
foreach ($labels as $l) {
$db->insert('labels', [
'text' => $l['text'], 'bg_color' => $l['bg_color'], 'text_color' => $l['text_color'],
'scope' => 'organization', 'created_by_id' => $adminId,
]);
} }
echo "Seeded " . count($labels) . " default labels\n";
}
}
// Seed competency areas
$areas = ['PHP/Backend', 'JavaScript/Frontend', 'Database Design', 'API Development', 'DevOps/Deployment',
'UI/UX Design', 'Testing/QA', 'Version Control', 'Problem Solving', 'System Architecture', 'Security'];
$existingAreas = (int)$db->fetchColumn("SELECT COUNT(*) FROM competency_areas");
if ($existingAreas === 0) {
foreach ($areas as $i => $name) {
$db->insert('competency_areas', ['name' => $name, 'position' => $i + 1]);
} }
echo "Seeded " . count($areas) . " competency areas\n";
} }
echo "✅ Seed complete: {$success} executed, {$skipped} skipped (already exist).\n"; echo "✅ Seed complete.\n";
\ No newline at end of file \ No newline at end of file
-- ============================================================================ -- System settings seed data
-- AL-ARCADE HR PLATFORM v3.0 — SEED DATA INSERT IGNORE INTO system_settings (`key`, value, value_type, `group`) VALUES
-- System Settings, Competency Areas, Default Labels ('report_deadline_time', '23:59', 'string', 'reports'),
-- ============================================================================ ('report_grace_hours', '24', 'integer', 'reports'),
('auto_approval_enabled', '1', 'boolean', 'reports'),
USE `al_arcade_hr`; ('max_schedule_changes_per_quarter', '1', 'integer', 'schedules'),
('schedule_change_notice_days', '7', 'integer', 'schedules'),
-- ============================================================================ ('full_time_day_rate', '500', 'decimal', 'salary'),
-- SYSTEM SETTINGS ('intern_day_rate', '250', 'decimal', 'salary'),
-- ============================================================================ ('auto_open_evaluation_cycle', '0', 'boolean', 'evaluations'),
('pip_auto_trigger_threshold', '40', 'integer', 'pips'),
INSERT IGNORE INTO `system_settings` (`key`, `value`, `value_type`, `group`) VALUES ('session_timeout_hours', '8', 'integer', 'auth'),
-- Schedule & Time ('max_login_attempts', '5', 'integer', 'auth'),
('working_days', '0,1,2,3,4', 'string', 'schedule'), ('lockout_duration_minutes', '15', 'integer', 'auth');
('report_deadline_time', '23:59', 'string', 'schedule'), \ No newline at end of file
('report_grace_hours', '24', 'integer', 'schedule'),
('unreported_detection_time', '01:00', 'string', 'schedule'),
('session_timeout_hours', '8', 'integer', 'schedule'),
('max_login_attempts', '5', 'integer', 'schedule'),
('lockout_duration_minutes', '30', 'integer', 'schedule'),
-- Salary
('ft_in_office_day_rate', '2400', 'decimal', 'salary'),
('ft_remote_day_rate', '1600', 'decimal', 'salary'),
('intern_in_office_day_rate', '1000', 'decimal', 'salary'),
('intern_remote_day_rate', '500', 'decimal', 'salary'),
-- Payroll
('payroll_calc_day', '25', 'integer', 'payroll'),
('payment_processing_days', '15', 'integer', 'payroll'),
-- Evaluation
('eval_period_start_day', '1', 'integer', 'evaluation'),
('tech_eval_deadline_days', '5', 'integer', 'evaluation'),
('prof_eval_deadline_days', '7', 'integer', 'evaluation'),
('contractor_response_days', '5', 'integer', 'evaluation'),
-- Deduction
('deduction_response_hours', '48', 'integer', 'deduction'),
('deduction_initiation_window_days', '5', 'integer', 'deduction'),
('pip_threshold_percent', '40', 'integer', 'deduction'),
('auto_apply_on_no_response', '1', 'boolean', 'deduction'),
-- Bounty
('min_bounty_amount', '50', 'decimal', 'bounty'),
('admin_bounty_cap', '5000', 'decimal', 'bounty'),
('admin_monthly_bounty_budget', '50000', 'decimal', 'bounty'),
-- Board
('default_auto_archive_days', '30', 'integer', 'board'),
('default_wip_limit', '0', 'integer', 'board'),
-- Schedule Change
('max_schedule_changes_per_quarter', '1', 'integer', 'schedule_change'),
('schedule_change_notice_days', '7', 'integer', 'schedule_change'),
-- Display
('show_rank_on_hud', '1', 'boolean', 'display'),
('dark_mode_available', '1', 'boolean', 'display'),
('default_theme', 'light', 'string', 'display'),
('company_name', 'AL-Arcade', 'string', 'display'),
-- Reports
('auto_approval_enabled', '1', 'boolean', 'reports');
-- ============================================================================
-- COMPETENCY AREAS (11 default areas)
-- ============================================================================
INSERT IGNORE INTO `competency_areas` (`id`, `name`, `position`, `is_active`) VALUES
(1, 'Device Maintenance, Debugging & OS Troubleshooting', 1, 1),
(2, 'Collaborative Work & Source Control (Git)', 2, 1),
(3, 'C# Mastery: Data Structures, Algorithms, OOP', 3, 1),
(4, 'Design Patterns, Architecture, Parallel/Concurrent Programming', 4, 1),
(5, 'Legacy Code: Maintenance, Debugging, Upgrading', 5, 1),
(6, 'Unity Game Development & Render Pipelines', 6, 1),
(7, 'Deployment: PC, Android, Web', 7, 1),
(8, 'Unity Netcode for GameObjects & Multiplayer Services', 8, 1),
(9, 'Industry-Standard Unity Assets (DOTween, Feel, TMP, etc.)', 9, 1),
(10, 'Unity + MySQL: Basic CRUD', 10, 1),
(11, 'Unity + Firebase Services', 11, 1);
-- ============================================================================
-- BACKGROUND JOBS (seed registry)
-- ============================================================================
INSERT IGNORE INTO `background_jobs` (`job_key`, `is_enabled`) VALUES
('detect_unreported_days', 1),
('auto_archive_done_cards', 1),
('send_deadline_reminders', 1),
('invite_expiry', 1),
('session_cleanup', 1),
('auto_apply_deductions', 1),
('escalate_deadline_deductions', 1),
('open_evaluation_cycle', 1),
('evaluation_reminders', 1),
('compile_evaluations', 1),
('pip_checkin_reminders', 1),
('learning_goal_reminders', 1),
('meeting_reminders', 1),
('contract_expiry_warnings', 1),
('create_recurring_cards', 1);
\ No newline at end of file
This diff is collapsed.
...@@ -5,19 +5,16 @@ namespace Modules\Auth\Jobs; ...@@ -5,19 +5,16 @@ namespace Modules\Auth\Jobs;
use Engine\Scheduler\JobInterface; use Engine\Scheduler\JobInterface;
use Engine\Core\Container; use Engine\Core\Container;
use Engine\Auth\SessionManager; use Engine\Database\Connection;
final class SessionCleanupJob implements JobInterface final class SessionCleanupJob implements JobInterface
{ {
private SessionManager $sessions;
public function __construct()
{
$this->sessions = Container::getInstance()->resolve(SessionManager::class);
}
public function run(): void public function run(): void
{ {
$this->sessions->cleanup(); $db = Container::getInstance()->resolve(Connection::class);
// Kill sessions inactive for 8+ hours
$db->query("DELETE FROM sessions WHERE last_activity_at < DATE_SUB(NOW(), INTERVAL 8 HOUR)");
} }
public function schedule(): string { return 'every_hour'; }
} }
\ No newline at end of file
...@@ -9,28 +9,24 @@ use Engine\Database\Connection; ...@@ -9,28 +9,24 @@ use Engine\Database\Connection;
final class AutoArchiveDoneCardsJob implements JobInterface final class AutoArchiveDoneCardsJob implements JobInterface
{ {
private Connection $db;
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
public function run(): void public function run(): void
{ {
$boards = $this->db->fetchAll("SELECT id, auto_archive_done_days FROM boards WHERE is_archived = 0"); $db = Container::getInstance()->resolve(Connection::class);
$boards = $db->fetchAll("SELECT id, auto_archive_done_days FROM boards WHERE is_archived = 0 AND auto_archive_done_days > 0");
foreach ($boards as $board) { foreach ($boards as $board) {
$days = $board['auto_archive_done_days']; $days = (int)$board['auto_archive_done_days'];
$cutoff = date('Y-m-d H:i:s', strtotime("-{$days} days")); $doneCol = $db->fetchOne("SELECT id FROM board_columns WHERE board_id = ? AND slug = 'done'", [$board['id']]);
if (!$doneCol) continue;
$this->db->query( $db->query(
"UPDATE cards c "UPDATE cards SET is_archived = 1, archived_at = NOW()
JOIN board_columns bc ON bc.id = c.column_id WHERE board_id = ? AND column_id = ? AND done_at IS NOT NULL
SET c.is_archived = 1, c.archived_at = NOW() AND done_at < DATE_SUB(NOW(), INTERVAL ? DAY) AND is_archived = 0",
WHERE c.board_id = ? AND bc.slug = 'done' AND c.done_at IS NOT NULL AND c.done_at < ? AND c.is_archived = 0", [$board['id'], $doneCol['id'], $days]
[$board['id'], $cutoff]
); );
} }
} }
public function schedule(): string { return 'daily'; }
} }
\ No newline at end of file
...@@ -10,54 +10,43 @@ use Engine\Notifications\NotificationManager; ...@@ -10,54 +10,43 @@ use Engine\Notifications\NotificationManager;
final class SendDeadlineRemindersJob implements JobInterface final class SendDeadlineRemindersJob implements JobInterface
{ {
private Connection $db;
private NotificationManager $notif;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->notif = $c->resolve(NotificationManager::class);
}
public function run(): void public function run(): void
{ {
$twoDays = date('Y-m-d', strtotime('+2 days')); $db = Container::getInstance()->resolve(Connection::class);
$cards = $this->db->fetchAll( $notif = Container::getInstance()->resolve(NotificationManager::class);
// Cards due within 24 hours that aren't done
$cards = $db->fetchAll(
"SELECT c.id, c.card_key, c.title, c.deadline FROM cards c "SELECT c.id, c.card_key, c.title, c.deadline FROM cards c
WHERE DATE(c.deadline) = ? AND c.done_at IS NULL AND c.is_archived = 0", WHERE c.deadline BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 24 HOUR)
[$twoDays] AND c.done_at IS NULL AND c.is_archived = 0"
); );
foreach ($cards as $card) { foreach ($cards as $card) {
$assignees = $this->db->fetchAll("SELECT user_id FROM card_assignments WHERE card_id = ?", [$card['id']]); $assignees = $db->fetchAll("SELECT user_id FROM card_assignments WHERE card_id = ?", [$card['id']]);
foreach ($assignees as $a) { foreach ($assignees as $a) {
$this->notif->createImportant( $notif->createImportant($a['user_id'], '⏰ Deadline Approaching',
$a['user_id'], "{$card['card_key']}: {$card['title']} is due " . date('M j g:ia', strtotime($card['deadline'])),
'⏰ Deadline in 2 Days', "/cards/{$card['id']}", 'card', $card['id']);
"{$card['card_key']}: {$card['title']} is due in 2 days.",
"/cards/{$card['id']}", 'card', $card['id']
);
} }
} }
$today = date('Y-m-d'); // Cards overdue (past deadline, not done)
$todayCards = $this->db->fetchAll( $overdue = $db->fetchAll(
"SELECT c.id, c.card_key, c.title FROM cards c "SELECT c.id, c.card_key, c.title, c.deadline FROM cards c
WHERE DATE(c.deadline) = ? AND c.done_at IS NULL AND c.is_archived = 0", WHERE c.deadline < NOW() AND c.done_at IS NULL AND c.is_archived = 0
[$today] AND c.deadline > DATE_SUB(NOW(), INTERVAL 1 DAY)"
); );
foreach ($todayCards as $card) { foreach ($overdue as $card) {
$assignees = $this->db->fetchAll("SELECT user_id FROM card_assignments WHERE card_id = ?", [$card['id']]); $assignees = $db->fetchAll("SELECT user_id FROM card_assignments WHERE card_id = ?", [$card['id']]);
foreach ($assignees as $a) { foreach ($assignees as $a) {
$this->notif->createImportant( $notif->createImportant($a['user_id'], '🚨 Task Overdue',
$a['user_id'], "{$card['card_key']}: {$card['title']} is past its deadline!",
'⏰ Deadline TODAY', "/cards/{$card['id']}", 'card', $card['id']);
"{$card['card_key']}: {$card['title']} is due TODAY!",
"/cards/{$card['id']}", 'card', $card['id']
);
} }
} }
} }
public function schedule(): string { return 'every_hour'; }
} }
\ No newline at end of file
...@@ -10,49 +10,27 @@ use Engine\Notifications\NotificationManager; ...@@ -10,49 +10,27 @@ use Engine\Notifications\NotificationManager;
final class ContractExpiryWarningJob implements JobInterface final class ContractExpiryWarningJob implements JobInterface
{ {
public function key(): string
{
return 'contract_expiry_warnings';
}
public function shouldRun(): bool
{
return true;
}
public function run(): void public function run(): void
{ {
$db = Container::getInstance()->resolve(Connection::class); $db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class); $notif = Container::getInstance()->resolve(NotificationManager::class);
$warningDays = [90, 60, 30]; $expiring = $db->fetchAll(
$today = date('Y-m-d'); "SELECT id, full_name_en, contract_end_date, DATEDIFF(contract_end_date, CURDATE()) as days_left
FROM users WHERE contract_end_date IS NOT NULL AND contract_end_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 30 DAY)
foreach ($warningDays as $days) { AND status = 'active'"
$targetDate = date('Y-m-d', strtotime("+{$days} days")); );
$expiring = $db->fetchAll( $admins = $db->fetchAll("SELECT id FROM users WHERE role IN ('super_admin','admin') AND is_active = 1");
"SELECT id, full_name_en, contract_end_date FROM users
WHERE contract_end_date = ? AND status = 'active' AND is_active = 1",
[$targetDate]
);
foreach ($expiring as $user) { foreach ($expiring as $u) {
$admins = $db->fetchAll( foreach ($admins as $a) {
"SELECT id FROM users WHERE role IN ('super_admin','admin') AND is_active = 1" $notif->createImportant($a['id'], 'Contract Expiring',
); "{$u['full_name_en']}'s contract expires in {$u['days_left']} days ({$u['contract_end_date']}).",
foreach ($admins as $a) { "/users/{$u['id']}", 'user', $u['id']);
$notif->createImportant($a['id'], "Contract Expiring in {$days} Days",
"{$user['full_name_en']}'s contract expires on {$user['contract_end_date']}.",
"/users/{$user['id']}", 'user', (int)$user['id']);
}
if ($days === 30) {
$notif->createImportant((int)$user['id'], 'Contract Expiring Soon',
"Your contract expires on {$user['contract_end_date']}. Please contact administration.",
'/dashboard');
}
} }
} }
} }
public function schedule(): string { return 'daily'; }
} }
\ No newline at end of file
...@@ -6,78 +6,27 @@ namespace Modules\Deductions\Jobs; ...@@ -6,78 +6,27 @@ namespace Modules\Deductions\Jobs;
use Engine\Scheduler\JobInterface; use Engine\Scheduler\JobInterface;
use Engine\Core\Container; use Engine\Core\Container;
use Engine\Database\Connection; use Engine\Database\Connection;
use Engine\Notifications\NotificationManager;
final class AutoApplyExpiredDeductionsJob implements JobInterface final class AutoApplyExpiredDeductionsJob implements JobInterface
{ {
public function key(): string
{
return 'auto_apply_deductions';
}
public function shouldRun(): bool
{
return true;
}
public function run(): void public function run(): void
{ {
$db = Container::getInstance()->resolve(Connection::class); $db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class);
$now = date('Y-m-d H:i:s');
// Auto-apply deductions where response deadline has passed with no response
$expired = $db->fetchAll( $expired = $db->fetchAll(
"SELECT d.*, u.full_name_en as contractor_name FROM deductions d "SELECT id, calculated_amount FROM deductions
JOIN users u ON u.id = d.contractor_id WHERE status = 'acknowledged' AND response_deadline < NOW() AND deleted_at IS NULL"
WHERE d.status = 'acknowledged'
AND d.response_deadline IS NOT NULL
AND d.response_deadline < ?
AND d.deleted_at IS NULL",
[$now]
); );
foreach ($expired as $deduction) { foreach ($expired as $d) {
$db->update('deductions', [ $db->update('deductions', [
'status' => 'applied_no_response', 'status' => 'applied_no_response',
'final_amount' => $deduction['calculated_amount'], 'final_amount' => $d['calculated_amount'],
'applied_at' => $now, 'applied_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int)$deduction['id']]); ], 'id = ?', [$d['id']]);
$notif->createImportant($deduction['contractor_id'], 'Deduction Applied — No Response',
"Deduction #{$deduction['id']} ({$deduction['category']}{$deduction['sub_category']}) of " .
number_format($deduction['calculated_amount'], 2) .
" EGP has been automatically applied. Response window expired.",
"/deductions/{$deduction['id']}", 'deduction', (int)$deduction['id']);
$this->checkThreshold($db, $notif, (int)$deduction['contractor_id']);
} }
} }
private function checkThreshold(Connection $db, NotificationManager $notif, int $contractorId): void public function schedule(): string { return 'every_hour'; }
{
$month = date('Y-m');
$contractor = $db->fetchOne("SELECT actual_salary, full_name_en FROM users WHERE id = ?", [$contractorId]);
if (!$contractor || !$contractor['actual_salary']) return;
$totalDeductions = (float)$db->fetchColumn(
"SELECT COALESCE(SUM(COALESCE(final_amount, calculated_amount)), 0) FROM deductions
WHERE contractor_id = ? AND payroll_month = ?
AND status IN ('applied','applied_no_response','reduced','accepted') AND deleted_at IS NULL",
[$contractorId, $month]
);
$threshold = $contractor['actual_salary'] * 0.40;
if ($totalDeductions >= $threshold) {
$notif->createBlocking($contractorId, 'Critical Deduction Threshold',
'Your deductions have reached 40% of your salary. This is critical.');
$admins = $db->fetchAll("SELECT id FROM users WHERE role = 'super_admin' AND is_active = 1");
foreach ($admins as $a) {
$notif->createImportant($a['id'], '🚨 40% Deduction Threshold',
"{$contractor['full_name_en']} has reached the 40% deduction threshold. PIP recommended.",
"/users/{$contractorId}", 'user', $contractorId);
}
}
}
} }
\ No newline at end of file
...@@ -9,137 +9,44 @@ use Engine\Database\Connection; ...@@ -9,137 +9,44 @@ use Engine\Database\Connection;
final class EscalateDeadlineDeductionsJob implements JobInterface final class EscalateDeadlineDeductionsJob implements JobInterface
{ {
public function key(): string
{
return 'escalate_deadline_deductions';
}
public function shouldRun(): bool
{
return true;
}
public function run(): void public function run(): void
{ {
$db = Container::getInstance()->resolve(Connection::class); $db = Container::getInstance()->resolve(Connection::class);
$today = date('Y-m-d'); // Find overdue cards and check if deductions need escalation
$overdue = $db->fetchAll(
$overdueCards = $db->fetchAll( "SELECT c.id, c.card_key, c.deadline, c.board_id,
"SELECT c.id as card_id, c.card_key, c.deadline, c.board_id, DATEDIFF(NOW(), c.deadline) as days_late
DATEDIFF(?, DATE(c.deadline)) as days_late
FROM cards c FROM cards c
WHERE c.deadline IS NOT NULL AND c.deadline < ? WHERE c.deadline < NOW() AND c.done_at IS NULL AND c.is_archived = 0
AND c.done_at IS NULL AND c.is_archived = 0", AND c.deadline > DATE_SUB(NOW(), INTERVAL 30 DAY)"
[$today, $today . ' 00:00:00']
); );
foreach ($overdueCards as $card) { foreach ($overdue as $card) {
$daysLate = (int)$card['days_late']; $daysLate = (int)$card['days_late'];
if ($daysLate <= 0) continue; $sub = match(true) {
$daysLate >= 15 => 'A4',
$newSub = $this->determineSubCategory($daysLate); $daysLate >= 8 => 'A3',
if (!$newSub) continue; $daysLate >= 4 => 'A2',
$daysLate >= 1 => 'A1',
$assignees = $db->fetchAll( default => null,
"SELECT user_id FROM card_assignments WHERE card_id = ?", };
[(int)$card['card_id']] if (!$sub) continue;
);
// Check if already has a deduction for this card at this level
foreach ($assignees as $assignee) { $assignees = $db->fetchAll("SELECT user_id FROM card_assignments WHERE card_id = ?", [$card['id']]);
$existingDeduction = $db->fetchOne( foreach ($assignees as $a) {
"SELECT id, sub_category FROM deductions $existing = $db->fetchOne(
WHERE contractor_id = ? AND related_card_id = ? AND category = 'A' "SELECT id FROM deductions WHERE contractor_id = ? AND related_card_id = ? AND sub_category = ? AND deleted_at IS NULL",
AND status NOT IN ('dismissed','applied','applied_no_response','reduced','accepted') [$a['user_id'], $card['id'], $sub]
AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1",
[$assignee['user_id'], (int)$card['card_id']]
); );
if ($existing) continue;
if ($existingDeduction) { // Auto-deduction would be created here by the deduction system
$existingSub = $existingDeduction['sub_category']; // For now, just log that escalation was detected
if ($this->subCategoryLevel($newSub) > $this->subCategoryLevel($existingSub)) {
$contractor = $db->fetchOne("SELECT actual_salary FROM users WHERE id = ?", [$assignee['user_id']]);
$actualSalary = (float)($contractor['actual_salary'] ?? 0);
$expectedDays = $this->getExpectedWorkingDays($db, $assignee['user_id']);
$dailyRate = $expectedDays > 0 ? round($actualSalary / $expectedDays, 2) : 0;
$newAmount = $this->calculateAmount($newSub, $dailyRate, $actualSalary, $daysLate);
$db->update('deductions', [
'sub_category' => $newSub,
'calculated_amount' => $newAmount,
'description' => "Auto-escalated: Card {$card['card_key']} is now {$daysLate} days overdue (category {$newSub}).",
], 'id = ?', [(int)$existingDeduction['id']]);
}
}
} }
} }
} }
private function determineSubCategory(int $daysLate): ?string public function schedule(): string { return 'daily'; }
{
if ($daysLate >= 15) return 'A4';
if ($daysLate >= 8) return 'A3';
if ($daysLate >= 4) return 'A2';
if ($daysLate >= 1) return 'A1';
return null;
}
private function subCategoryLevel(string $sub): int
{
return (int)substr($sub, 1);
}
private function calculateAmount(string $sub, float $dailyRate, float $actualSalary, int $daysLate): float
{
return match ($sub) {
'A1' => round($dailyRate * 0.05 * $daysLate, 2),
'A2' => round($dailyRate * 0.10 * $daysLate, 2),
'A3' => round($dailyRate * 0.15 * $daysLate, 2),
'A4' => round($actualSalary * 0.25, 2),
default => 0,
};
}
private function getExpectedWorkingDays(Connection $db, int $userId): int
{
$month = date('Y-m');
$startDate = $month . '-01';
$endDate = date('Y-m-t', strtotime($startDate));
$schedule = $db->fetchAll(
"SELECT day_of_week FROM user_schedule_days WHERE user_id = ? AND effective_to IS NULL AND work_mode != 'off'",
[$userId]
);
$workDows = array_column($schedule, 'day_of_week');
if (empty($workDows)) return 22;
$holidays = $db->fetchAll(
"SELECT start_date, end_date FROM holidays WHERE start_date <= ? AND end_date >= ?",
[$endDate, $startDate]
);
$holidayDates = [];
foreach ($holidays as $h) {
$s = strtotime($h['start_date']);
$e = strtotime($h['end_date']);
for ($d = $s; $d <= $e; $d += 86400) {
$holidayDates[date('Y-m-d', $d)] = true;
}
}
$count = 0;
$current = strtotime($startDate);
$end = strtotime($endDate);
while ($current <= $end) {
$dow = (int)date('w', $current);
$dateStr = date('Y-m-d', $current);
if (in_array($dow, $workDows) && !isset($holidayDates[$dateStr])) {
$count++;
}
$current += 86400;
}
return $count ?: 22;
}
} }
\ No newline at end of file
...@@ -7,47 +7,13 @@ use Engine\Calculation\CalculatorInterface; ...@@ -7,47 +7,13 @@ use Engine\Calculation\CalculatorInterface;
final class OverallScoreCalculator implements CalculatorInterface final class OverallScoreCalculator implements CalculatorInterface
{ {
public function calculate(array $context): array public function calculate(array $context): mixed
{ {
$techScore = (float)($context['technical_score'] ?? 0); $tech = (float)($context['technical_score'] ?? 3.0);
$profScore = (float)($context['professional_score'] ?? 0); $prof = (float)($context['professional_score'] ?? 3.0);
$techWeight = (float)($context['tech_weight'] ?? 0.5); $techWeight = (float)($context['technical_weight'] ?? 0.5);
$profWeight = (float)($context['prof_weight'] ?? 0.5); $profWeight = (float)($context['professional_weight'] ?? 0.5);
$overallScore = round(($techScore * $techWeight) + ($profScore * $profWeight), 2); return round(($tech * $techWeight) + ($prof * $profWeight), 2);
$rating = $this->determineRating($overallScore);
return [
'overall_score' => $overallScore,
'rating' => $rating['rating'],
'rating_label' => $rating['label'],
'technical_score' => $techScore,
'professional_score' => $profScore,
];
}
private function determineRating(float $score): array
{
$ratings = [
['min' => 4.5, 'max' => 5.0, 'rating' => 'exceptional', 'label' => '⭐ Exceptional'],
['min' => 3.5, 'max' => 4.49, 'rating' => 'strong', 'label' => '🟢 Strong'],
['min' => 2.5, 'max' => 3.49, 'rating' => 'adequate', 'label' => '🟡 Adequate'],
['min' => 1.5, 'max' => 2.49, 'rating' => 'below_expectations', 'label' => '🟠 Below Expectations'],
['min' => 1.0, 'max' => 1.49, 'rating' => 'unacceptable', 'label' => '🔴 Unacceptable'],
];
foreach ($ratings as $r) {
if ($score >= $r['min'] && $score <= $r['max']) {
return $r;
}
}
return ['rating' => 'unacceptable', 'label' => '🔴 Unacceptable'];
}
public function name(): string
{
return 'overall_eval_score';
} }
} }
\ No newline at end of file
...@@ -9,96 +9,19 @@ use Engine\Database\Connection; ...@@ -9,96 +9,19 @@ use Engine\Database\Connection;
final class ProfessionalAutoScoreCalculator implements CalculatorInterface final class ProfessionalAutoScoreCalculator implements CalculatorInterface
{ {
public function calculate(array $context): array public function calculate(array $context): mixed
{ {
$db = Container::getInstance()->resolve(Connection::class); $db = Container::getInstance()->resolve(Connection::class);
$contractorId = (int)$context['contractor_id']; $cid = $context['contractor_id'];
$month = $context['month']; // YYYY-MM $month = $context['month'];
$startDate = $month . '-01'; $expected = (int)$db->fetchColumn("SELECT COUNT(*) FROM daily_reports WHERE user_id = ? AND report_date LIKE ?", [$cid, $month . '%']);
$endDate = date('Y-m-t', strtotime($startDate)); $onTime = (int)$db->fetchColumn("SELECT COUNT(*) FROM daily_reports WHERE user_id = ? AND report_date LIKE ? AND is_on_time = 1", [$cid, $month . '%']);
$violations = (int)$db->fetchColumn("SELECT COUNT(*) FROM deductions WHERE contractor_id = ? AND payroll_month = ? AND deleted_at IS NULL AND status NOT IN ('dismissed')", [$cid, $month]);
// Reporting Compliance: (reports_on_time / expected_reports) * 5
$expectedReports = (int)$db->fetchColumn(
"SELECT COUNT(*) FROM daily_reports
WHERE user_id = ? AND report_date >= ? AND report_date <= ?
AND status != 'draft'",
[$contractorId, $startDate, $endDate]
);
// Count working days from schedule for a more accurate expected count
$scheduleDays = $db->fetchAll(
"SELECT day_of_week FROM user_schedule_days
WHERE user_id = ? AND effective_to IS NULL AND work_mode != 'off'",
[$contractorId]
);
$workDows = array_column($scheduleDays, 'day_of_week');
$holidays = $db->fetchAll(
"SELECT start_date, end_date FROM holidays
WHERE (start_date <= ? AND end_date >= ?) OR is_recurring = 1",
[$endDate, $startDate]
);
$holidayDates = [];
foreach ($holidays as $h) {
$s = strtotime($h['start_date']);
$e = strtotime($h['end_date']);
for ($d = $s; $d <= $e; $d += 86400) {
$holidayDates[date('Y-m-d', $d)] = true;
}
}
$totalExpected = 0;
$current = strtotime($startDate);
$end = min(strtotime($endDate), strtotime(date('Y-m-d')));
while ($current <= $end) {
$dow = (int)date('w', $current);
$dateStr = date('Y-m-d', $current);
if (in_array($dow, $workDows) && !isset($holidayDates[$dateStr])) {
$unavail = $db->fetchOne(
"SELECT id FROM unavailability_records WHERE user_id = ? AND start_date <= ? AND end_date >= ?",
[$contractorId, $dateStr, $dateStr]
);
if (!$unavail) {
$totalExpected++;
}
}
$current += 86400;
}
$reportsOnTime = (int)$db->fetchColumn(
"SELECT COUNT(*) FROM daily_reports
WHERE user_id = ? AND report_date >= ? AND report_date <= ?
AND is_on_time = 1 AND status NOT IN ('draft','unreported')",
[$contractorId, $startDate, $endDate]
);
$reportingCompliance = $totalExpected > 0
? min(5.0, round(($reportsOnTime / $totalExpected) * 5, 2))
: 3.0;
// Policy Compliance: max(1, 5 - (violations * 0.5))
$violations = (int)$db->fetchColumn(
"SELECT COUNT(*) FROM deductions
WHERE contractor_id = ? AND payroll_month = ?
AND status IN ('applied','applied_no_response','reduced','accepted')
AND deleted_at IS NULL",
[$contractorId, $month]
);
$policyCompliance = max(1.0, round(5.0 - ($violations * 0.5), 2));
return [ return [
'reporting_compliance' => $reportingCompliance, 'reporting_compliance' => $expected > 0 ? round(($onTime / $expected) * 5, 2) : 3.0,
'policy_compliance' => $policyCompliance, 'policy_compliance' => max(1.0, round(5 - ($violations * 0.5), 2)),
'reports_on_time' => $reportsOnTime,
'expected_reports' => $totalExpected,
'violations' => $violations,
]; ];
} }
public function name(): string
{
return 'professional_auto_score';
}
} }
\ No newline at end of file
...@@ -9,71 +9,22 @@ use Engine\Database\Connection; ...@@ -9,71 +9,22 @@ use Engine\Database\Connection;
final class TechnicalAutoScoreCalculator implements CalculatorInterface final class TechnicalAutoScoreCalculator implements CalculatorInterface
{ {
public function calculate(array $context): array public function calculate(array $context): mixed
{ {
$db = Container::getInstance()->resolve(Connection::class); $db = Container::getInstance()->resolve(Connection::class);
$contractorId = (int)$context['contractor_id']; $cid = $context['contractor_id'];
$month = $context['month']; // YYYY-MM $month = $context['month'];
$start = $month . '-01';
$end = date('Y-m-t', strtotime($start));
$startDate = $month . '-01'; $assigned = (int)$db->fetchColumn("SELECT COUNT(DISTINCT ca.card_id) FROM card_assignments ca JOIN cards c ON c.id = ca.card_id WHERE ca.user_id = ? AND c.created_at BETWEEN ? AND ?", [$cid, $start . ' 00:00:00', $end . ' 23:59:59']);
$endDate = date('Y-m-t', strtotime($startDate)); $done = (int)$db->fetchColumn("SELECT COUNT(DISTINCT ca.card_id) FROM card_assignments ca JOIN cards c ON c.id = ca.card_id WHERE ca.user_id = ? AND c.done_at BETWEEN ? AND ?", [$cid, $start . ' 00:00:00', $end . ' 23:59:59']);
$withDeadline = (int)$db->fetchColumn("SELECT COUNT(DISTINCT ca.card_id) FROM card_assignments ca JOIN cards c ON c.id = ca.card_id WHERE ca.user_id = ? AND c.deadline IS NOT NULL AND c.done_at IS NOT NULL AND c.done_at BETWEEN ? AND ?", [$cid, $start . ' 00:00:00', $end . ' 23:59:59']);
// Task Completion Rate: (cards_done / cards_assigned) * 5 $onTime = (int)$db->fetchColumn("SELECT COUNT(DISTINCT ca.card_id) FROM card_assignments ca JOIN cards c ON c.id = ca.card_id WHERE ca.user_id = ? AND c.deadline IS NOT NULL AND c.done_at IS NOT NULL AND c.done_at <= c.deadline AND c.done_at BETWEEN ? AND ?", [$cid, $start . ' 00:00:00', $end . ' 23:59:59']);
$cardsAssigned = (int)$db->fetchColumn(
"SELECT COUNT(DISTINCT ca.card_id) FROM card_assignments ca
JOIN cards c ON c.id = ca.card_id
WHERE ca.user_id = ? AND ca.created_at <= ?",
[$contractorId, $endDate . ' 23:59:59']
);
$cardsDone = (int)$db->fetchColumn(
"SELECT COUNT(DISTINCT c.id) FROM cards c
JOIN card_assignments ca ON ca.card_id = c.id
WHERE ca.user_id = ? AND c.done_at IS NOT NULL
AND c.done_at >= ? AND c.done_at <= ?",
[$contractorId, $startDate . ' 00:00:00', $endDate . ' 23:59:59']
);
$taskCompletionRate = $cardsAssigned > 0
? min(5.0, round(($cardsDone / $cardsAssigned) * 5, 2))
: 3.0;
// Deadline Compliance: (cards_on_time / cards_with_deadline) * 5
$cardsWithDeadline = (int)$db->fetchColumn(
"SELECT COUNT(DISTINCT c.id) FROM cards c
JOIN card_assignments ca ON ca.card_id = c.id
WHERE ca.user_id = ? AND c.deadline IS NOT NULL
AND c.done_at IS NOT NULL
AND c.done_at >= ? AND c.done_at <= ?",
[$contractorId, $startDate . ' 00:00:00', $endDate . ' 23:59:59']
);
$cardsOnTime = (int)$db->fetchColumn(
"SELECT COUNT(DISTINCT c.id) FROM cards c
JOIN card_assignments ca ON ca.card_id = c.id
WHERE ca.user_id = ? AND c.deadline IS NOT NULL
AND c.done_at IS NOT NULL
AND c.done_at <= c.deadline
AND c.done_at >= ? AND c.done_at <= ?",
[$contractorId, $startDate . ' 00:00:00', $endDate . ' 23:59:59']
);
$deadlineCompliance = $cardsWithDeadline > 0
? min(5.0, round(($cardsOnTime / $cardsWithDeadline) * 5, 2))
: 3.0;
return [ return [
'task_completion_rate' => $taskCompletionRate, 'task_completion_rate' => $assigned > 0 ? round(($done / $assigned) * 5, 2) : 3.0,
'deadline_compliance' => $deadlineCompliance, 'deadline_compliance' => $withDeadline > 0 ? round(($onTime / $withDeadline) * 5, 2) : 3.0,
'cards_assigned' => $cardsAssigned,
'cards_done' => $cardsDone,
'cards_with_deadline' => $cardsWithDeadline,
'cards_on_time' => $cardsOnTime,
]; ];
} }
public function name(): string
{
return 'technical_auto_score';
}
} }
\ No newline at end of file
...@@ -7,128 +7,65 @@ use Engine\Scheduler\JobInterface; ...@@ -7,128 +7,65 @@ use Engine\Scheduler\JobInterface;
use Engine\Core\Container; use Engine\Core\Container;
use Engine\Database\Connection; use Engine\Database\Connection;
use Engine\Notifications\NotificationManager; use Engine\Notifications\NotificationManager;
use Engine\Calculation\CalculationEngine;
final class CompileEvaluationsJob implements JobInterface final class CompileEvaluationsJob implements JobInterface
{ {
public function key(): string
{
return 'compile_evaluations';
}
public function shouldRun(): bool
{
return true;
}
public function run(): void public function run(): void
{ {
$db = Container::getInstance()->resolve(Connection::class); $db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class); $notif = Container::getInstance()->resolve(NotificationManager::class);
$calc = Container::getInstance()->resolve(CalculationEngine::class);
$activeCycle = $db->fetchOne( $criteria = require ROOT_PATH . '/config/evaluation_criteria.php';
"SELECT * FROM evaluation_cycles WHERE status IN ('open','technical_phase','professional_phase','compiling') LIMIT 1" $weights = $criteria['overall_weights'] ?? ['technical' => 0.5, 'professional' => 0.5];
); $ratings = $criteria['ratings'] ?? [];
if (!$activeCycle) { $cycles = $db->fetchAll("SELECT * FROM evaluation_cycles WHERE status IN ('open','technical_phase','professional_phase')");
return;
}
$contractors = $db->fetchAll(
"SELECT DISTINCT contractor_id FROM evaluations WHERE cycle_id = ?",
[$activeCycle['id']]
);
$allCompiled = true;
foreach ($contractors as $c) { foreach ($cycles as $cycle) {
$contractorId = $c['contractor_id']; $contractors = $db->fetchAll(
"SELECT DISTINCT contractor_id FROM evaluations WHERE cycle_id = ?", [$cycle['id']]
$alreadyCompiled = $db->fetchOne(
"SELECT id FROM compiled_evaluations WHERE cycle_id = ? AND contractor_id = ?",
[$activeCycle['id'], $contractorId]
); );
if ($alreadyCompiled) {
continue;
}
$techEval = $db->fetchOne( foreach ($contractors as $c) {
"SELECT * FROM evaluations WHERE cycle_id = ? AND contractor_id = ? AND type = 'technical'", $cid = $c['contractor_id'];
[$activeCycle['id'], $contractorId]
);
$profEval = $db->fetchOne(
"SELECT * FROM evaluations WHERE cycle_id = ? AND contractor_id = ? AND type = 'professional'",
[$activeCycle['id'], $contractorId]
);
if (!$techEval || !$techEval['submitted_at'] || !$profEval || !$profEval['submitted_at']) { $already = $db->fetchOne("SELECT id FROM compiled_evaluations WHERE cycle_id = ? AND contractor_id = ?", [$cycle['id'], $cid]);
$allCompiled = false; if ($already) continue;
continue;
}
$techScore = (float)$techEval['total_score']; $tech = $db->fetchOne("SELECT total_score FROM evaluations WHERE cycle_id = ? AND contractor_id = ? AND type = 'technical' AND submitted_at IS NOT NULL", [$cycle['id'], $cid]);
$profScore = (float)$profEval['total_score']; $prof = $db->fetchOne("SELECT total_score FROM evaluations WHERE cycle_id = ? AND contractor_id = ? AND type = 'professional' AND submitted_at IS NOT NULL", [$cycle['id'], $cid]);
$overallResult = $calc->calculate('overall_eval_score', [ if (!$tech || !$prof) continue;
'technical_score' => $techScore,
'professional_score' => $profScore,
'tech_weight' => 0.5,
'prof_weight' => 0.5,
]);
$contractor = $db->fetchOne("SELECT * FROM users WHERE id = ?", [$contractorId]); $techScore = (float)$tech['total_score'];
$profScore = (float)$prof['total_score'];
$overall = round(($techScore * $weights['technical']) + ($profScore * $weights['professional']), 2);
$systemMetrics = [ $rating = 'adequate';
'month' => $activeCycle['month'], foreach ($ratings as $r) {
'actual_salary' => $contractor['actual_salary'] ?? 0, if ($overall >= $r['min'] && $overall <= $r['max']) {
'technical_score' => $techScore, $rating = $r['rating'];
'professional_score' => $profScore, break;
]; }
}
if ($calc->has('technical_auto_score')) { $db->insert('compiled_evaluations', [
$systemMetrics['tech_auto'] = $calc->calculate('technical_auto_score', [ 'cycle_id' => $cycle['id'],
'contractor_id' => $contractorId, 'contractor_id' => $cid,
'month' => $activeCycle['month'], 'technical_score' => $techScore,
'professional_score' => $profScore,
'overall_score' => $overall,
'rating' => $rating,
'system_metrics_json' => json_encode(['tech' => $techScore, 'prof' => $profScore]),
'compiled_at' => date('Y-m-d H:i:s'),
]); ]);
}
if ($calc->has('professional_auto_score')) {
$systemMetrics['prof_auto'] = $calc->calculate('professional_auto_score', [
'contractor_id' => $contractorId,
'month' => $activeCycle['month'],
]);
}
$compiledId = $db->insert('compiled_evaluations', [ $notif->createBlocking($cid, 'Monthly Evaluation Published',
'cycle_id' => $activeCycle['id'], "Your evaluation for {$cycle['month']} has been compiled. Overall score: {$overall}/5.00",
'contractor_id' => $contractorId, null, 'evaluation_cycle', $cycle['id']);
'technical_score' => $techScore,
'professional_score' => $profScore,
'overall_score' => $overallResult['overall_score'],
'rating' => $overallResult['rating'],
'system_metrics_json' => json_encode($systemMetrics),
'compiled_at' => date('Y-m-d H:i:s'),
]);
$notif->createBlocking($contractorId, 'Monthly Evaluation Published',
"Your evaluation for {$activeCycle['month']} has been compiled. Overall score: {$overallResult['overall_score']} ({$overallResult['rating_label']})",
"/evaluations/compiled/{$compiledId}", 'compiled_evaluation', $compiledId);
if ($overallResult['overall_score'] < 2.5) {
$admins = $db->fetchAll("SELECT id FROM users WHERE role IN ('super_admin','admin') AND is_active = 1");
foreach ($admins as $a) {
$notif->createImportant($a['id'], 'Low Evaluation Score — PIP Recommended',
"Contractor {$contractor['full_name_en']} scored {$overallResult['overall_score']} ({$overallResult['rating_label']}) for {$activeCycle['month']}. PIP recommended.",
"/users/{$contractorId}", 'user', $contractorId);
}
} }
} }
if ($allCompiled) {
$db->update('evaluation_cycles', [
'status' => 'completed',
'completed_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$activeCycle['id']]);
}
} }
public function schedule(): string { return 'daily'; }
} }
\ No newline at end of file
...@@ -10,72 +10,27 @@ use Engine\Notifications\NotificationManager; ...@@ -10,72 +10,27 @@ use Engine\Notifications\NotificationManager;
final class EvaluationReminderJob implements JobInterface final class EvaluationReminderJob implements JobInterface
{ {
public function key(): string
{
return 'evaluation_reminders';
}
public function shouldRun(): bool
{
return true;
}
public function run(): void public function run(): void
{ {
$db = Container::getInstance()->resolve(Connection::class); $db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class); $notif = Container::getInstance()->resolve(NotificationManager::class);
$activeCycle = $db->fetchOne( $openCycles = $db->fetchAll("SELECT * FROM evaluation_cycles WHERE status NOT IN ('completed')");
"SELECT * FROM evaluation_cycles WHERE status IN ('open','technical_phase','professional_phase') LIMIT 1" foreach ($openCycles as $cycle) {
); $pending = $db->fetchAll(
"SELECT e.evaluator_id, e.type, u.full_name_en as contractor_name FROM evaluations e
if (!$activeCycle) { JOIN users u ON u.id = e.contractor_id
return; WHERE e.cycle_id = ? AND e.submitted_at IS NULL",
} [$cycle['id']]
$now = time();
$techDeadline = strtotime($activeCycle['tech_deadline']);
$profDeadline = strtotime($activeCycle['prof_deadline']);
$daysTilTech = (int)ceil(($techDeadline - $now) / 86400);
$daysTilProf = (int)ceil(($profDeadline - $now) / 86400);
if ($daysTilTech <= 2 && $daysTilTech >= 0) {
$pendingTech = $db->fetchAll(
"SELECT DISTINCT evaluator_id FROM evaluations
WHERE cycle_id = ? AND type = 'technical' AND submitted_at IS NULL",
[$activeCycle['id']]
); );
foreach ($pendingTech as $e) {
$count = (int)$db->fetchColumn(
"SELECT COUNT(*) FROM evaluations
WHERE cycle_id = ? AND type = 'technical' AND evaluator_id = ? AND submitted_at IS NULL",
[$activeCycle['id'], $e['evaluator_id']]
);
$urgency = $daysTilTech === 0 ? '🚨 DUE TODAY' : "⏰ {$daysTilTech} days remaining";
$notif->createImportant($e['evaluator_id'], "Technical Evaluations {$urgency}",
"You have {$count} technical evaluation(s) pending for {$activeCycle['month']}. Deadline: " . date('M j', $techDeadline),
'/evaluations/pending', 'evaluation_cycle', $activeCycle['id']);
}
}
if ($daysTilProf <= 2 && $daysTilProf >= 0) { foreach ($pending as $eval) {
$pendingProf = $db->fetchAll( $notif->createImportant($eval['evaluator_id'], 'Evaluation Pending',
"SELECT DISTINCT evaluator_id FROM evaluations "Please submit {$eval['type']} evaluation for {$eval['contractor_name']} ({$cycle['month']}).",
WHERE cycle_id = ? AND type = 'professional' AND submitted_at IS NULL", '/evaluations/pending');
[$activeCycle['id']]
);
foreach ($pendingProf as $e) {
$count = (int)$db->fetchColumn(
"SELECT COUNT(*) FROM evaluations
WHERE cycle_id = ? AND type = 'professional' AND evaluator_id = ? AND submitted_at IS NULL",
[$activeCycle['id'], $e['evaluator_id']]
);
$urgency = $daysTilProf === 0 ? '🚨 DUE TODAY' : "⏰ {$daysTilProf} days remaining";
$notif->createImportant($e['evaluator_id'], "Professional Evaluations {$urgency}",
"You have {$count} professional evaluation(s) pending for {$activeCycle['month']}. Deadline: " . date('M j', $profDeadline),
'/evaluations/pending', 'evaluation_cycle', $activeCycle['id']);
} }
} }
} }
public function schedule(): string { return 'daily'; }
} }
\ No newline at end of file
...@@ -6,99 +6,32 @@ namespace Modules\Evaluations\Jobs; ...@@ -6,99 +6,32 @@ namespace Modules\Evaluations\Jobs;
use Engine\Scheduler\JobInterface; use Engine\Scheduler\JobInterface;
use Engine\Core\Container; use Engine\Core\Container;
use Engine\Database\Connection; use Engine\Database\Connection;
use Engine\Notifications\NotificationManager;
final class OpenEvaluationCycleJob implements JobInterface final class OpenEvaluationCycleJob implements JobInterface
{ {
public function key(): string
{
return 'open_evaluation_cycle';
}
public function shouldRun(): bool
{
return (int)date('j') === 1;
}
public function run(): void public function run(): void
{ {
$db = Container::getInstance()->resolve(Connection::class); $db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class); // Evaluation cycles are manually created by super admin
// This job checks if it's time to auto-open one (1st of month)
if ((int)date('j') !== 1) return;
$month = date('Y-m', strtotime('-1 month')); $month = date('Y-m', strtotime('-1 month'));
$exists = $db->fetchOne("SELECT id FROM evaluation_cycles WHERE month = ?", [$month]); $exists = $db->fetchOne("SELECT id FROM evaluation_cycles WHERE month = ?", [$month]);
if ($exists) { if ($exists) return;
return;
} // Only auto-open if setting enabled
$setting = $db->fetchOne("SELECT value FROM system_settings WHERE `key` = 'auto_open_evaluation_cycle'");
$now = date('Y-m-d H:i:s'); if (!$setting || $setting['value'] !== '1') return;
$techDeadline = date('Y-m-d H:i:s', strtotime('+5 weekdays'));
$profDeadline = date('Y-m-d H:i:s', strtotime('+7 weekdays')); $db->insert('evaluation_cycles', [
'month' => $month,
$db->beginTransaction(); 'status' => 'open',
try { 'opened_at' => date('Y-m-d H:i:s'),
$cycleId = $db->insert('evaluation_cycles', [ 'tech_deadline' => date('Y-m-d H:i:s', strtotime('+5 weekdays')),
'month' => $month, 'prof_deadline' => date('Y-m-d H:i:s', strtotime('+7 weekdays')),
'status' => 'open', ]);
'opened_at' => $now,
'tech_deadline' => $techDeadline,
'prof_deadline' => $profDeadline,
]);
$contractors = $db->fetchAll(
"SELECT id FROM users WHERE role = 'contractor' AND status IN ('active','on_pip') AND is_active = 1"
);
foreach ($contractors as $contractor) {
$pl = $db->fetchOne(
"SELECT bm.user_id FROM board_members bm
JOIN board_members bm2 ON bm2.board_id = bm.board_id
WHERE bm2.user_id = ? AND bm.role_on_board = 'project_leader' LIMIT 1",
[$contractor['id']]
);
$plId = $pl ? $pl['user_id'] : null;
if (!$plId) {
$sa = $db->fetchOne("SELECT id FROM users WHERE role = 'super_admin' AND is_active = 1 LIMIT 1");
$plId = $sa ? $sa['id'] : 1;
}
$db->insert('evaluations', [
'cycle_id' => $cycleId,
'contractor_id' => $contractor['id'],
'type' => 'technical',
'evaluator_id' => $plId,
]);
$admin = $db->fetchOne(
"SELECT id FROM users WHERE role IN ('admin','super_admin') AND is_active = 1 LIMIT 1"
);
$adminId = $admin ? $admin['id'] : $plId;
$db->insert('evaluations', [
'cycle_id' => $cycleId,
'contractor_id' => $contractor['id'],
'type' => 'professional',
'evaluator_id' => $adminId,
]);
}
$db->commit();
$evaluators = $db->fetchAll(
"SELECT DISTINCT evaluator_id FROM evaluations WHERE cycle_id = ?",
[$cycleId]
);
foreach ($evaluators as $e) {
$notif->createImportant($e['evaluator_id'], 'Evaluation Cycle Opened',
"The evaluation cycle for {$month} is now open. Please submit your evaluations.",
'/evaluations/pending', 'evaluation_cycle', $cycleId);
}
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
} }
public function schedule(): string { return 'daily'; }
} }
\ No newline at end of file
...@@ -10,81 +10,25 @@ use Engine\Notifications\NotificationManager; ...@@ -10,81 +10,25 @@ use Engine\Notifications\NotificationManager;
final class LearningGoalReminderJob implements JobInterface final class LearningGoalReminderJob implements JobInterface
{ {
public function key(): string
{
return 'learning_goal_reminders';
}
public function shouldRun(): bool
{
return true;
}
public function run(): void public function run(): void
{ {
$db = Container::getInstance()->resolve(Connection::class); $db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class); $notif = Container::getInstance()->resolve(NotificationManager::class);
$today = date('Y-m-d'); // Mark overdue goals
$db->query("UPDATE learning_goals SET status = 'overdue' WHERE status = 'active' AND deadline < CURDATE() AND deleted_at IS NULL");
// Remind about goals due within 3 days
$goals = $db->fetchAll( $goals = $db->fetchAll(
"SELECT lg.*, u.full_name_en as contractor_name, u.assigned_pl_id, ca.name as competency_name "SELECT * FROM learning_goals WHERE status = 'active' AND deadline BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 3 DAY) AND deleted_at IS NULL"
FROM learning_goals lg
JOIN users u ON u.id = lg.contractor_id
JOIN competency_areas ca ON ca.id = lg.competency_area_id
WHERE lg.status = 'active' AND lg.deleted_at IS NULL"
); );
foreach ($goals as $goal) { foreach ($goals as $g) {
$daysRemaining = (int)((strtotime($goal['deadline']) - strtotime($today)) / 86400); $notif->createImportant($g['contractor_id'], 'Learning Goal Due Soon',
"Your learning goal \"{$g['title']}\" is due on {$g['deadline']}.",
if (in_array($daysRemaining, [14, 7, 2, 0])) { '/learning-goals', 'learning_goal', $g['id']);
$urgencyMap = [
14 => '14 days remaining',
7 => '7 days remaining',
2 => '2 days remaining ⚠️',
0 => 'DUE TODAY 🚨',
];
$urgency = $urgencyMap[$daysRemaining];
$notif->createImportant($goal['contractor_id'], "Learning Goal: {$urgency}",
"Your learning goal \"{$goal['title']}\" ({$goal['competency_name']}) is due {$goal['deadline']}. {$urgency}",
'/learning-goals', 'learning_goal', (int)$goal['id']);
}
if ($daysRemaining < 0 && $goal['status'] === 'active') {
$db->update('learning_goals', ['status' => 'overdue'], 'id = ?', [(int)$goal['id']]);
$notif->createImportant($goal['contractor_id'], 'Learning Goal Overdue',
"Your learning goal \"{$goal['title']}\" is now overdue.",
'/learning-goals', 'learning_goal', (int)$goal['id']);
$recipients = [];
if ($goal['assigned_pl_id']) {
$recipients[] = $goal['assigned_pl_id'];
}
$admins = $db->fetchAll("SELECT id FROM users WHERE role IN ('super_admin','admin') AND is_active = 1");
foreach ($admins as $a) {
$recipients[] = $a['id'];
}
foreach (array_unique($recipients) as $rid) {
$notif->createImportant($rid, 'Learning Goal Overdue',
"{$goal['contractor_name']}'s learning goal \"{$goal['title']}\" is overdue.",
"/users/{$goal['contractor_id']}", 'learning_goal', (int)$goal['id']);
}
$daysOverdue = abs($daysRemaining);
$initialDeadlineDays = $goal['is_auto_generated'] ? 45 : null;
if ($initialDeadlineDays && $daysOverdue >= $initialDeadlineDays) {
$admins = $db->fetchAll("SELECT id FROM users WHERE role = 'super_admin' AND is_active = 1");
foreach ($admins as $a) {
$notif->createImportant($a['id'], '🚨 Learning Goal — Double Deadline Exceeded',
"{$goal['contractor_name']}'s auto-generated learning goal \"{$goal['title']}\" has exceeded double the original deadline. Termination review required.",
"/users/{$goal['contractor_id']}", 'user', (int)$goal['contractor_id']);
}
}
}
} }
} }
public function schedule(): string { return 'daily'; }
} }
\ No newline at end of file
...@@ -10,81 +10,23 @@ use Engine\Notifications\NotificationManager; ...@@ -10,81 +10,23 @@ use Engine\Notifications\NotificationManager;
final class MeetingReminderJob implements JobInterface final class MeetingReminderJob implements JobInterface
{ {
public function key(): string
{
return 'meeting_reminders';
}
public function shouldRun(): bool
{
return true;
}
public function run(): void public function run(): void
{ {
$db = Container::getInstance()->resolve(Connection::class); $db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class); $notif = Container::getInstance()->resolve(NotificationManager::class);
$now = time();
$today = date('Y-m-d');
$tomorrow = date('Y-m-d', strtotime('+1 day')); $tomorrow = date('Y-m-d', strtotime('+1 day'));
$currentTime = date('H:i:s'); $meetings = $db->fetchAll("SELECT * FROM meetings WHERE meeting_date = ? AND status = 'scheduled'", [$tomorrow]);
// 1-day reminders: meetings tomorrow
$tomorrowMeetings = $db->fetchAll(
"SELECT m.*, u.full_name_en as creator_name FROM meetings m
JOIN users u ON u.id = m.created_by_id
WHERE m.meeting_date = ? AND m.status = 'scheduled'",
[$tomorrow]
);
foreach ($tomorrowMeetings as $m) { foreach ($meetings as $m) {
$invitees = $db->fetchAll( $invitees = $db->fetchAll("SELECT user_id FROM meeting_invitees WHERE meeting_id = ?", [$m['id']]);
"SELECT user_id FROM meeting_invitees WHERE meeting_id = ?",
[$m['id']]
);
foreach ($invitees as $inv) { foreach ($invitees as $inv) {
$alreadySent = $db->fetchOne( $notif->createImportant($inv['user_id'], 'Meeting Tomorrow',
"SELECT id FROM notifications WHERE user_id = ? AND link_entity_type = 'meeting' "Meeting: {$m['title']} — Tomorrow at {$m['start_time']}",
AND link_entity_id = ? AND title LIKE '%tomorrow%' AND created_at >= ?", "/meetings/{$m['id']}", 'meeting', $m['id']);
[$inv['user_id'], $m['id'], date('Y-m-d 00:00:00')]
);
if (!$alreadySent) {
$notif->createImportant($inv['user_id'], "Meeting tomorrow: {$m['title']}",
"Reminder: \"{$m['title']}\" is scheduled for tomorrow at {$m['start_time']}." .
($m['location'] ? " Location: {$m['location']}" : ''),
"/meetings/{$m['id']}", 'meeting', (int)$m['id']);
}
}
}
// 1-hour reminders: meetings today within the next hour
$oneHourFromNow = date('H:i:s', strtotime('+1 hour'));
$soonMeetings = $db->fetchAll(
"SELECT m.* FROM meetings m
WHERE m.meeting_date = ? AND m.status = 'scheduled'
AND m.start_time > ? AND m.start_time <= ?",
[$today, $currentTime, $oneHourFromNow]
);
foreach ($soonMeetings as $m) {
$invitees = $db->fetchAll(
"SELECT user_id FROM meeting_invitees WHERE meeting_id = ?",
[$m['id']]
);
foreach ($invitees as $inv) {
$alreadySent = $db->fetchOne(
"SELECT id FROM notifications WHERE user_id = ? AND link_entity_type = 'meeting'
AND link_entity_id = ? AND title LIKE '%1 hour%' AND created_at >= ?",
[$inv['user_id'], $m['id'], date('Y-m-d 00:00:00')]
);
if (!$alreadySent) {
$notif->createImportant($inv['user_id'], "Meeting in 1 hour: {$m['title']}",
"Starting at {$m['start_time']}." .
($m['location'] ? " Location: {$m['location']}" : ''),
"/meetings/{$m['id']}", 'meeting', (int)$m['id']);
}
} }
} }
} }
public function schedule(): string { return 'daily'; }
} }
\ No newline at end of file
...@@ -9,17 +9,11 @@ use Engine\Database\Connection; ...@@ -9,17 +9,11 @@ use Engine\Database\Connection;
final class InviteExpiryJob implements JobInterface final class InviteExpiryJob implements JobInterface
{ {
private Connection $db;
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
public function run(): void public function run(): void
{ {
$this->db->query( $db = Container::getInstance()->resolve(Connection::class);
"UPDATE invites SET status = 'expired' WHERE status = 'active' AND expires_at < NOW()" $db->query("UPDATE invites SET status = 'expired' WHERE status = 'active' AND expires_at < NOW()");
);
} }
public function schedule(): string { return 'every_hour'; }
} }
\ No newline at end of file
...@@ -10,78 +10,25 @@ use Engine\Notifications\NotificationManager; ...@@ -10,78 +10,25 @@ use Engine\Notifications\NotificationManager;
final class PIPCheckinReminderJob implements JobInterface final class PIPCheckinReminderJob implements JobInterface
{ {
public function key(): string
{
return 'pip_checkin_reminders';
}
public function shouldRun(): bool
{
return true;
}
public function run(): void public function run(): void
{ {
$db = Container::getInstance()->resolve(Connection::class); $db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class); $notif = Container::getInstance()->resolve(NotificationManager::class);
$today = date('Y-m-d');
$tomorrow = date('Y-m-d', strtotime('+1 day')); $tomorrow = date('Y-m-d', strtotime('+1 day'));
$checkins = $db->fetchAll( $checkins = $db->fetchAll(
"SELECT pc.*, p.contractor_id, p.created_by_id, u.full_name_en as contractor_name, "SELECT pc.*, p.contractor_id, p.created_by_id FROM pip_checkins pc
u.assigned_pl_id
FROM pip_checkins pc
JOIN pips p ON p.id = pc.pip_id
JOIN users u ON u.id = p.contractor_id
WHERE pc.scheduled_date IN (?, ?)
AND pc.logged_at IS NULL
AND p.status = 'active'
AND p.deleted_at IS NULL",
[$today, $tomorrow]
);
foreach ($checkins as $checkin) {
$isToday = $checkin['scheduled_date'] === $today;
$urgency = $isToday ? 'TODAY' : 'tomorrow';
$notif->createImportant($checkin['contractor_id'], "PIP Check-in {$urgency}",
"You have a PIP check-in scheduled for {$urgency}.",
"/pips/{$checkin['pip_id']}", 'pip', (int)$checkin['pip_id']);
$notif->createImportant($checkin['created_by_id'], "PIP Check-in {$urgency}",
"PIP check-in for {$checkin['contractor_name']} is scheduled {$urgency}.",
"/pips/{$checkin['pip_id']}", 'pip', (int)$checkin['pip_id']);
if ($checkin['assigned_pl_id'] && $checkin['assigned_pl_id'] !== $checkin['created_by_id']) {
$notif->createImportant($checkin['assigned_pl_id'], "PIP Check-in {$urgency}",
"PIP check-in for {$checkin['contractor_name']} is scheduled {$urgency}.",
"/pips/{$checkin['pip_id']}", 'pip', (int)$checkin['pip_id']);
}
}
$missedCheckins = $db->fetchAll(
"SELECT pc.*, p.contractor_id, u.full_name_en as contractor_name
FROM pip_checkins pc
JOIN pips p ON p.id = pc.pip_id JOIN pips p ON p.id = pc.pip_id
JOIN users u ON u.id = p.contractor_id WHERE pc.scheduled_date = ? AND pc.logged_at IS NULL AND p.status IN ('acknowledged','active') AND p.deleted_at IS NULL",
WHERE pc.scheduled_date < ? [$tomorrow]
AND pc.logged_at IS NULL
AND p.status = 'active'
AND p.deleted_at IS NULL",
[$today]
); );
foreach ($missedCheckins as $missed) { foreach ($checkins as $ci) {
$daysSince = (int)((strtotime($today) - strtotime($missed['scheduled_date'])) / 86400); $notif->createImportant($ci['created_by_id'], 'PIP Check-in Tomorrow',
if ($daysSince === 2) { "PIP check-in scheduled for tomorrow for contractor ID {$ci['contractor_id']}.",
$admins = $db->fetchAll("SELECT id FROM users WHERE role IN ('super_admin','admin') AND is_active = 1"); "/pips/{$ci['pip_id']}", 'pip', $ci['pip_id']);
foreach ($admins as $a) {
$notif->createImportant($a['id'], 'Missed PIP Check-in',
"PIP check-in for {$missed['contractor_name']} on {$missed['scheduled_date']} was missed. Notes have not been logged.",
"/pips/{$missed['pip_id']}", 'pip', (int)$missed['pip_id']);
}
}
} }
} }
public function schedule(): string { return 'daily'; }
} }
\ No newline at end of file
...@@ -6,161 +6,64 @@ namespace Modules\RecurringCards\Jobs; ...@@ -6,161 +6,64 @@ namespace Modules\RecurringCards\Jobs;
use Engine\Scheduler\JobInterface; use Engine\Scheduler\JobInterface;
use Engine\Core\Container; use Engine\Core\Container;
use Engine\Database\Connection; use Engine\Database\Connection;
use Engine\Notifications\NotificationManager;
final class CreateRecurringCardsJob implements JobInterface final class CreateRecurringCardsJob implements JobInterface
{ {
public function key(): string
{
return 'create_recurring_cards';
}
public function shouldRun(): bool
{
return true;
}
public function run(): void public function run(): void
{ {
$db = Container::getInstance()->resolve(Connection::class); $db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class);
$now = date('Y-m-d H:i:s');
$definitions = $db->fetchAll(
"SELECT rcd.*, b.board_key, b.card_sequence, b.is_archived
FROM recurring_card_definitions rcd
JOIN boards b ON b.id = rcd.board_id
WHERE rcd.is_active = 1 AND rcd.next_creation_at IS NOT NULL AND rcd.next_creation_at <= ?
AND b.is_archived = 0",
[$now]
);
foreach ($definitions as $def) {
$db->beginTransaction();
try {
$template = json_decode($def['card_template_json'], true) ?? [];
$db->query("UPDATE boards SET card_sequence = card_sequence + 1 WHERE id = ?", [(int)$def['board_id']]);
$updatedBoard = $db->fetchOne("SELECT card_sequence FROM boards WHERE id = ?", [(int)$def['board_id']]);
$cardKey = $def['board_key'] . '-' . $updatedBoard['card_sequence'];
$backlogCol = $db->fetchOne(
"SELECT id FROM board_columns WHERE board_id = ? AND slug = 'backlog'",
[(int)$def['board_id']]
);
if (!$backlogCol) {
$db->rollBack();
continue;
}
$dateStr = date('M j, Y');
$title = ($template['title'] ?? 'Recurring Task') . " — {$dateStr}";
$cardId = $db->insert('cards', [ $defs = $db->fetchAll("SELECT * FROM recurring_card_definitions WHERE is_active = 1 AND (next_creation_at IS NULL OR next_creation_at <= NOW())");
'board_id' => (int)$def['board_id'],
'column_id' => $backlogCol['id'], foreach ($defs as $def) {
'card_number' => $updatedBoard['card_sequence'], $board = $db->fetchOne("SELECT * FROM boards WHERE id = ? AND is_archived = 0", [$def['board_id']]);
'card_key' => $cardKey, if (!$board) continue;
'title' => $title,
'description' => $template['description'] ?? null, $backlogCol = $db->fetchOne("SELECT id FROM board_columns WHERE board_id = ? AND slug = 'backlog'", [$board['id']]);
'priority' => $template['priority'] ?? 'none', if (!$backlogCol) continue;
'estimated_hours' => isset($template['estimated_hours']) ? (float)$template['estimated_hours'] : null,
'deadline' => $template['deadline_offset_days'] ?? null $template = json_decode($def['card_template_json'], true) ?: [];
? date('Y-m-d 23:59:00', strtotime("+{$template['deadline_offset_days']} days"))
: null, $db->query("UPDATE boards SET card_sequence = card_sequence + 1 WHERE id = ?", [$board['id']]);
'position_in_column' => 0, $updated = $db->fetchOne("SELECT card_sequence FROM boards WHERE id = ?", [$board['id']]);
'created_by_id' => (int)$def['created_by_id'], $cardKey = $board['board_key'] . '-' . $updated['card_sequence'];
$cardId = $db->insert('cards', [
'board_id' => $board['id'],
'column_id' => $backlogCol['id'],
'card_number' => $updated['card_sequence'],
'card_key' => $cardKey,
'title' => $template['title'] ?? 'Recurring Task',
'description' => $template['description'] ?? null,
'priority' => $template['priority'] ?? 'none',
'position_in_column' => 0,
'created_by_id' => $def['created_by_id'],
]);
// Assign users
$assignees = json_decode($def['assignees_json'] ?? '[]', true) ?: [];
foreach ($assignees as $uid) {
$db->insert('card_assignments', [
'card_id' => $cardId, 'user_id' => (int)$uid, 'assigned_by_id' => $def['created_by_id'],
]); ]);
$db->insert('card_activity_log', [
'card_id' => $cardId,
'user_id' => null,
'action' => 'created',
'details_json' => json_encode(['source' => 'recurring', 'definition_id' => $def['id']]),
]);
if (!empty($template['labels'])) {
foreach ($template['labels'] as $labelId) {
$labelExists = $db->fetchOne("SELECT id FROM labels WHERE id = ?", [(int)$labelId]);
if ($labelExists) {
$db->query("INSERT IGNORE INTO card_labels (card_id, label_id) VALUES (?, ?)", [$cardId, (int)$labelId]);
}
}
}
if (!empty($template['checklists'])) {
foreach ($template['checklists'] as $ci => $checklist) {
$clId = $db->insert('card_checklists', [
'card_id' => $cardId,
'name' => $checklist['name'] ?? 'Checklist',
'position' => $ci + 1,
]);
if (!empty($checklist['items'])) {
foreach ($checklist['items'] as $ii => $item) {
$db->insert('card_checklist_items', [
'checklist_id' => $clId,
'text' => is_string($item) ? $item : ($item['text'] ?? ''),
'position' => $ii + 1,
]);
}
}
}
}
$assignees = $def['assignees_json'] ? json_decode($def['assignees_json'], true) : [];
foreach ($assignees as $assigneeId) {
$db->insert('card_assignments', [
'card_id' => $cardId,
'user_id' => (int)$assigneeId,
'assigned_by_id' => (int)$def['created_by_id'],
]);
$notif->createImportant((int)$assigneeId, 'Recurring Task Created',
"Recurring card {$cardKey}: {$title} has been created.",
"/cards/{$cardId}", 'card', $cardId);
}
$nextCreation = $this->calculateNextCreation($def);
$db->update('recurring_card_definitions', [
'last_created_at' => $now,
'next_creation_at' => $nextCreation,
], 'id = ?', [(int)$def['id']]);
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
error_log("CreateRecurringCardsJob error for def {$def['id']}: " . $e->getMessage());
} }
}
}
private function calculateNextCreation(array $def): string // Calculate next creation
{ $now = time();
$now = time(); $next = match($def['frequency']) {
switch ($def['frequency']) { 'daily' => strtotime('+1 day', $now),
case 'daily': 'weekly' => strtotime('+1 week', $now),
return date('Y-m-d H:i:s', strtotime('+1 day', $now)); 'biweekly' => strtotime('+2 weeks', $now),
case 'weekly': 'monthly' => strtotime('+1 month', $now),
$dayNames = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; default => strtotime('+' . ($def['frequency_days'] ?: 7) . ' days', $now),
$targetDay = $dayNames[$def['day_of_week'] ?? 1] ?? 'Monday'; };
return date('Y-m-d H:i:s', strtotime("next {$targetDay}", $now));
case 'biweekly': $db->update('recurring_card_definitions', [
return date('Y-m-d H:i:s', strtotime('+2 weeks', $now)); 'last_created_at' => date('Y-m-d H:i:s'),
case 'monthly': 'next_creation_at' => date('Y-m-d H:i:s', $next),
$dom = $def['day_of_month'] ?? 1; ], 'id = ?', [$def['id']]);
$nextMonth = strtotime('+1 month', $now);
$nextDate = date('Y-m', $nextMonth) . '-' . str_pad((string)$dom, 2, '0', STR_PAD_LEFT);
if (!checkdate((int)date('m', $nextMonth), $dom, (int)date('Y', $nextMonth))) {
$nextDate = date('Y-m-t', $nextMonth);
}
return $nextDate . ' 04:00:00';
case 'custom':
$days = (int)($def['frequency_days'] ?? 7);
return date('Y-m-d H:i:s', strtotime("+{$days} days", $now));
default:
return date('Y-m-d H:i:s', strtotime('+7 days', $now));
} }
} }
public function schedule(): string { return 'daily'; }
} }
\ No newline at end of file
...@@ -10,63 +10,64 @@ use Engine\Notifications\NotificationManager; ...@@ -10,63 +10,64 @@ use Engine\Notifications\NotificationManager;
final class DetectUnreportedDaysJob implements JobInterface final class DetectUnreportedDaysJob implements JobInterface
{ {
private Connection $db;
private NotificationManager $notif;
public function __construct()
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->notif = $c->resolve(NotificationManager::class);
}
public function run(): void public function run(): void
{ {
$db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class);
$yesterday = date('Y-m-d', strtotime('-1 day')); $yesterday = date('Y-m-d', strtotime('-1 day'));
$dayOfWeek = (int)date('w', strtotime($yesterday)); $dow = (int)date('w', strtotime($yesterday));
$contractors = $this->db->fetchAll( // Skip weekends (Friday=5, Saturday=6 in Egypt work week — configurable)
"SELECT u.id, u.full_name_en FROM users u WHERE u.role = 'contractor' AND u.status = 'active'" $contractors = $db->fetchAll(
"SELECT u.id, u.full_name_en FROM users u
WHERE u.role = 'contractor' AND u.status IN ('active','on_pip') AND u.is_active = 1"
); );
foreach ($contractors as $contractor) { foreach ($contractors as $c) {
$userId = $contractor['id']; // Check if user had a scheduled work day yesterday
$schedule = $db->fetchOne(
$isWorkDay = $this->db->fetchOne( "SELECT work_mode FROM user_schedule_days WHERE user_id = ? AND day_of_week = ? AND effective_to IS NULL",
"SELECT 1 FROM user_schedule_days WHERE user_id = ? AND day_of_week = ? AND work_mode != 'off' AND effective_to IS NULL", [$c['id'], $dow]
[$userId, $dayOfWeek]
); );
if (!$isWorkDay) continue;
$isHoliday = $this->db->fetchOne( if (!$schedule || $schedule['work_mode'] === 'off') continue;
"SELECT 1 FROM holidays WHERE start_date <= ? AND end_date >= ?",
[$yesterday, $yesterday] // Check unavailability
$unavail = $db->fetchOne(
"SELECT id FROM unavailability_records WHERE user_id = ? AND start_date <= ? AND end_date >= ?",
[$c['id'], $yesterday, $yesterday]
); );
if ($isHoliday) continue; if ($unavail) continue;
$isUnavailable = $this->db->fetchOne( // Check holiday
"SELECT 1 FROM unavailability_records WHERE user_id = ? AND start_date <= ? AND end_date >= ?", $holiday = $db->fetchOne(
[$userId, $yesterday, $yesterday] "SELECT id FROM holidays WHERE start_date <= ? AND end_date >= ?",
[$yesterday, $yesterday]
); );
if ($isUnavailable) continue; if ($holiday) continue;
$hasReport = $this->db->fetchOne( // Check if report exists
"SELECT 1 FROM daily_reports WHERE user_id = ? AND report_date = ?", $report = $db->fetchOne(
[$userId, $yesterday] "SELECT id, status FROM daily_reports WHERE user_id = ? AND report_date = ?",
[$c['id'], $yesterday]
); );
if ($hasReport) continue;
$this->db->insert('daily_reports', [ if (!$report) {
'user_id' => $userId, // Create unreported record
'report_date' => $yesterday, $db->insert('daily_reports', [
'status' => 'unreported', 'user_id' => $c['id'],
]); 'report_date' => $yesterday,
'status' => 'unreported',
'is_on_time' => 0,
]);
$this->notif->createImportant( $notif->createImportant($c['id'], 'Unreported Day',
$userId, "You have no report for {$yesterday}. Please submit or log unavailability.",
'Unreported Day', '/reports/submit');
"You have an unreported day: {$yesterday}. A deduction may be initiated." }
);
} }
} }
public function schedule(): string { return 'daily'; }
} }
\ No newline at end of file
...@@ -9,61 +9,28 @@ use Engine\Database\Connection; ...@@ -9,61 +9,28 @@ use Engine\Database\Connection;
final class BaseSalaryCalculator implements CalculatorInterface final class BaseSalaryCalculator implements CalculatorInterface
{ {
private Connection $db;
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
public function calculate(array $context): mixed public function calculate(array $context): mixed
{ {
$userId = $context['user_id']; $db = Container::getInstance()->resolve(Connection::class);
$contractorType = $context['contractor_type'] ?? null; $userId = $context['user_id'] ?? 0;
// Get current schedule $schedule = $db->fetchAll(
$schedule = $this->db->fetchAll( "SELECT day_of_week, work_mode FROM user_schedule_days WHERE user_id = ? AND effective_to IS NULL",
"SELECT day_of_week, work_mode FROM user_schedule_days
WHERE user_id = ? AND effective_to IS NULL AND work_mode != 'off'",
[$userId] [$userId]
); );
if (!$contractorType) { $workDays = 0;
$user = $this->db->fetchOne("SELECT contractor_type FROM users WHERE id = ?", [$userId]); foreach ($schedule as $s) {
$contractorType = $user['contractor_type'] ?? 'full_timer'; if ($s['work_mode'] !== 'off') $workDays++;
}
// Get day rates from system settings
$rates = $this->getDayRates($contractorType);
$baseSalary = 0.00;
foreach ($schedule as $day) {
if ($day['work_mode'] === 'in_office') {
$baseSalary += $rates['in_office'];
} elseif ($day['work_mode'] === 'remote') {
$baseSalary += $rates['remote'];
}
} }
return round($baseSalary, 2); $user = $db->fetchOne("SELECT contractor_type FROM users WHERE id = ?", [$userId]);
} $fullTimeRate = (float)($db->fetchOne("SELECT value FROM system_settings WHERE `key` = 'full_time_day_rate'")['value'] ?? 0);
$internRate = (float)($db->fetchOne("SELECT value FROM system_settings WHERE `key` = 'intern_day_rate'")['value'] ?? 0);
private function getDayRates(string $contractorType): array
{
$prefix = $contractorType === 'intern' ? 'intern' : 'full_timer';
$inOffice = $this->db->fetchOne( $rate = ($user['contractor_type'] ?? '') === 'intern' ? $internRate : $fullTimeRate;
"SELECT `value` FROM system_settings WHERE `key` = ?", $weeksPerMonth = 4.33;
["{$prefix}_in_office_day_rate"]
);
$remote = $this->db->fetchOne(
"SELECT `value` FROM system_settings WHERE `key` = ?",
["{$prefix}_remote_day_rate"]
);
return [ return round($workDays * $rate * $weeksPerMonth, 2);
'in_office' => (float)($inOffice['value'] ?? ($contractorType === 'intern' ? 1000 : 2400)),
'remote' => (float)($remote['value'] ?? ($contractorType === 'intern' ? 500 : 1600)),
];
} }
} }
\ No newline at end of file
...@@ -5,15 +5,22 @@ namespace Modules\Salary\Calculators; ...@@ -5,15 +5,22 @@ namespace Modules\Salary\Calculators;
use Engine\Calculation\CalculatorInterface; use Engine\Calculation\CalculatorInterface;
use Engine\Core\Container; use Engine\Core\Container;
use Engine\Database\Connection;
final class DailyRateCalculator implements CalculatorInterface final class DailyRateCalculator implements CalculatorInterface
{ {
public function calculate(array $context): mixed public function calculate(array $context): mixed
{ {
$actualSalary = (float)($context['actual_salary'] ?? 0); $db = Container::getInstance()->resolve(Connection::class);
$expectedWorkingDays = (int)($context['expected_working_days'] ?? 22); $userId = $context['user_id'] ?? 0;
$month = $context['month'] ?? date('Y-m');
if ($expectedWorkingDays <= 0) return 0; $user = $db->fetchOne("SELECT actual_salary FROM users WHERE id = ?", [$userId]);
return round($actualSalary / $expectedWorkingDays, 2); $actualSalary = (float)($user['actual_salary'] ?? 0);
$calcEngine = Container::getInstance()->resolve(\Engine\Calculation\CalculationEngine::class);
$expectedDays = $calcEngine->calculate('expected_working_days', ['user_id' => $userId, 'month' => $month]);
return $expectedDays > 0 ? round($actualSalary / $expectedDays, 2) : 0;
} }
} }
\ No newline at end of file
...@@ -9,57 +9,52 @@ use Engine\Database\Connection; ...@@ -9,57 +9,52 @@ use Engine\Database\Connection;
final class ExpectedWorkingDaysCalculator implements CalculatorInterface final class ExpectedWorkingDaysCalculator implements CalculatorInterface
{ {
private Connection $db;
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
public function calculate(array $context): mixed public function calculate(array $context): mixed
{ {
$userId = $context['user_id']; $db = Container::getInstance()->resolve(Connection::class);
$userId = $context['user_id'] ?? 0;
$month = $context['month'] ?? date('Y-m'); $month = $context['month'] ?? date('Y-m');
$year = (int)substr($month, 0, 4); $schedule = $db->fetchAll(
$mon = (int)substr($month, 5, 2); "SELECT day_of_week, work_mode FROM user_schedule_days WHERE user_id = ? AND effective_to IS NULL",
$daysInMonth = cal_days_in_month(CAL_GREGORIAN, $mon, $year);
// Get working days from schedule
$schedule = $this->db->fetchAll(
"SELECT day_of_week, work_mode FROM user_schedule_days
WHERE user_id = ? AND effective_to IS NULL AND work_mode != 'off'",
[$userId] [$userId]
); );
$workingDaysOfWeek = array_map(fn($s) => (int)$s['day_of_week'], $schedule); $workDows = [];
foreach ($schedule as $s) {
if ($s['work_mode'] !== 'off') $workDows[] = (int)$s['day_of_week'];
}
if (empty($workDows)) return 22; // Default fallback
// Get holidays in this month $holidays = $db->fetchAll(
$holidays = $this->db->fetchAll(
"SELECT start_date, end_date FROM holidays WHERE start_date <= ? AND end_date >= ?", "SELECT start_date, end_date FROM holidays WHERE start_date <= ? AND end_date >= ?",
["{$month}-{$daysInMonth}", "{$month}-01"] [$month . '-31', $month . '-01']
); );
$holidayDates = []; $holidayDates = [];
foreach ($holidays as $h) { foreach ($holidays as $h) {
$start = new \DateTime($h['start_date']); $s = strtotime($h['start_date']);
$end = new \DateTime($h['end_date']); $e = strtotime($h['end_date']);
while ($start <= $end) { for ($d = $s; $d <= $e; $d += 86400) {
$holidayDates[] = $start->format('Y-m-d'); $holidayDates[date('Y-m-d', $d)] = true;
$start->modify('+1 day');
} }
} }
$count = 0; $startDate = $month . '-01';
for ($day = 1; $day <= $daysInMonth; $day++) { $endDate = date('Y-m-t', strtotime($startDate));
$date = sprintf('%s-%02d', $month, $day); $workDays = 0;
$dayOfWeek = (int)date('w', strtotime($date)); // 0=Sun $current = strtotime($startDate);
$end = strtotime($endDate);
if (in_array($dayOfWeek, $workingDaysOfWeek) && !in_array($date, $holidayDates)) {
$count++; while ($current <= $end) {
$dow = (int)date('w', $current);
$dateStr = date('Y-m-d', $current);
if (in_array($dow, $workDows) && !isset($holidayDates[$dateStr])) {
$workDays++;
} }
$current += 86400;
} }
return $count; return $workDays;
} }
} }
\ No newline at end of file
...@@ -9,42 +9,20 @@ use Engine\Database\Connection; ...@@ -9,42 +9,20 @@ use Engine\Database\Connection;
final class LiveSalaryCalculator implements CalculatorInterface final class LiveSalaryCalculator implements CalculatorInterface
{ {
private Connection $db;
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
public function calculate(array $context): mixed public function calculate(array $context): mixed
{ {
$userId = $context['user_id']; $db = Container::getInstance()->resolve(Connection::class);
$userId = $context['user_id'] ?? 0;
$month = $context['month'] ?? date('Y-m'); $month = $context['month'] ?? date('Y-m');
$actualSalary = (float)($context['actual_salary'] ?? 0);
$bounties = (float)$this->db->fetchColumn(
"SELECT COALESCE(SUM(amount), 0) FROM bounty_payouts WHERE recipient_id = ? AND payroll_month = ?",
[$userId, $month]
);
$deductions = (float)$this->db->fetchColumn(
"SELECT COALESCE(SUM(COALESCE(final_amount, calculated_amount)), 0) FROM deductions
WHERE contractor_id = ? AND payroll_month = ? AND status IN ('applied','applied_no_response','reduced','accepted') AND deleted_at IS NULL",
[$userId, $month]
);
$posAdj = (float)$this->db->fetchColumn( $user = $db->fetchOne("SELECT actual_salary FROM users WHERE id = ?", [$userId]);
"SELECT COALESCE(SUM(amount), 0) FROM manual_adjustments $actual = (float)($user['actual_salary'] ?? 0);
WHERE contractor_id = ? AND effective_month = ? AND type = 'positive' AND status = 'approved' AND deleted_at IS NULL",
[$userId, $month]
);
$negAdj = (float)$this->db->fetchColumn( $bounties = (float)$db->fetchColumn("SELECT COALESCE(SUM(amount),0) FROM bounty_payouts WHERE recipient_id = ? AND payroll_month = ?", [$userId, $month]);
"SELECT COALESCE(SUM(amount), 0) FROM manual_adjustments $deductions = (float)$db->fetchColumn("SELECT COALESCE(SUM(COALESCE(final_amount,calculated_amount)),0) FROM deductions WHERE contractor_id = ? AND payroll_month = ? AND status IN ('applied','applied_no_response','reduced','accepted') AND deleted_at IS NULL", [$userId, $month]);
WHERE contractor_id = ? AND effective_month = ? AND type = 'negative' AND status = 'approved' AND deleted_at IS NULL", $posAdj = (float)$db->fetchColumn("SELECT COALESCE(SUM(amount),0) FROM manual_adjustments WHERE contractor_id = ? AND effective_month = ? AND type = 'positive' AND status = 'approved' AND deleted_at IS NULL", [$userId, $month]);
[$userId, $month] $negAdj = (float)$db->fetchColumn("SELECT COALESCE(SUM(amount),0) FROM manual_adjustments WHERE contractor_id = ? AND effective_month = ? AND type = 'negative' AND status = 'approved' AND deleted_at IS NULL", [$userId, $month]);
);
return round($actualSalary + $bounties + $posAdj - $deductions - $negAdj, 2); return round($actual + $bounties + $posAdj - $deductions - $negAdj, 2);
} }
} }
\ No newline at end of file
...@@ -10,16 +10,12 @@ use Modules\Webhooks\Services\WebhookDispatcher; ...@@ -10,16 +10,12 @@ use Modules\Webhooks\Services\WebhookDispatcher;
final class WebhookRetryJob implements JobInterface final class WebhookRetryJob implements JobInterface
{ {
public function key(): string { return 'webhook_retry'; }
public function schedule(): string { return '*/5 * * * *'; }
public function run(): void public function run(): void
{ {
$db = Container::getInstance()->resolve(Connection::class); $db = Container::getInstance()->resolve(Connection::class);
$dispatcher = new WebhookDispatcher($db); $dispatcher = new WebhookDispatcher($db);
$retried = $dispatcher->retryFailed(); $dispatcher->retryFailed();
if ($retried > 0) {
error_log("[WebhookRetryJob] Retried {$retried} failed webhook deliveries.");
}
} }
public function schedule(): string { return 'every_15_minutes'; }
} }
\ No newline at end of file
...@@ -10,33 +10,10 @@ use Modules\Webhooks\Services\WebhookDispatcher; ...@@ -10,33 +10,10 @@ use Modules\Webhooks\Services\WebhookDispatcher;
final class DispatchWebhookListener implements ListenerInterface final class DispatchWebhookListener implements ListenerInterface
{ {
private static array $eventMapping = [ public function handle(string $event, array $payload): void
'card.created' => 'card.created',
'card.moved' => 'card.moved',
'card.assigned' => 'card.assigned',
'card.moved_to_done' => 'card.done',
'bounty.paid' => 'bounty.paid',
'report.submitted' => 'report.submitted',
'report.unreported_detected' => 'report.missed',
'deduction.initiated' => 'deduction.created',
'deduction.applied' => 'deduction.applied',
'user.activated' => 'contractor.activated',
'user.terminated' => 'contractor.terminated',
'payroll.approved' => 'payroll.approved',
'evaluation.compiled' => 'evaluation.compiled',
];
public function handle(string $eventName, array $data): void
{ {
$webhookEvent = self::$eventMapping[$eventName] ?? null; $db = Container::getInstance()->resolve(Connection::class);
if (!$webhookEvent) return; $dispatcher = new WebhookDispatcher($db);
$dispatcher->dispatch($event, $payload);
try {
$db = Container::getInstance()->resolve(Connection::class);
$dispatcher = new WebhookDispatcher($db);
$dispatcher->dispatch($webhookEvent, $data);
} catch (\Throwable $e) {
error_log("[WebhookListener] Failed to dispatch webhook for {$eventName}: " . $e->getMessage());
}
} }
} }
\ No newline at end of file
This diff is collapsed.
/* Analytics Charts - placeholder for chart rendering */
(function() { (function() {
'use strict'; 'use strict';
window.renderChart = function(containerId, type, data) {
window.AnalyticsCharts = { const el = document.getElementById(containerId);
renderBarChart: function(canvasId, labels, datasets, options) { if (!el) return;
const canvas = document.getElementById(canvasId); // Simple bar chart using CSS
if (!canvas) return; if (type === 'bar' && Array.isArray(data)) {
const ctx = canvas.getContext('2d'); const max = Math.max(...data.map(d => d.value || 0));
const width = canvas.width = canvas.parentElement.clientWidth; let html = '<div style="display:flex;align-items:end;gap:4px;height:200px;">';
const height = canvas.height = options?.height || 300; data.forEach(d => {
const padding = { top: 20, right: 20, bottom: 40, left: 60 }; const pct = max > 0 ? (d.value / max) * 100 : 0;
html += `<div style="flex:1;text-align:center;"><div style="background:var(--accent-primary);height:${pct}%;border-radius:4px 4px 0 0;min-height:2px;"></div><div style="font-size:0.7rem;color:var(--text-muted);margin-top:4px;">${d.label || ''}</div></div>`;
const chartW = width - padding.left - padding.right;
const chartH = height - padding.top - padding.bottom;
const allValues = datasets.flatMap(d => d.data);
const maxVal = Math.max(...allValues, 1);
const barGroupW = chartW / labels.length;
const barW = Math.min(30, (barGroupW - 10) / datasets.length);
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = '#f9fafb';
ctx.fillRect(0, 0, width, height);
// Y axis
ctx.strokeStyle = '#e5e7eb';
ctx.lineWidth = 1;
for (let i = 0; i <= 5; i++) {
const y = padding.top + chartH - (chartH * i / 5);
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
ctx.stroke();
ctx.fillStyle = '#6b7280';
ctx.font = '11px Arial';
ctx.textAlign = 'right';
ctx.fillText(Math.round(maxVal * i / 5).toLocaleString(), padding.left - 8, y + 4);
}
// Bars
const colors = ['#3b82f6', '#22c55e', '#eab308', '#ef4444', '#8b5cf6'];
datasets.forEach((dataset, di) => {
ctx.fillStyle = dataset.color || colors[di % colors.length];
dataset.data.forEach((val, i) => {
const barH = (val / maxVal) * chartH;
const x = padding.left + i * barGroupW + (barGroupW - barW * datasets.length) / 2 + di * barW;
const y = padding.top + chartH - barH;
ctx.fillRect(x, y, barW - 1, barH);
});
});
// X labels
ctx.fillStyle = '#374151';
ctx.font = '11px Arial';
ctx.textAlign = 'center';
labels.forEach((label, i) => {
const x = padding.left + i * barGroupW + barGroupW / 2;
ctx.fillText(label, x, height - 8);
});
},
renderLineChart: function(canvasId, labels, datasets, options) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const ctx = canvas.getContext('2d');
const width = canvas.width = canvas.parentElement.clientWidth;
const height = canvas.height = options?.height || 250;
const padding = { top: 20, right: 20, bottom: 40, left: 60 };
const chartW = width - padding.left - padding.right;
const chartH = height - padding.top - padding.bottom;
const allValues = datasets.flatMap(d => d.data);
const maxVal = Math.max(...allValues, 1);
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = '#f9fafb';
ctx.fillRect(0, 0, width, height);
// Grid
ctx.strokeStyle = '#e5e7eb';
ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const y = padding.top + chartH - (chartH * i / 4);
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
ctx.stroke();
ctx.fillStyle = '#6b7280';
ctx.font = '11px Arial';
ctx.textAlign = 'right';
ctx.fillText(Math.round(maxVal * i / 4).toLocaleString(), padding.left - 8, y + 4);
}
const colors = ['#3b82f6', '#22c55e', '#eab308', '#ef4444'];
datasets.forEach((dataset, di) => {
ctx.strokeStyle = dataset.color || colors[di % colors.length];
ctx.lineWidth = 2;
ctx.beginPath();
dataset.data.forEach((val, i) => {
const x = padding.left + (i / Math.max(1, labels.length - 1)) * chartW;
const y = padding.top + chartH - (val / maxVal) * chartH;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
// Dots
ctx.fillStyle = dataset.color || colors[di % colors.length];
dataset.data.forEach((val, i) => {
const x = padding.left + (i / Math.max(1, labels.length - 1)) * chartW;
const y = padding.top + chartH - (val / maxVal) * chartH;
ctx.beginPath();
ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fill();
});
});
// X labels
ctx.fillStyle = '#374151';
ctx.font = '11px Arial';
ctx.textAlign = 'center';
labels.forEach((label, i) => {
const x = padding.left + (i / Math.max(1, labels.length - 1)) * chartW;
ctx.fillText(label, x, height - 8);
});
},
renderPieChart: function(canvasId, data, options) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const ctx = canvas.getContext('2d');
const size = Math.min(canvas.parentElement.clientWidth, options?.height || 250);
canvas.width = size;
canvas.height = size;
const total = data.reduce((sum, d) => sum + d.value, 0);
if (total === 0) return;
const cx = size / 2;
const cy = size / 2;
const radius = size / 2 - 20;
const colors = ['#3b82f6', '#22c55e', '#eab308', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4'];
let startAngle = -Math.PI / 2;
data.forEach((d, i) => {
const sliceAngle = (d.value / total) * Math.PI * 2;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, radius, startAngle, startAngle + sliceAngle);
ctx.closePath();
ctx.fillStyle = d.color || colors[i % colors.length];
ctx.fill();
// Label
if (sliceAngle > 0.2) {
const midAngle = startAngle + sliceAngle / 2;
const labelX = cx + Math.cos(midAngle) * radius * 0.65;
const labelY = cy + Math.sin(midAngle) * radius * 0.65;
ctx.fillStyle = '#fff';
ctx.font = 'bold 11px Arial';
ctx.textAlign = 'center';
ctx.fillText(d.label, labelX, labelY);
}
startAngle += sliceAngle;
}); });
html += '</div>';
el.innerHTML = html;
} }
}; };
})(); })();
\ No newline at end of file
/* Keyboard Shortcuts */
(function() { (function() {
'use strict'; 'use strict';
document.addEventListener('keydown', function(e) {
const shortcuts = { // Ctrl+K or Cmd+K → Focus search
'ctrl+k': { action: openSearch, desc: 'Open search / command palette' }, if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
'ctrl+shift+n': { action: () => navigateTo('/reports/submit'), desc: 'Submit daily report' },
'ctrl+shift+b': { action: () => navigateTo('/boards'), desc: 'Go to boards' },
'ctrl+shift+d': { action: () => navigateTo('/dashboard'), desc: 'Go to dashboard' },
'ctrl+shift+m': { action: () => navigateTo('/messages'), desc: 'Go to messages' },
'escape': { action: closeModals, desc: 'Close modals/panels' },
'?': { action: showHelpPanel, desc: 'Show keyboard shortcuts' },
};
const helpPanelHtml = `
<div id="kb-help-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:9999;display:flex;align-items:center;justify-content:center">
<div style="background:var(--bg-primary,#fff);border-radius:12px;padding:32px;max-width:500px;width:90%;max-height:80vh;overflow-y:auto;box-shadow:0 20px 60px rgba(0,0,0,0.3)">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
<h2 style="margin:0;font-size:1.3em">⌨️ Keyboard Shortcuts</h2>
<button onclick="document.getElementById('kb-help-overlay').style.display='none'" style="background:none;border:none;font-size:1.5em;cursor:pointer">×</button>
</div>
<table style="width:100%;border-collapse:collapse">
<tbody id="kb-shortcuts-list"></tbody>
</table>
</div>
</div>`;
function init() {
document.body.insertAdjacentHTML('beforeend', helpPanelHtml);
const list = document.getElementById('kb-shortcuts-list');
if (list) {
Object.entries(shortcuts).forEach(([combo, config]) => {
const row = document.createElement('tr');
row.innerHTML = `<td style="padding:8px 12px"><kbd style="background:#f0f0f0;border:1px solid #ccc;border-radius:4px;padding:2px 8px;font-family:monospace;font-size:0.9em">${combo}</kbd></td><td style="padding:8px 12px;color:#666">${config.desc}</td>`;
list.appendChild(row);
});
}
document.addEventListener('keydown', handleKeyDown);
}
function handleKeyDown(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) {
if (e.key === 'Escape') closeModals();
return;
}
const combo = buildCombo(e);
const shortcut = shortcuts[combo];
if (shortcut) {
e.preventDefault(); e.preventDefault();
shortcut.action(); const search = document.getElementById('global-search');
if (search) search.focus();
} }
} // Escape → Blur search
if (e.key === 'Escape') {
function buildCombo(e) { document.activeElement.blur();
const parts = [];
if (e.ctrlKey || e.metaKey) parts.push('ctrl');
if (e.shiftKey) parts.push('shift');
if (e.altKey) parts.push('alt');
const key = e.key.toLowerCase();
if (!['control', 'shift', 'alt', 'meta'].includes(key)) parts.push(key);
return parts.join('+');
}
function openSearch() {
const searchInput = document.querySelector('#global-search-input, [data-search-input]');
if (searchInput) {
searchInput.focus();
searchInput.select();
} }
const modal = document.getElementById('search-modal'); });
if (modal) modal.style.display = 'flex';
}
function closeModals() {
document.querySelectorAll('[data-modal], .modal-overlay, #kb-help-overlay, #search-modal').forEach(el => {
el.style.display = 'none';
});
document.querySelectorAll('.slide-panel').forEach(el => {
el.classList.remove('open');
});
}
function showHelpPanel() {
const overlay = document.getElementById('kb-help-overlay');
if (overlay) overlay.style.display = 'flex';
}
function navigateTo(url) {
window.location.href = url;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})(); })();
\ No newline at end of file
/* Offline Queue - stores failed requests for retry */
(function() { (function() {
'use strict'; 'use strict';
window.offlineQueue = {
const QUEUE_KEY = 'al_arcade_offline_queue'; queue: JSON.parse(localStorage.getItem('offlineQueue') || '[]'),
let isOnline = navigator.onLine; add(url, options) {
let statusEl = null; this.queue.push({ url, options, timestamp: Date.now() });
localStorage.setItem('offlineQueue', JSON.stringify(this.queue));
function init() { },
statusEl = document.createElement('div'); async flush() {
statusEl.id = 'offline-status'; const pending = [...this.queue];
statusEl.style.cssText = 'position:fixed;top:0;left:0;right:0;padding:8px 16px;background:#ef4444;color:#fff;text-align:center;font-size:0.9em;z-index:99999;display:none;transition:transform 0.3s ease'; this.queue = [];
statusEl.textContent = '⚠️ You are offline — changes will sync when reconnected'; localStorage.setItem('offlineQueue', '[]');
document.body.prepend(statusEl); for (const item of pending) {
try { await fetch(item.url, item.options); } catch (e) { this.add(item.url, item.options); }
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
if (!navigator.onLine) handleOffline();
processQueue();
}
function handleOnline() {
isOnline = true;
statusEl.style.display = 'none';
processQueue();
}
function handleOffline() {
isOnline = false;
statusEl.style.display = 'block';
}
function getQueue() {
try {
return JSON.parse(localStorage.getItem(QUEUE_KEY) || '[]');
} catch (e) { return []; }
}
function saveQueue(queue) {
localStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
}
function enqueue(action) {
const queue = getQueue();
queue.push({
...action,
queued_at: new Date().toISOString(),
id: Date.now() + '_' + Math.random().toString(36).substr(2, 9),
});
saveQueue(queue);
if (isOnline) processQueue();
}
async function processQueue() {
if (!isOnline) return;
const queue = getQueue();
if (queue.length === 0) return;
const remaining = [];
for (const action of queue) {
try {
const response = await fetch(action.url, {
method: action.method || 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
'X-Queued-At': action.queued_at,
},
body: action.body ? JSON.stringify(action.body) : undefined,
credentials: 'same-origin',
});
if (!response.ok && response.status >= 500) {
remaining.push(action);
}
} catch (e) {
remaining.push(action);
break;
} }
} }
};
saveQueue(remaining); window.addEventListener('online', () => window.offlineQueue.flush());
if (remaining.length > 0 && remaining.length < queue.length) {
setTimeout(processQueue, 5000);
}
}
window.OfflineQueue = { enqueue, getQueue, processQueue, isOnline: () => isOnline };
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})(); })();
\ No newline at end of file
<?php <?php
/** // Quick health check endpoint
* DIAGNOSTIC FILE — DELETE AFTER DEBUGGING header('Content-Type: application/json');
* Hit: https://hrsystem.caprover.al-arcade.com/test.php echo json_encode([
*/ 'status' => 'ok',
'php_version' => PHP_VERSION,
error_reporting(E_ALL); 'timestamp' => date('c'),
ini_set('display_errors', '1'); 'server' => php_uname('s'),
]);
echo "<h1>AL-ARCADE HR — Diagnostic</h1><pre>"; \ No newline at end of file
// Step 1: Basic PHP
echo "✅ Step 1: PHP is alive. Version: " . PHP_VERSION . "\n";
// Step 2: ROOT_PATH
define('ROOT_PATH', dirname(__DIR__));
echo "✅ Step 2: ROOT_PATH = " . ROOT_PATH . "\n";
// Step 3: Check critical files exist
$files = [
'bootstrap/autoload.php',
'bootstrap/app.php',
'config/app.php',
'config/database.php',
'config/permissions.php',
'engine/Core/App.php',
'engine/Core/Config.php',
'engine/Core/Container.php',
'engine/Core/Router.php',
'engine/Core/Request.php',
'engine/Core/Response.php',
'engine/Core/MiddlewarePipeline.php',
'engine/Database/Connection.php',
'engine/Auth/PasswordHasher.php',
'engine/Auth/SessionManager.php',
'engine/Auth/RateLimiter.php',
'engine/Auth/Authenticator.php',
'engine/Auth/PermissionEngine.php',
'engine/Audit/AuditLogger.php',
'engine/Events/EventDispatcher.php',
'engine/StateMachine/StateMachine.php',
'engine/StateMachine/StateDefinition.php',
'engine/Notifications/NotificationManager.php',
'engine/Calculation/CalculationEngine.php',
'engine/Calculation/CalculatorInterface.php',
'engine/Validation/Validator.php',
'engine/FileStorage/FileManager.php',
'engine/Scheduler/JobRunner.php',
'engine/Scheduler/JobInterface.php',
'engine/Search/SearchEngine.php',
'engine/Export/ExportManager.php',
'engine/Template/TemplateEngine.php',
'engine/RealTime/SSEController.php',
'engine/Cache/QueryCache.php',
'middleware/AuthenticationMiddleware.php',
'middleware/AuditMiddleware.php',
'middleware/JsonBodyParserMiddleware.php',
'middleware/ApiKeyAuthMiddleware.php',
'middleware/CORSMiddleware.php',
'middleware/CSRFMiddleware.php',
'middleware/BlockingNotificationMiddleware.php',
'middleware/SecurityHeadersMiddleware.php',
'templates/auth/login.php',
'templates/layouts/app.php',
'templates/layouts/auth.php',
];
$missing = [];
foreach ($files as $f) {
$full = ROOT_PATH . '/' . $f;
if (file_exists($full)) {
echo "✅ {$f}\n";
} else {
echo "❌ MISSING: {$f}\n";
$missing[] = $f;
}
}
if (!empty($missing)) {
echo "\n\n🔴 MISSING " . count($missing) . " FILES! These must be created.\n";
echo "</pre>";
exit;
}
// Step 4: Try autoloader
echo "\n--- Step 4: Autoloader ---\n";
try {
require ROOT_PATH . '/bootstrap/autoload.php';
echo "✅ Autoloader loaded\n";
} catch (\Throwable $e) {
echo "❌ Autoloader failed: " . $e->getMessage() . "\n";
echo "</pre>";
exit;
}
// Step 5: Try loading classes one by one
echo "\n--- Step 5: Class Loading ---\n";
$classes = [
'Engine\\Core\\Config',
'Engine\\Core\\Container',
'Engine\\Core\\Router',
'Engine\\Core\\Request',
'Engine\\Core\\Response',
'Engine\\Core\\MiddlewarePipeline',
'Engine\\Database\\Connection',
'Engine\\Auth\\PasswordHasher',
'Engine\\Auth\\SessionManager',
'Engine\\Auth\\RateLimiter',
'Engine\\Auth\\Authenticator',
'Engine\\Auth\\PermissionEngine',
'Engine\\Audit\\AuditLogger',
'Engine\\Events\\EventDispatcher',
'Engine\\StateMachine\\StateMachine',
'Engine\\Notifications\\NotificationManager',
'Engine\\Calculation\\CalculationEngine',
'Engine\\Validation\\Validator',
'Engine\\FileStorage\\FileManager',
'Engine\\Scheduler\\JobRunner',
'Engine\\Search\\SearchEngine',
'Engine\\Export\\ExportManager',
'Engine\\Template\\TemplateEngine',
];
foreach ($classes as $class) {
try {
if (class_exists($class)) {
echo "✅ {$class}\n";
} else {
echo "❌ CLASS NOT FOUND: {$class}\n";
}
} catch (\Throwable $e) {
echo "❌ {$class}: " . $e->getMessage() . "\n";
}
}
// Step 6: Try database connection
echo "\n--- Step 6: Database ---\n";
try {
$dbConfig = require ROOT_PATH . '/config/database.php';
echo "✅ Database config loaded: host={$dbConfig['host']}\n";
$dsn = "mysql:host={$dbConfig['host']};port={$dbConfig['port']};dbname={$dbConfig['database']};charset={$dbConfig['charset']}";
$pdo = new PDO($dsn, $dbConfig['username'], $dbConfig['password'], $dbConfig['options'] ?? []);
echo "✅ PDO connection successful\n";
$tables = $pdo->query("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='al_arcade_hr'")->fetchColumn();
echo "✅ Tables: {$tables}\n";
$users = $pdo->query("SELECT COUNT(*) FROM users")->fetchColumn();
echo "✅ Users: {$users}\n";
} catch (\Throwable $e) {
echo "❌ DB Error: " . $e->getMessage() . "\n";
}
// Step 7: Try bootstrap
echo "\n--- Step 7: Full Bootstrap ---\n";
try {
require ROOT_PATH . '/bootstrap/app.php';
echo "✅ Bootstrap completed!\n";
$container = \Engine\Core\Container::getInstance();
$router = $container->resolve(\Engine\Core\Router::class);
$routes = $router->getRoutes();
echo "✅ Routes loaded: " . count($routes) . "\n";
// Show first 10 routes
foreach (array_slice($routes, 0, 10) as $r) {
echo " {$r['httpMethod']} {$r['uri']}{$r['controller']}::{$r['method']}\n";
}
if (count($routes) > 10) {
echo " ... and " . (count($routes) - 10) . " more\n";
}
} catch (\Throwable $e) {
echo "❌ Bootstrap failed: " . $e->getMessage() . "\n";
echo " File: " . $e->getFile() . ":" . $e->getLine() . "\n";
echo " Trace:\n" . $e->getTraceAsString() . "\n";
}
echo "\n--- DONE ---\n";
echo "</pre>";
\ No newline at end of file
<?php /** @var array $user */ /** @var array $data_sources */ ?> <?php /** @var array $user */ /** @var array $data_sources */ ?>
<div class="report-builder"> <div class="dashboard-header"><h1>📈 Report Builder</h1></div>
<h1>🔨 Custom Report Builder</h1> <div class="card"><p>Custom report builder. Use the API endpoint <code>POST /analytics/report-builder/execute</code> with source, columns, filters, and aggregation parameters.</p>
<h3 class="mt-3">Available Data Sources</h3>
<div class="builder-form"> <?php foreach ($data_sources as $name => $ds): ?>
<div class="form-group"> <div class="stat-row"><strong><?= $name ?></strong><span><?= implode(', ', array_keys($ds['columns'])) ?></span></div>
<label>Data Source</label> <?php endforeach; ?>
<select id="rb-source" onchange="updateSourceColumns()" class="form-control"> </div>
<option value="">Select a data source...</option> \ No newline at end of file
<?php foreach ($data_sources as $key => $ds): ?>
<option value="<?= $key ?>"><?= ucfirst($key) ?></option>
<?php endforeach; ?>
</select>
</div>
<div id="rb-columns-section" style="display:none">
<div class="form-group">
<label>Columns</label>
<div id="rb-columns" class="checkbox-grid"></div>
</div>
<div class="form-group">
<label>Filters</label>
<div id="rb-filters"></div>
<button onclick="addFilter()" class="btn btn-secondary btn-sm">+ Add Filter</button>
</div>
<div class="form-row">
<div class="form-group">
<label>Group By</label>
<select id="rb-groupby" class="form-control"><option value="">None</option></select>
</div>
<div class="form-group">
<label>Aggregation</label>
<select id="rb-aggregation" class="form-control">
<option value="">None</option>
<option value="sum">SUM</option>
<option value="avg">AVG</option>
<option value="count">COUNT</option>
<option value="min">MIN</option>
<option value="max">MAX</option>
</select>
</div>
<div class="form-group">
<label>Aggregate Column</label>
<select id="rb-aggcol" class="form-control"></select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Sort By</label>
<select id="rb-sortby" class="form-control"></select>
</div>
<div class="form-group">
<label>Direction</label>
<select id="rb-sortdir" class="form-control"><option value="DESC">Descending</option><option value="ASC">Ascending</option></select>
</div>
<div class="form-group">
<label>Limit</label>
<input type="number" id="rb-limit" value="1000" min="1" max="5000" class="form-control">
</div>
</div>
<button onclick="executeReport()" class="btn btn-primary btn-lg">🚀 Run Report</button>
</div>
</div>
<div id="rb-results" style="display:none">
<div class="results-header">
<h2>Results <span id="rb-count"></span></h2>
<button onclick="exportResults()" class="btn btn-secondary">📥 Export CSV</button>
</div>
<div class="table-responsive"><table id="rb-results-table" class="data-table"></table></div>
</div>
</div>
<script>
const dataSources = <?= json_encode($data_sources) ?>;
let lastResults = [];
function updateSourceColumns() {
const source = document.getElementById('rb-source').value;
if (!source) { document.getElementById('rb-columns-section').style.display = 'none'; return; }
document.getElementById('rb-columns-section').style.display = 'block';
const ds = dataSources[source];
const cols = Object.keys(ds.columns);
document.getElementById('rb-columns').innerHTML = cols.map(c => '<label class="checkbox-label"><input type="checkbox" value="' + c + '" checked> ' + c + '</label>').join('');
const opts = cols.map(c => '<option value="' + c + '">' + c + '</option>').join('');
document.getElementById('rb-groupby').innerHTML = '<option value="">None</option>' + opts;
document.getElementById('rb-sortby').innerHTML = opts;
document.getElementById('rb-aggcol').innerHTML = (ds.aggregatable || []).map(c => '<option value="' + c + '">' + c + '</option>').join('');
}
function addFilter() {
const source = document.getElementById('rb-source').value;
if (!source) return;
const cols = Object.keys(dataSources[source].columns);
const div = document.createElement('div');
div.className = 'filter-row';
div.innerHTML = '<select class="f-col form-control">' + cols.map(c => '<option>' + c + '</option>').join('') + '</select>' +
'<select class="f-op form-control"><option>=</option><option>!=</option><option>></option><option><</option><option>>=</option><option><=</option><option>LIKE</option><option>IS NULL</option><option>IS NOT NULL</option></select>' +
'<input class="f-val form-control" placeholder="value">' +
'<button onclick="this.parentElement.remove()" class="btn btn-xs btn-danger">×</button>';
document.getElementById('rb-filters').appendChild(div);
}
function executeReport() {
const source = document.getElementById('rb-source').value;
const columns = Array.from(document.querySelectorAll('#rb-columns input:checked')).map(cb => cb.value);
const filters = Array.from(document.querySelectorAll('.filter-row')).map(row => ({
column: row.querySelector('.f-col').value,
operator: row.querySelector('.f-op').value,
value: row.querySelector('.f-val').value,
}));
fetch('/analytics/report-builder/execute', {
method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]')?.content || ''},
body: JSON.stringify({
source, columns, filters,
group_by: document.getElementById('rb-groupby').value,
aggregation: document.getElementById('rb-aggregation').value,
agg_column: document.getElementById('rb-aggcol').value,
sort_by: document.getElementById('rb-sortby').value,
sort_dir: document.getElementById('rb-sortdir').value,
limit: parseInt(document.getElementById('rb-limit').value),
}), credentials: 'same-origin'
}).then(r => r.json()).then(data => {
lastResults = data.rows || [];
document.getElementById('rb-results').style.display = 'block';
document.getElementById('rb-count').textContent = '(' + data.count + ' rows)';
const table = document.getElementById('rb-results-table');
if (lastResults.length === 0) { table.innerHTML = '<tr><td>No results.</td></tr>'; return; }
const headers = Object.keys(lastResults[0]);
table.innerHTML = '<thead><tr>' + headers.map(h => '<th>' + h + '</th>').join('') + '</tr></thead><tbody>' +
lastResults.map(row => '<tr>' + headers.map(h => '<td>' + (row[h] ?? '') + '</td>').join('') + '</tr>').join('') + '</tbody>';
});
}
function exportResults() {
if (!lastResults.length) return;
const headers = Object.keys(lastResults[0]);
let csv = headers.join(',') + '\n';
lastResults.forEach(row => { csv += headers.map(h => '"' + String(row[h] ?? '').replace(/"/g, '""') + '"').join(',') + '\n'; });
const blob = new Blob([csv], {type: 'text/csv'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'report_' + new Date().toISOString().slice(0, 10) + '.csv';
a.click();
}
</script>
\ No newline at end of file
<?php /** @var array $user */ /** @var array $api_keys */ ?> <?php /** @var array $user */ /** @var array $api_keys */ ?>
<div class="api-keys-page"> <div class="dashboard-header"><h1>🔑 API Keys</h1></div>
<div class="page-header"> <div class="card">
<h1>🗝️ API Keys</h1> <?php if (empty($api_keys)): ?>
<button onclick="document.getElementById('create-key-modal').style.display='flex'" class="btn btn-primary">+ Generate API Key</button> <div class="empty-state"><div class="empty-state-icon">🔑</div><div>No API keys</div></div>
</div> <?php else: ?>
<table class="data-table"><thead><tr><th>Name</th><th>Prefix</th><th>Scope</th><th>Last Used</th><th>Status</th></tr></thead><tbody>
<table class="data-table"> <?php foreach ($api_keys as $k): ?>
<thead><tr><th>Name</th><th>Prefix</th><th>Scope</th><th>Rate Limit</th><th>Last Used</th><th>Status</th><th>Created</th><th>Actions</th></tr></thead> <tr><td><?= htmlspecialchars($k['name']) ?></td><td class="mono"><?= $k['key_prefix'] ?>...</td><td><?= $k['scope'] ?></td><td><?= $k['last_used_at'] ?? 'Never' ?></td><td><?= $k['revoked_at'] ? '❌ Revoked' : '✅ Active' ?></td></tr>
<tbody> <?php endforeach; ?>
<?php foreach ($api_keys as $k): ?> </tbody></table>
<tr> <?php endif; ?>
<td><?= htmlspecialchars($k['name']) ?></td> </div>
<td><code><?= $k['key_prefix'] ?>...</code></td> \ No newline at end of file
<td><span class="scope-badge scope-<?= $k['scope'] ?>"><?= $k['scope'] ?></span></td>
<td><?= number_format($k['rate_limit_per_hour']) ?>/hr</td>
<td><?= $k['last_used_at'] ?? 'Never' ?></td>
<td><?= $k['revoked_at'] ? '🔴 Revoked' : '🟢 Active' ?></td>
<td><?= $k['created_at'] ?></td>
<td>
<?php if (!$k['revoked_at']): ?>
<button onclick="revokeKey(<?= $k['id'] ?>)" class="btn btn-xs btn-warning">Revoke</button>
<?php endif; ?>
<button onclick="deleteKey(<?= $k['id'] ?>)" class="btn btn-xs btn-danger">Delete</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div id="create-key-modal" class="modal-overlay" style="display:none">
<div class="modal-content">
<h3>Generate API Key</h3>
<form onsubmit="createKey(event)">
<div class="form-group"><label>Name</label><input type="text" name="name" required class="form-control" placeholder="My Integration"></div>
<div class="form-group"><label>Scope</label>
<select name="scope" class="form-control">
<option value="read_only">Read Only</option>
<option value="read_write">Read/Write</option>
<option value="admin">Admin</option>
</select>
</div>
<div class="form-group"><label>Rate Limit (per hour)</label><input type="number" name="rate_limit" value="1000" class="form-control"></div>
<button type="submit" class="btn btn-primary">Generate</button>
<button type="button" onclick="this.closest('.modal-overlay').style.display='none'" class="btn btn-secondary">Cancel</button>
</form>
</div>
</div>
<script>
function createKey(e) {
e.preventDefault();
const form = e.target;
fetch('/api-keys', {
method: 'POST', headers: {'Content-Type':'application/json','X-CSRF-Token': document.querySelector('meta[name=csrf-token]')?.content||''},
body: JSON.stringify({name: form.name.value, scope: form.scope.value, rate_limit_per_hour: parseInt(form.rate_limit.value)}),
credentials: 'same-origin'
}).then(r => r.json()).then(data => {
if (data.api_key) {
prompt('Your API Key (COPY NOW — shown once only):', data.api_key);
}
location.reload();
});
}
function revokeKey(id) {
if (!confirm('Revoke this API key? It will stop working immediately.')) return;
fetch('/api-keys/' + id + '/revoke', {method:'POST', headers:{'X-CSRF-Token': document.querySelector('meta[name=csrf-token]')?.content||''}, credentials:'same-origin'}).then(() => location.reload());
}
function deleteKey(id) {
if (!confirm('Permanently delete this API key record?')) return;
fetch('/api-keys/' + id, {method:'DELETE', headers:{'X-CSRF-Token': document.querySelector('meta[name=csrf-token]')?.content||''}, credentials:'same-origin'}).then(() => location.reload());
}
</script>
\ No newline at end of file
<?php /** @var array $user */ /** @var array $entries */ /** @var int $total */ /** @var int $page */ /** @var int $last_page */ /** @var array $actions */ /** @var array $entity_types */ /** @var array $modules */ ?> <?php /** @var array $user */ /** @var array $entries */ /** @var int $total */ /** @var int $page */ /** @var int $last_page */ ?>
<div class="audit-trail"> <div class="dashboard-header"><h1>📜 Audit Trail</h1><span class="stat-sub"><?= number_format($total) ?> entries</span></div>
<h1>🔍 Audit Trail</h1> <div class="card">
<p class="subtitle"><?= number_format($total) ?> total entries — Immutable. Append-only. Forever.</p> <table class="data-table"><thead><tr><th>Time</th><th>User</th><th>Action</th><th>Entity</th><th>Module</th><th>IP</th></tr></thead><tbody>
<?php foreach ($entries as $e): ?>
<form method="get" class="audit-filters"> <tr><td class="mono" style="font-size:0.75rem"><?= $e['created_at'] ?></td><td><?= htmlspecialchars($e['username'] ?? 'system') ?></td><td><span class="badge badge-primary"><?= $e['action'] ?></span></td><td><?= $e['entity_type'] ?><?= $e['entity_id'] ? '#'.$e['entity_id'] : '' ?></td><td><?= $e['module'] ?></td><td class="mono" style="font-size:0.75rem"><?= $e['ip_address'] ?></td></tr>
<input type="text" name="search" value="<?= htmlspecialchars($_GET['search'] ?? '') ?>" placeholder="Search..." class="form-control"> <?php endforeach; ?>
<select name="action" class="form-control"> </tbody></table>
<option value="">All Actions</option> <?php if ($last_page > 1): ?><div class="pagination"><?php for($i=max(1,$page-3);$i<=min($last_page,$page+3);$i++): ?><a href="/audit-trail?page=<?=$i?>" class="<?=$i===$page?'active':''?>"><?=$i?></a><?php endfor; ?></div><?php endif; ?>
<?php foreach ($actions as $a): ?><option value="<?= $a ?>" <?= ($_GET['action'] ?? '') === $a ? 'selected' : '' ?>><?= $a ?></option><?php endforeach; ?>
</select>
<select name="entity_type" class="form-control">
<option value="">All Entities</option>
<?php foreach ($entity_types as $et): ?><option value="<?= $et ?>" <?= ($_GET['entity_type'] ?? '') === $et ? 'selected' : '' ?>><?= $et ?></option><?php endforeach; ?>
</select>
<select name="module" class="form-control">
<option value="">All Modules</option>
<?php foreach ($modules as $m): ?><option value="<?= $m ?>" <?= ($_GET['module'] ?? '') === $m ? 'selected' : '' ?>><?= $m ?></option><?php endforeach; ?>
</select>
<input type="date" name="date_from" value="<?= $_GET['date_from'] ?? '' ?>" class="form-control">
<input type="date" name="date_to" value="<?= $_GET['date_to'] ?? '' ?>" class="form-control">
<button type="submit" class="btn btn-primary">Filter</button>
<a href="/audit-trail" class="btn btn-secondary">Clear</a>
</form>
<div class="table-responsive">
<table class="data-table audit-table">
<thead>
<tr><th>ID</th><th>Time</th><th>User</th><th>Role</th><th>Action</th><th>Entity</th><th>ID</th><th>Module</th><th>IP</th></tr>
</thead>
<tbody>
<?php foreach ($entries as $e): ?>
<tr>
<td><?= $e['id'] ?></td>
<td class="nowrap"><?= $e['created_at'] ?></td>
<td><?= htmlspecialchars($e['username'] ?? 'system') ?></td>
<td><?= $e['user_role'] ?? '-' ?></td>
<td><span class="action-badge"><?= htmlspecialchars($e['action']) ?></span></td>
<td><?= htmlspecialchars($e['entity_type']) ?></td>
<td><?= $e['entity_id'] ?? '-' ?></td>
<td><?= htmlspecialchars($e['module']) ?></td>
<td class="nowrap"><?= $e['ip_address'] ?? '-' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php if ($last_page > 1): ?>
<div class="pagination">
<?php if ($page > 1): ?><a href="?<?= http_build_query(array_merge($_GET, ['page' => $page - 1])) ?>" class="btn btn-secondary">← Prev</a><?php endif; ?>
<span>Page <?= $page ?> / <?= $last_page ?></span>
<?php if ($page < $last_page): ?><a href="?<?= http_build_query(array_merge($_GET, ['page' => $page + 1])) ?>" class="btn btn-secondary">Next →</a><?php endif; ?>
</div>
<?php endif; ?>
<div class="export-actions">
<form method="post" action="/export/audit-trail" style="display:inline">
<input type="hidden" name="date_from" value="<?= $_GET['date_from'] ?? date('Y-m-01') ?>">
<input type="hidden" name="date_to" value="<?= $_GET['date_to'] ?? date('Y-m-d') ?>">
<input type="hidden" name="format" value="csv">
<button type="submit" class="btn btn-secondary">📥 Export CSV</button>
</form>
<form method="post" action="/export/audit-trail" style="display:inline">
<input type="hidden" name="date_from" value="<?= $_GET['date_from'] ?? date('Y-m-01') ?>">
<input type="hidden" name="date_to" value="<?= $_GET['date_to'] ?? date('Y-m-d') ?>">
<input type="hidden" name="format" value="json">
<button type="submit" class="btn btn-secondary">📥 Export JSON</button>
</form>
</div>
</div> </div>
\ No newline at end of file
<?php $__engine->extend('layouts/app'); ?> <?php /** @var array $user */ /** @var string|null $error */ /** @var bool $forced */ ?>
<div class="auth-box">
<?php $__engine->section('title'); ?>Change Password<?php $__engine->endSection(); ?> <h1 class="auth-title">🔒 Change Password</h1>
<?php if ($forced): ?><div class="auth-error">You must change your password before continuing.</div><?php endif; ?>
<div class="container"> <?php if ($error): ?><div class="auth-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<div class="card"> <form method="POST" action="/password/change">
<h2>Change Password</h2> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token'] ?? '') ?>">
<?php if (!empty($forced)): ?> <div class="form-group"><label class="form-label">Current Password</label><input type="password" name="current_password" class="form-control" required></div>
<div class="alert alert-warning">You must change your password before continuing.</div> <div class="form-group"><label class="form-label">New Password</label><input type="password" name="new_password" class="form-control" required minlength="10"><div class="form-hint">Min 10 chars, uppercase, lowercase, number</div></div>
<?php endif; ?> <div class="form-group"><label class="form-label">Confirm New Password</label><input type="password" name="confirm_password" class="form-control" required></div>
<?php if (!empty($error)): ?> <button type="submit" class="btn btn-primary btn-block btn-lg">Change Password</button>
<div class="alert alert-error"><?= $__engine->e($error) ?></div> </form>
<?php endif; ?>
<form method="POST" action="/password/change">
<input type="hidden" name="_csrf_token" value="<?= $__engine->e($_COOKIE['csrf_token'] ?? '') ?>">
<div class="form-group">
<label>Current Password</label>
<input type="password" name="current_password" required>
</div>
<div class="form-group">
<label>New Password (min 10 chars, 1 upper, 1 lower, 1 number, 1 special)</label>
<input type="password" name="new_password" required minlength="10">
</div>
<div class="form-group">
<label>Confirm New Password</label>
<input type="password" name="confirm_password" required>
</div>
<button type="submit" class="btn btn-primary">Change Password</button>
</form>
</div>
</div> </div>
\ No newline at end of file
<?php $__engine->extend('layouts/app'); ?> <?php /** @var array $user */ /** @var array $boards */ ?>
<?php $__engine->section('title'); ?>Boards<?php $__engine->endSection(); ?>
<div class="container"> <div class="dashboard-header">
<div class="page-header"> <h1>📋 Boards</h1>
<h1>📋 Boards</h1> <?php if (in_array($user['role'], ['super_admin','admin'])): ?>
<?php if (in_array($user['role'], ['super_admin', 'admin'])): ?> <button class="btn btn-primary" onclick="document.getElementById('create-board-modal').style.display='flex'">+ New Board</button>
<button class="btn btn-primary" onclick="document.getElementById('create-board-modal').style.display='block'">+ New Board</button> <?php endif; ?>
<?php endif; ?> </div>
</div>
<div class="board-grid"> <div class="grid grid-3">
<?php foreach ($boards as $board): ?> <?php foreach ($boards as $b): ?>
<a href="/boards/<?= $board['id'] ?>" class="card board-card"> <a href="/boards/<?= $b['id'] ?>" class="card" style="text-decoration:none;">
<h3><?= $__engine->e($board['name']) ?></h3> <h3><?= htmlspecialchars($b['name']) ?></h3>
<p class="text-muted"><?= $__engine->e($board['description'] ?? '') ?></p> <div class="mono" style="color:var(--text-muted);font-size:0.8rem;"><?= htmlspecialchars($b['board_key']) ?></div>
<div class="board-meta"> <div style="margin-top:12px;display:flex;gap:16px;font-size:0.85rem;color:var(--text-secondary);">
<span>🔑 <?= $__engine->e($board['board_key']) ?></span> <span>👥 <?= $b['member_count'] ?? 0 ?></span>
<span>👥 <?= $board['member_count'] ?> members</span> <span>🃏 <?= $b['card_count'] ?? 0 ?></span>
<span>🃏 <?= $board['card_count'] ?> cards</span>
</div> </div>
</a> </a>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
</div>
\ No newline at end of file <?php if (empty($boards)): ?>
<div class="empty-state"><div class="empty-state-icon">📋</div><div class="empty-state-text">No boards yet</div></div>
<?php endif; ?>
\ No newline at end of file
<?php $__engine->extend('layouts/app'); ?> <?php /** @var array $user */ /** @var array $board */ /** @var array $columns */ /** @var array $cards_by_column */ /** @var array $members */ /** @var array $labels */ ?>
<?php $__engine->section('title'); ?><?= $__engine->e($board['name']) ?><?php $__engine->endSection(); ?>
<div class="board-page"> <div class="dashboard-header">
<div class="board-header"> <h1><?= htmlspecialchars($board['name']) ?></h1>
<h1><?= $__engine->e($board['name']) ?></h1> <div class="flex gap-1">
<span class="board-key"><?= $__engine->e($board['board_key']) ?></span> <span class="badge badge-muted"><?= htmlspecialchars($board['board_key']) ?></span>
<span class="text-muted"><?= count($members) ?> members</span> <span class="badge badge-info"><?= count($members) ?> members</span>
</div> </div>
</div>
<div class="kanban-board" id="kanban"> <div style="display:flex;gap:16px;overflow-x:auto;padding-bottom:20px;min-height:60vh;">
<?php foreach ($columns as $column): ?> <?php foreach ($columns as $col): ?>
<div class="kanban-column" data-column-id="<?= $column['id'] ?>" data-slug="<?= $__engine->e($column['slug']) ?>"> <div style="min-width:280px;max-width:320px;flex-shrink:0;">
<div class="column-header"> <div style="background:var(--bg-tertiary);padding:10px 14px;border-radius:var(--radius) var(--radius) 0 0;font-weight:600;font-size:0.9rem;display:flex;justify-content:space-between;align-items:center;">
<span class="column-icon"><?= $__engine->e($column['icon'] ?? '') ?></span> <span><?= $col['icon'] ?? '' ?> <?= htmlspecialchars($col['name']) ?></span>
<span class="column-name"><?= $__engine->e($column['name']) ?></span> <span class="badge badge-muted"><?= count($cards_by_column[$col['id']] ?? []) ?></span>
<span class="column-count"><?= count($cards_by_column[$column['id']] ?? []) ?></span>
</div> </div>
<div class="column-cards" data-column-id="<?= $column['id'] ?>"> <div style="background:var(--bg-secondary);border:1px solid var(--border-color);border-top:none;border-radius:0 0 var(--radius) var(--radius);padding:8px;min-height:200px;">
<?php foreach (($cards_by_column[$column['id']] ?? []) as $card): ?> <?php foreach (($cards_by_column[$col['id']] ?? []) as $card): ?>
<div class="kanban-card" data-card-id="<?= $card['id'] ?>" onclick="window.location='/cards/<?= $card['id'] ?>'"> <a href="/cards/<?= $card['id'] ?>" style="display:block;background:var(--bg-card);border:1px solid var(--border-color);border-radius:var(--radius);padding:10px;margin-bottom:8px;text-decoration:none;color:inherit;transition:var(--transition);" onmouseover="this.style.borderColor='var(--accent-primary)'" onmouseout="this.style.borderColor='var(--border-color)'">
<?php if (!empty($card['labels'])): ?> <div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<div class="card-labels"> <span class="priority-dot priority-<?= $card['priority'] ?? 'none' ?>"></span>
<?php foreach (array_slice($card['labels'], 0, 3) as $label): ?> <span class="mono" style="font-size:0.75rem;color:var(--text-muted)"><?= htmlspecialchars($card['card_key']) ?></span>
<span class="label-pill" style="background:<?= $__engine->e($label['bg_color']) ?>;color:<?= $__engine->e($label['text_color']) ?>"><?= $__engine->e($label['text']) ?></span> <?php if ($card['bounty_amount']): ?><span class="badge badge-warning" style="font-size:0.65rem;">💰 <?= number_format($card['bounty_amount'],0) ?></span><?php endif; ?>
<?php endforeach; ?> </div>
</div> <div style="font-size:0.85rem;"><?= htmlspecialchars($card['title']) ?></div>
<?php endif; ?>
<div class="card-title"><?= $__engine->e($card['title']) ?></div>
<div class="card-key"><?= $__engine->e($card['card_key']) ?></div>
<div class="card-meta">
<?php if ($card['deadline']): ?> <?php if ($card['deadline']): ?>
<span class="<?= strtotime($card['deadline']) < time() && !$card['done_at'] ? 'overdue' : '' ?>"> <div style="font-size:0.72rem;color:<?= strtotime($card['deadline']) < time() ? 'var(--danger)' : 'var(--text-muted)' ?>;margin-top:4px;"><?= date('M j', strtotime($card['deadline'])) ?></div>
<?= date('M j', strtotime($card['deadline'])) ?>
</span>
<?php endif; ?> <?php endif; ?>
<?php if ($card['bounty_amount']): ?> </a>
<span class="bounty-badge">💰 <?= number_format($card['bounty_amount'], 0) ?></span>
<?php endif; ?>
</div>
<?php if (!empty($card['assignee_ids'])): ?>
<div class="card-assignees">
<span class="assignee-count">👤 <?= count($card['assignee_ids']) ?></span>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div>
</div> </div>
\ No newline at end of file
<?php $__engine->extend('layouts/app'); ?> <?php /** @var array $user */ /** @var array $card */ /** @var array $board */ /** @var array $column */ /** @var array $columns */ ?>
<?php $__engine->section('title'); ?><?= $__engine->e($card['card_key']) ?><?= $__engine->e($card['title']) ?><?php $__engine->endSection(); ?>
<div class="card-detail"> <div class="dashboard-header">
<div class="card-detail-header"> <h1><span class="mono text-primary"><?= htmlspecialchars($card['card_key']) ?></span> <?= htmlspecialchars($card['title']) ?></h1>
<a href="/boards/<?= $card['board_id'] ?>" class="btn btn-sm btn-secondary"><?= $__engine->e($board['name']) ?></a> <div class="flex gap-1">
<h1><?= $__engine->e($card['card_key']) ?>: <?= $__engine->e($card['title']) ?></h1> <span class="badge badge-muted"><?= $column['name'] ?? '' ?></span>
<span class="badge badge-<?= $__engine->e($column['slug']) ?>"><?= $__engine->e($column['name']) ?></span> <span class="badge badge-<?= $card['priority']==='critical'?'danger':($card['priority']==='high'?'warning':'info') ?>"><?= ucfirst($card['priority']) ?></span>
</div> </div>
</div>
<div class="card-detail-body"> <div class="grid grid-2">
<div class="card-main"> <div>
<?php if ($card['description']): ?> <div class="card mb-3">
<div class="card-description"> <h3 class="mb-2">Description</h3>
<h3>Description</h3> <div style="color:var(--text-secondary)"><?= $card['description'] ? nl2br(htmlspecialchars($card['description'])) : '<em>No description</em>' ?></div>
<div class="rich-text"><?= $card['description'] ?></div> </div>
</div>
<?php endif; ?>
<?php foreach ($card['checklists'] as $cl): ?> <!-- Checklists -->
<div class="checklist"> <?php foreach ($card['checklists'] ?? [] as $cl): ?>
<h3>☑️ <?= $__engine->e($cl['name']) ?></h3> <div class="card mb-3">
<?php $total = count($cl['items']); $done = count(array_filter($cl['items'], fn($i) => $i['is_checked'])); ?> <h3 class="mb-1"><?= htmlspecialchars($cl['name']) ?></h3>
<div class="progress-bar"><div style="width:<?= $total > 0 ? round(($done/$total)*100) : 0 ?>%"></div></div> <?php foreach ($cl['items'] ?? [] as $item): ?>
<p class="text-muted"><?= $done ?>/<?= $total ?> complete</p> <div class="task-item">
<?php foreach ($cl['items'] as $item): ?> <span style="font-size:1.2rem;"><?= $item['is_checked'] ? '✅' : '⬜' ?></span>
<label class="checklist-item"> <span style="<?= $item['is_checked'] ? 'text-decoration:line-through;color:var(--text-muted)' : '' ?>"><?= htmlspecialchars($item['text']) ?></span>
<input type="checkbox" <?= $item['is_checked'] ? 'checked' : '' ?> </div>
onchange="fetch('/cards/<?= $card['id'] ?>/checklist-items/<?= $item['id'] ?>/toggle',{method:'POST',headers:{'X-CSRF-Token':getCsrfToken(),'Accept':'application/json'}})">
<span class="<?= $item['is_checked'] ? 'checked-text' : '' ?>"><?= $__engine->e($item['text']) ?></span>
</label>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
<div class="card-comments"> <!-- Comments -->
<h3>💬 Comments & Activity</h3> <div class="card">
<?php foreach ($card['comments'] as $comment): ?> <h3 class="mb-2">💬 Comments (<?= count($card['comments'] ?? []) ?>)</h3>
<div class="comment"> <?php foreach ($card['comments'] ?? [] as $comment): ?>
<strong><?= $__engine->e($comment['author_name']) ?></strong> <div style="border-bottom:1px solid var(--border-color);padding:10px 0;">
<span class="text-muted"><?= date('M j, H:i', strtotime($comment['created_at'])) ?></span> <div class="flex justify-between"><strong><?= htmlspecialchars($comment['author_name']) ?></strong><span class="task-meta"><?= date('M j g:ia', strtotime($comment['created_at'])) ?></span></div>
<?php if ($comment['edited_at']): ?><span class="text-muted">(edited)</span><?php endif; ?> <div style="margin-top:4px;color:var(--text-secondary)"><?= nl2br(htmlspecialchars($comment['content'])) ?></div>
<div class="comment-content"><?= nl2br($__engine->e($comment['content'])) ?></div>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div>
</div>
<form method="POST" action="/cards/<?= $card['id'] ?>/comments" class="comment-form"> <div>
<input type="hidden" name="_csrf_token" value="<?= $__engine->e($_COOKIE['csrf_token'] ?? '') ?>"> <div class="card mb-3">
<textarea name="content" placeholder="Add a comment..." rows="3" required></textarea> <h3 class="mb-2">Details</h3>
<button type="submit" class="btn btn-primary btn-sm">Comment</button> <div class="stat-row"><span>Board</span><strong><a href="/boards/<?= $board['id'] ?>"><?= htmlspecialchars($board['name']) ?></a></strong></div>
</form> <div class="stat-row"><span>Column</span><strong><?= htmlspecialchars($column['name'] ?? '') ?></strong></div>
</div> <?php if ($card['bounty_amount']): ?><div class="stat-row"><span>💰 Bounty</span><strong class="text-warning"><?= number_format($card['bounty_amount'], 2) ?> EGP</strong></div><?php endif; ?>
<?php if ($card['deadline']): ?><div class="stat-row"><span>⏰ Deadline</span><strong><?= date('M j, Y g:ia', strtotime($card['deadline'])) ?></strong></div><?php endif; ?>
<?php if ($card['estimated_hours']): ?><div class="stat-row"><span>Estimated</span><strong><?= $card['estimated_hours'] ?>h</strong></div><?php endif; ?>
<?php if ($card['done_at']): ?><div class="stat-row"><span>✅ Done</span><strong><?= date('M j, Y', strtotime($card['done_at'])) ?></strong></div><?php endif; ?>
</div> </div>
<div class="card-sidebar"> <div class="card mb-3">
<div class="sidebar-section"> <h3 class="mb-2">👥 Assignees</h3>
<h4>Assignees</h4> <?php foreach ($card['assignees'] ?? [] as $a): ?>
<?php foreach ($card['assignees'] as $a): ?> <div class="task-item"><a href="/users/<?= $a['id'] ?>"><?= htmlspecialchars($a['full_name_en']) ?></a></div>
<div class="assignee-row"><?= $__engine->e($a['full_name_en']) ?></div> <?php endforeach; ?>
<?php endforeach; ?> <?php if (empty($card['assignees'])): ?><div class="text-muted">No assignees</div><?php endif; ?>
</div> </div>
<div class="sidebar-section">
<h4>Labels</h4> <div class="card mb-3">
<?php foreach ($card['labels'] as $l): ?> <h3 class="mb-2">🏷️ Labels</h3>
<span class="label-pill" style="background:<?= $__engine->e($l['bg_color']) ?>;color:<?= $__engine->e($l['text_color']) ?>"><?= $__engine->e($l['text']) ?></span> <div class="flex flex-wrap gap-1">
<?php foreach ($card['labels'] ?? [] as $l): ?>
<span class="badge" style="background:<?= $l['bg_color'] ?>;color:<?= $l['text_color'] ?>"><?= htmlspecialchars($l['text']) ?></span>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php if ($card['deadline']): ?>
<div class="sidebar-section">
<h4>⏰ Deadline</h4>
<p class="<?= strtotime($card['deadline']) < time() && !$card['done_at'] ? 'overdue' : '' ?>">
<?= date('M j, Y H:i', strtotime($card['deadline'])) ?>
</p>
</div>
<?php endif; ?>
<?php if ($card['bounty_amount']): ?>
<div class="sidebar-section">
<h4>💰 Bounty</h4>
<p class="bounty-amount"><?= number_format($card['bounty_amount'], 2) ?> EGP</p>
</div>
<?php endif; ?>
<?php if ($card['priority'] !== 'none'): ?>
<div class="sidebar-section">
<h4>Priority</h4>
<p><?= ucfirst($card['priority']) ?></p>
</div>
<?php endif; ?>
<div class="sidebar-section">
<h4>Info</h4>
<p>Created: <?= date('M j, Y', strtotime($card['created_at'])) ?></p>
<?php if ($card['done_at']): ?>
<p>Completed: <?= date('M j, Y', strtotime($card['done_at'])) ?></p>
<?php endif; ?>
</div>
</div> </div>
</div> </div>
</div> </div>
\ No newline at end of file
<?php /** @var string $entity */ /** @var array $config */ /** @var array $rows */ /** @var int $total */ /** @var int $page */ /** @var int $last_page */ /** @var array $user */ ?> <?php /** @var array $user */ /** @var string $entity */ /** @var array $config */ /** @var array $rows */ /** @var int $total */ /** @var int $page */ /** @var int $last_page */ ?>
<div class="entity-manager"> <div class="dashboard-header"><h1>⚙️ <?= $config['label'] ?? ucfirst($entity) ?></h1><a href="/control-panel" class="btn btn-ghost btn-sm">← Back</a></div>
<div class="page-header"> <div class="card mb-2">
<h1><?= htmlspecialchars($config['label']) ?> Management</h1> <form method="GET" style="display:flex;gap:8px;"><input type="text" name="search" class="form-control" style="max-width:300px" placeholder="Search..." value="<?= htmlspecialchars($search ?? '') ?>"><button class="btn btn-primary btn-sm">Search</button></form>
<span class="badge"><?= number_format($total) ?> records</span>
<a href="/control-panel" class="btn btn-secondary">← Back to Control Panel</a>
</div>
<div class="toolbar">
<form method="get" class="search-form">
<input type="text" name="search" value="<?= htmlspecialchars($search ?? '') ?>" placeholder="Search..." class="input-search">
<button type="submit" class="btn btn-primary">Search</button>
</form>
<div class="toolbar-actions">
<a href="/export/csv" onclick="exportEntity('<?= $entity ?>')" class="btn btn-secondary">📥 Export CSV</a>
</div>
</div>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th><input type="checkbox" id="select-all" onchange="toggleSelectAll(this)"></th>
<?php foreach ($config['columns'] as $col): ?>
<th>
<a href="?sort=<?= $col ?>&dir=<?= ($sort === $col && $dir === 'ASC') ? 'DESC' : 'ASC' ?>&search=<?= urlencode($search ?? '') ?>">
<?= htmlspecialchars($col) ?>
<?php if ($sort === $col): ?><?= $dir === 'ASC' ? '▲' : '▼' ?><?php endif; ?>
</a>
</th>
<?php endforeach; ?>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($rows)): ?>
<tr><td colspan="<?= count($config['columns']) + 2 ?>" class="text-center text-muted">No records found.</td></tr>
<?php else: ?>
<?php foreach ($rows as $row): ?>
<tr data-id="<?= $row['id'] ?>">
<td><input type="checkbox" class="row-checkbox" value="<?= $row['id'] ?>"></td>
<?php foreach ($config['columns'] as $col): ?>
<td><?= htmlspecialchars((string)($row[$col] ?? '')) ?></td>
<?php endforeach; ?>
<td class="actions-cell">
<a href="/control-panel/<?= $entity ?>/<?= $row['id'] ?>" class="btn btn-xs btn-info">View</a>
<?php if (!empty($config['editable'])): ?>
<button onclick="editEntity('<?= $entity ?>', <?= $row['id'] ?>)" class="btn btn-xs btn-warning">Edit</button>
<?php endif; ?>
<button onclick="deleteEntity('<?= $entity ?>', <?= $row['id'] ?>)" class="btn btn-xs btn-danger">Delete</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if ($last_page > 1): ?>
<div class="pagination">
<?php if ($page > 1): ?>
<a href="?page=<?= $page - 1 ?>&sort=<?= $sort ?>&dir=<?= $dir ?>&search=<?= urlencode($search ?? '') ?>" class="btn btn-secondary">← Previous</a>
<?php endif; ?>
<span class="page-info">Page <?= $page ?> of <?= $last_page ?></span>
<?php if ($page < $last_page): ?>
<a href="?page=<?= $page + 1 ?>&sort=<?= $sort ?>&dir=<?= $dir ?>&search=<?= urlencode($search ?? '') ?>" class="btn btn-secondary">Next →</a>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="bulk-actions" id="bulk-actions" style="display:none">
<span id="selected-count">0</span> selected —
<button onclick="bulkDelete('<?= $entity ?>')" class="btn btn-danger btn-sm">Delete Selected</button>
</div>
</div> </div>
<div class="card">
<script> <table class="data-table"><thead><tr><?php foreach ($config['columns'] as $col): ?><th><?= $col ?></th><?php endforeach; ?></tr></thead><tbody>
function toggleSelectAll(el) { <?php foreach ($rows as $row): ?><tr><?php foreach ($config['columns'] as $col): ?><td><?= htmlspecialchars((string)($row[$col] ?? '')) ?></td><?php endforeach; ?></tr><?php endforeach; ?>
document.querySelectorAll('.row-checkbox').forEach(cb => cb.checked = el.checked); </tbody></table>
updateBulkBar(); <?php if ($last_page > 1): ?><div class="pagination"><?php for($i=1;$i<=$last_page;$i++): ?><a href="/control-panel/<?=$entity?>?page=<?=$i?>&search=<?=urlencode($search??'')?>" class="<?=$i===$page?'active':''?>"><?=$i?></a><?php endfor; ?></div><?php endif; ?>
} </div>
document.querySelectorAll('.row-checkbox').forEach(cb => cb.addEventListener('change', updateBulkBar)); \ No newline at end of file
function updateBulkBar() {
const checked = document.querySelectorAll('.row-checkbox:checked');
const bar = document.getElementById('bulk-actions');
document.getElementById('selected-count').textContent = checked.length;
bar.style.display = checked.length > 0 ? 'block' : 'none';
}
function editEntity(entity, id) {
fetch('/control-panel/' + entity + '/' + id).then(r => r.json()).then(data => {
const fields = data.editable_fields || [];
const record = data.record || {};
let html = '<form id="edit-form">';
fields.forEach(f => {
html += '<div class="form-group"><label>' + f + '</label><input name="' + f + '" value="' + (record[f] || '') + '" class="form-control"></div>';
});
html += '<button type="submit" class="btn btn-primary">Save</button></form>';
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.innerHTML = '<div class="modal-content"><h3>Edit ' + entity + ' #' + id + '</h3>' + html + '<button onclick="this.closest(\'.modal-overlay\').remove()" class="btn btn-secondary mt-2">Cancel</button></div>';
document.body.appendChild(modal);
modal.querySelector('form').onsubmit = function(e) {
e.preventDefault();
const formData = Object.fromEntries(new FormData(this));
fetch('/control-panel/' + entity + '/' + id, {
method: 'PUT', headers: {'Content-Type':'application/json','X-CSRF-Token': document.querySelector('meta[name=csrf-token]')?.content||''},
body: JSON.stringify(formData), credentials: 'same-origin'
}).then(r => r.json()).then(() => { modal.remove(); location.reload(); });
};
});
}
function deleteEntity(entity, id) {
if (!confirm('Are you sure you want to delete this record?')) return;
fetch('/control-panel/' + entity + '/' + id, {
method: 'DELETE', headers: {'Content-Type':'application/json','X-CSRF-Token': document.querySelector('meta[name=csrf-token]')?.content||''},
credentials: 'same-origin'
}).then(r => r.json()).then(() => location.reload());
}
function bulkDelete(entity) {
const ids = Array.from(document.querySelectorAll('.row-checkbox:checked')).map(cb => parseInt(cb.value));
if (!confirm('Delete ' + ids.length + ' records? This cannot be undone.')) return;
fetch('/control-panel/' + entity + '/bulk-delete', {
method: 'POST', headers: {'Content-Type':'application/json','X-CSRF-Token': document.querySelector('meta[name=csrf-token]')?.content||''},
body: JSON.stringify({ids}), credentials: 'same-origin'
}).then(r => r.json()).then(() => location.reload());
}
</script>
\ No newline at end of file
<?php /** @var array $user */ /** @var array $counts */ ?> <?php /** @var array $user */ /** @var array $counts */ ?>
<div class="control-panel"> <div class="dashboard-header"><h1>⚙️ Control Panel</h1></div>
<div class="page-header"> <div class="grid grid-4">
<h1>🎮 Super Admin Control Panel</h1> <?php foreach ($counts as $entity => $count): ?>
<p class="subtitle">God mode. Every entity. Full CRUD. No limits.</p> <a href="/control-panel/<?= $entity ?>" class="stat-card stat-primary" style="text-decoration:none;">
</div> <span class="stat-label"><?= ucfirst(str_replace('_',' ',$entity)) ?></span>
<span class="stat-value"><?= number_format($count) ?></span>
<div class="entity-grid"> </a>
<?php <?php endforeach; ?>
$entities = [
['key' => 'users', 'icon' => '👥', 'label' => 'Users', 'count' => $counts['users']],
['key' => 'boards', 'icon' => '📋', 'label' => 'Boards', 'count' => $counts['boards']],
['key' => 'cards', 'icon' => '🃏', 'label' => 'Cards', 'count' => $counts['cards']],
['key' => 'deductions', 'icon' => '💸', 'label' => 'Deductions', 'count' => $counts['deductions']],
['key' => 'daily_reports', 'icon' => '📝', 'label' => 'Reports', 'count' => $counts['reports']],
['key' => 'payroll_records', 'icon' => '💰', 'label' => 'Payroll', 'count' => $counts['payroll']],
['key' => 'evaluations', 'icon' => '⭐', 'label' => 'Evaluations', 'count' => $counts['evaluations']],
['key' => 'pips', 'icon' => '📊', 'label' => 'PIPs', 'count' => $counts['pips']],
['key' => 'meetings', 'icon' => '📅', 'label' => 'Meetings', 'count' => $counts['meetings']],
['key' => 'holidays', 'icon' => '🏖️', 'label' => 'Holidays', 'count' => $counts['holidays']],
['key' => 'notices', 'icon' => '📢', 'label' => 'Notices', 'count' => $counts['notices']],
['key' => 'policies', 'icon' => '📜', 'label' => 'Policies', 'count' => $counts['policies']],
['key' => 'contracts', 'icon' => '✍️', 'label' => 'Contracts', 'count' => $counts['contracts']],
['key' => 'invites', 'icon' => '✉️', 'label' => 'Invites', 'count' => 0],
['key' => 'learning_goals', 'icon' => '🎯', 'label' => 'Learning Goals', 'count' => 0],
['key' => 'manual_adjustments', 'icon' => '🔧', 'label' => 'Adjustments', 'count' => 0],
];
foreach ($entities as $e): ?>
<a href="/control-panel/<?= $e['key'] ?>" class="entity-card">
<span class="entity-icon"><?= $e['icon'] ?></span>
<span class="entity-label"><?= htmlspecialchars($e['label']) ?></span>
<span class="entity-count"><?= number_format($e['count']) ?></span>
</a>
<?php endforeach; ?>
</div>
<div class="quick-links">
<h2>Quick Access</h2>
<div class="link-grid">
<a href="/analytics" class="quick-link">📊 Analytics Dashboard</a>
<a href="/analytics/report-builder" class="quick-link">🔨 Report Builder</a>
<a href="/audit-trail" class="quick-link">🔍 Audit Trail</a>
<a href="/system-health" class="quick-link">🏥 System Health</a>
<a href="/session-management/sessions" class="quick-link">🔑 Active Sessions</a>
<a href="/api-keys" class="quick-link">🗝️ API Keys</a>
<a href="/webhooks" class="quick-link">🔗 Webhooks</a>
<a href="/settings" class="quick-link">⚙️ System Settings</a>
</div>
</div>
</div> </div>
\ No newline at end of file
<?php $__engine->extend('layouts/app'); ?> <?php
<?php $__engine->section('title'); ?>Dashboard<?php $__engine->endSection(); ?> /** @var array $user */
/** @var array|null $today_report */
/** @var array $my_tasks */
/** @var array $upcoming_deadlines */
/** @var int $reports_submitted */
/** @var array $hud */
/** @var array $learning_goals */
$month = date('F Y');
?>
<div class="dashboard"> <div class="dashboard-header">
<h1>Welcome, <?= $__engine->e($user['full_name_en']) ?>!</h1> <h1>📊 My Dashboard</h1>
<span class="dashboard-date"><?= date('l, F j, Y') ?></span>
<div class="dashboard-grid"> </div>
<!-- Today's Report -->
<div class="card">
<h3>📋 Today's Report</h3>
<?php if ($today_report && in_array($today_report['status'], ['submitted','approved','approved_auto','late'])): ?>
<p class="text-success">✅ Report submitted for today.</p>
<?php else: ?>
<p class="text-warning">⏳ You haven't submitted today's report yet.</p>
<a href="/reports/submit" class="btn btn-primary">Submit Report</a>
<?php endif; ?>
</div>
<!-- My Tasks -->
<div class="card card-wide">
<h3>🎯 My Tasks (<?= count($my_tasks ?? []) ?>)</h3>
<?php if (empty($my_tasks)): ?>
<p class="text-muted">No tasks assigned.</p>
<?php else: ?>
<div class="task-list">
<?php foreach (array_slice($my_tasks, 0, 10) as $task): ?>
<div class="task-item">
<span class="task-key"><?= $__engine->e($task['card_key'] ?? '') ?></span>
<span class="task-title"><?= $__engine->e($task['title']) ?></span>
<span class="task-status badge-<?= $__engine->e($task['column_slug']) ?>"><?= $__engine->e($task['column_name']) ?></span>
<?php if ($task['deadline']): ?>
<span class="task-deadline <?= strtotime($task['deadline']) < time() ? 'overdue' : '' ?>">
<?= date('M j', strtotime($task['deadline'])) ?>
</span>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<!-- Upcoming Deadlines --> <!-- HUD -->
<div class="card"> <?php if (!empty($hud) && ($hud['actual_salary'] ?? 0) > 0): ?>
<h3>⏰ Upcoming Deadlines</h3> <div class="hud-container">
<?php if (empty($upcoming_deadlines)): ?> <div class="hud-top">
<p class="text-muted">No upcoming deadlines.</p> <div>
<?php else: ?> <div class="hud-salary-label">Live Salary — <?= $hud['month_label'] ?? $month ?></div>
<?php foreach (array_slice($upcoming_deadlines, 0, 5) as $task): ?> <div class="hud-salary-value <?= $hud['color_class'] ?? '' ?>" id="hud-live-salary"><?= number_format($hud['live_salary'] ?? 0, 2) ?></div>
<div class="deadline-item">
<strong><?= $__engine->e($task['card_key'] ?? '') ?></strong>: <?= $__engine->e($task['title']) ?>
<br><small>Due: <?= date('M j, Y', strtotime($task['deadline'])) ?> · <?= $__engine->e($task['board_name']) ?></small>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div> </div>
<div>
<!-- Month Stats --> <span class="badge <?= ($hud['health']['status'] ?? '') === 'healthy' ? 'badge-success' : (($hud['health']['status'] ?? '') === 'warning' ? 'badge-warning' : 'badge-danger') ?>"><?= $hud['health']['icon'] ?? '🟢' ?> <?= $hud['health']['label'] ?? 'OK' ?></span>
<div class="card">
<h3>📊 This Month</h3>
<div class="stat-row">
<span>Reports Submitted</span>
<strong><?= $reports_submitted ?? 0 ?></strong>
</div>
</div> </div>
</div>
<div class="hud-bar-container">
<div class="hud-bar <?= $hud['color_class'] ?? 'hud-healthy' ?>" id="hud-salary-bar" style="width:<?= min(100, max(0, $hud['retention_pct'] ?? 100)) ?>%"></div>
</div>
<div class="hud-details">
<div class="hud-detail"><span class="hud-detail-label">Base</span><span class="hud-detail-value"><?= number_format($hud['actual_salary'] ?? 0, 0) ?></span></div>
<div class="hud-detail"><span class="hud-detail-label">Bounties</span><span class="hud-detail-value positive">+<?= number_format($hud['total_bounties'] ?? 0, 0) ?></span></div>
<div class="hud-detail"><span class="hud-detail-label">Deductions</span><span class="hud-detail-value negative">-<?= number_format($hud['total_deductions'] ?? 0, 0) ?></span></div>
<div class="hud-detail"><span class="hud-detail-label">Retention</span><span class="hud-detail-value"><?= round($hud['retention_pct'] ?? 100, 1) ?>%</span></div>
</div>
</div>
<?php endif; ?>
<!-- Learning Goals --> <!-- Today's Report -->
<?php if (!empty($learning_goals)): ?> <div class="card mb-3">
<div class="card"> <div class="card-header">
<h3>📚 Learning Goals</h3> <span class="card-title">📝 Today's Report</span>
<?php foreach ($learning_goals as $goal): ?> <?php if (!$today_report || in_array($today_report['status'] ?? '', ['draft','unreported',''])): ?>
<div class="goal-item"> <a href="/reports/submit" class="btn btn-primary btn-sm">Submit Report</a>
<strong><?= $__engine->e($goal['title']) ?></strong> <?php else: ?>
<br><small><?= $__engine->e($goal['competency_name']) ?> · Due: <?= date('M j', strtotime($goal['deadline'])) ?></small> <span class="badge badge-success">✅ Submitted</span>
<span class="badge badge-<?= $goal['status'] ?>"><?= ucfirst($goal['status']) ?></span> <?php endif; ?>
</div> </div>
<?php endforeach; ?> <?php if ($today_report && !in_array($today_report['status'], ['draft','unreported',''])): ?>
<p style="color:var(--text-secondary)">Status: <strong><?= ucfirst($today_report['status']) ?></strong> · Hours: <?= $today_report['total_hours'] ?? '—' ?></p>
<?php else: ?>
<p style="color:var(--warning)">⚠️ You haven't submitted your daily report yet.</p>
<?php endif; ?>
</div>
<div class="grid grid-2">
<!-- My Tasks -->
<div class="card">
<div class="card-header">
<span class="card-title">🎯 My Tasks (<?= count($my_tasks ?? []) ?>)</span>
<a href="/boards" class="btn btn-sm btn-ghost">All Boards</a>
</div> </div>
<?php if (!empty($my_tasks)): ?>
<ul class="task-list">
<?php foreach (array_slice($my_tasks, 0, 10) as $task): ?>
<li class="task-item">
<span class="priority-dot priority-<?= $task['priority'] ?? 'none' ?>"></span>
<a href="/cards/<?= $task['id'] ?>" class="task-key"><?= htmlspecialchars($task['card_key'] ?? '') ?></a>
<span class="task-title"><?= htmlspecialchars($task['title'] ?? '') ?></span>
<span class="badge badge-muted"><?= $task['column_name'] ?? '' ?></span>
</li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<div class="empty-state"><div class="empty-state-icon">🎉</div><div>No active tasks</div></div>
<?php endif; ?> <?php endif; ?>
</div>
<!-- Recent Notifications --> <!-- Upcoming Deadlines -->
<div class="card"> <div class="card">
<h3>🔔 Recent Notifications</h3> <div class="card-header">
<?php foreach ($notifications ?? [] as $notif): ?> <span class="card-title">⏰ Upcoming Deadlines</span>
<div class="notif-item <?= $notif['is_read'] ? '' : 'unread' ?>">
<strong><?= $__engine->e($notif['title']) ?></strong>
<p><?= $__engine->e(substr($notif['content'], 0, 100)) ?></p>
<small><?= date('M j, H:i', strtotime($notif['created_at'])) ?></small>
</div>
<?php endforeach; ?>
<a href="/notifications" class="btn btn-link">View All</a>
</div> </div>
<?php if (!empty($upcoming_deadlines)): ?>
<ul class="task-list">
<?php foreach ($upcoming_deadlines as $d): ?>
<li class="task-item">
<a href="/cards/<?= $d['id'] ?>" class="task-key"><?= htmlspecialchars($d['card_key'] ?? '') ?></a>
<span class="task-title"><?= htmlspecialchars($d['title'] ?? '') ?></span>
<span class="task-meta"><?= date('M j', strtotime($d['deadline'])) ?></span>
</li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<div class="empty-state"><div class="empty-state-icon">📭</div><div>No upcoming deadlines</div></div>
<?php endif; ?>
</div> </div>
</div> </div>
\ No newline at end of file
<!DOCTYPE html> <!DOCTYPE html><html><head><title>404</title><link rel="stylesheet" href="/assets/css/app.css"></head>
<html><head><title>404 Not Found</title><link rel="stylesheet" href="/assets/css/app.css"></head>
<body class="error-page"><div class="error-container"><h1>404</h1><p>Page not found.</p><a href="/dashboard" class="btn btn-primary">Go Home</a></div></body></html> <body class="error-page"><div class="error-container"><h1>404</h1><p>Page not found.</p><a href="/dashboard" class="btn btn-primary">Go Home</a></div></body></html>
\ No newline at end of file
<?php $this->layout('layouts/app', ['title' => 'Evaluation Details']); ?> <?php /** @var array $user */ /** @var array $compiled */ /** @var array $tech_scores */ /** @var array $prof_scores */ ?>
<div class="dashboard-header"><h1>📊 Evaluation — <?= $compiled['month'] ?></h1></div>
<div class="container mx-auto px-4 py-6"> <div class="card mb-3" style="text-align:center;">
<div class="flex justify-between items-center mb-6"> <div style="font-size:3rem;font-weight:800;color:var(--accent-primary)"><?= $compiled['overall_score'] ?>/5.00</div>
<h1 class="text-2xl font-bold">Evaluation: <?= htmlspecialchars($compiled['month']) ?></h1> <span class="badge <?= $compiled['rating']==='exceptional' ? 'badge-success' : ($compiled['rating']==='unacceptable' ? 'badge-danger' : 'badge-info') ?>" style="font-size:1rem;padding:6px 16px;"><?= ucfirst(str_replace('_',' ',$compiled['rating'])) ?></span>
<a href="/evaluations" class="text-gray-400 hover:text-white">← Back</a> </div>
</div> <div class="grid grid-2">
<div class="card"><h3>Technical (<?= $compiled['technical_score'] ?? '—' ?>)</h3><?php foreach ($tech_scores as $s): ?><div class="stat-row"><span><?= str_replace('_',' ',ucfirst($s['criterion_key'])) ?></span><strong><?= $s['final_value'] ?></strong></div><?php endforeach; ?></div>
<div class="bg-gray-800 rounded-lg p-6 mb-6"> <div class="card"><h3>Professional (<?= $compiled['professional_score'] ?? '—' ?>)</h3><?php foreach ($prof_scores as $s): ?><div class="stat-row"><span><?= str_replace('_',' ',ucfirst($s['criterion_key'])) ?></span><strong><?= $s['final_value'] ?></strong></div><?php endforeach; ?></div>
<div class="text-center mb-6">
<div class="text-4xl font-bold text-blue-400"><?= number_format((float)$compiled['overall_score'], 2) ?>/5.00</div>
<div class="text-lg mt-2 <?= match($compiled['rating']) {
'exceptional' => 'text-yellow-400',
'strong' => 'text-green-400',
'adequate' => 'text-yellow-500',
'below_expectations' => 'text-orange-400',
'unacceptable' => 'text-red-400',
default => 'text-gray-400',
} ?>">
<?= htmlspecialchars(ucfirst(str_replace('_', ' ', $compiled['rating']))) ?>
</div>
</div>
<div class="grid grid-cols-2 gap-6">
<div>
<h3 class="font-semibold mb-3">Technical Score: <?= number_format((float)($compiled['technical_score'] ?? 0), 2) ?></h3>
<?php foreach ($tech_scores as $s): ?>
<div class="flex justify-between py-1 border-b border-gray-700">
<span class="text-gray-400"><?= htmlspecialchars($s['criterion_key']) ?></span>
<span><?= number_format((float)$s['final_value'], 2) ?></span>
</div>
<?php endforeach; ?>
</div>
<div>
<h3 class="font-semibold mb-3">Professional Score: <?= number_format((float)($compiled['professional_score'] ?? 0), 2) ?></h3>
<?php foreach ($prof_scores as $s): ?>
<div class="flex justify-between py-1 border-b border-gray-700">
<span class="text-gray-400"><?= htmlspecialchars($s['criterion_key']) ?></span>
<span><?= number_format((float)$s['final_value'], 2) ?></span>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<?php if ($compiled['contractor_id'] === $user['id'] && !$compiled['acknowledged_at']): ?>
<form method="POST" action="/evaluations/compiled/<?= $compiled['id'] ?>/acknowledge" class="mb-4">
<button type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white py-3 rounded-lg font-bold">
I Acknowledge This Evaluation
</button>
</form>
<?php endif; ?>
<?php if ($compiled['contractor_id'] === $user['id'] && $compiled['acknowledged_at'] && !$compiled['contractor_response']): ?>
<form method="POST" action="/evaluations/compiled/<?= $compiled['id'] ?>/respond" class="bg-gray-800 rounded-lg p-6">
<h3 class="font-semibold mb-3">Submit Your Response (Optional)</h3>
<textarea name="response" rows="4" class="w-full bg-gray-700 rounded p-3 text-white" placeholder="Your thoughts on this evaluation..."></textarea>
<button type="submit" class="mt-3 bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded">Submit Response</button>
</form>
<?php endif; ?>
<?php if ($compiled['contractor_response']): ?>
<div class="bg-gray-800 rounded-lg p-6 mt-4">
<h3 class="font-semibold mb-2">Your Response</h3>
<p class="text-gray-300"><?= nl2br(htmlspecialchars($compiled['contractor_response'])) ?></p>
<p class="text-gray-500 text-sm mt-2">Submitted: <?= htmlspecialchars($compiled['responded_at']) ?></p>
</div>
<?php endif; ?>
</div> </div>
\ No newline at end of file
<?php $this->layout('layouts/app', ['title' => 'My Evaluations']); ?> <?php /** @var array $user */ /** @var array $evaluations */ ?>
<div class="dashboard-header"><h1>📊 My Evaluations</h1></div>
<div class="container mx-auto px-4 py-6"> <div class="card">
<h1 class="text-2xl font-bold mb-6">My Evaluations</h1> <?php if (empty($evaluations)): ?>
<div class="empty-state"><div class="empty-state-icon">📊</div><div>No evaluations yet</div></div>
<?php if (empty($evaluations)): ?> <?php else: ?>
<div class="bg-gray-800 rounded-lg p-8 text-center text-gray-400"> <table class="data-table"><thead><tr><th>Month</th><th>Overall</th><th>Technical</th><th>Professional</th><th>Rating</th><th></th></tr></thead><tbody>
<p>No evaluations yet. They are compiled at the end of each month.</p> <?php foreach ($evaluations as $e): ?>
</div> <tr><td><?= $e['month'] ?></td><td class="mono"><?= $e['overall_score'] ?></td><td class="mono"><?= $e['technical_score'] ?? '—' ?></td><td class="mono"><?= $e['professional_score'] ?? '—' ?></td><td><span class="badge <?= $e['rating']==='exceptional' ? 'badge-success' : ($e['rating']==='unacceptable' ? 'badge-danger' : 'badge-info') ?>"><?= ucfirst(str_replace('_',' ',$e['rating'])) ?></span></td><td><a href="/evaluations/compiled/<?= $e['id'] ?>" class="btn btn-sm btn-ghost">View</a></td></tr>
<?php else: ?> <?php endforeach; ?>
<div class="space-y-4"> </tbody></table>
<?php foreach ($evaluations as $eval): ?> <?php endif; ?>
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold"><?= htmlspecialchars($eval['month']) ?></h2>
<span class="px-3 py-1 rounded-full text-sm font-bold
<?= match($eval['rating']) {
'exceptional' => 'bg-yellow-500 text-black',
'strong' => 'bg-green-600 text-white',
'adequate' => 'bg-yellow-600 text-black',
'below_expectations' => 'bg-orange-600 text-white',
'unacceptable' => 'bg-red-600 text-white',
default => 'bg-gray-600 text-white',
} ?>">
<?= htmlspecialchars(ucfirst(str_replace('_', ' ', $eval['rating']))) ?>
</span>
</div>
<div class="grid grid-cols-3 gap-4 text-center">
<div>
<div class="text-gray-400 text-sm">Technical</div>
<div class="text-xl font-bold"><?= number_format((float)$eval['technical_score'], 2) ?></div>
</div>
<div>
<div class="text-gray-400 text-sm">Professional</div>
<div class="text-xl font-bold"><?= number_format((float)$eval['professional_score'], 2) ?></div>
</div>
<div>
<div class="text-gray-400 text-sm">Overall</div>
<div class="text-xl font-bold text-blue-400"><?= number_format((float)$eval['overall_score'], 2) ?>/5.00</div>
</div>
</div>
<div class="mt-4 text-right">
<a href="/evaluations/compiled/<?= $eval['id'] ?>" class="text-blue-400 hover:text-blue-300">View Details →</a>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div> </div>
\ No newline at end of file
<?php $this->layout('layouts/app', ['title' => 'Professional Evaluation']); ?> <?php /** @var array $user */ /** @var array $evaluation */ /** @var string $contractor_name */ /** @var string $month */ /** @var array $auto_scores */ ?>
<div class="dashboard-header"><h1>📊 Professional Evaluation</h1><span><?= htmlspecialchars($contractor_name) ?><?= $month ?></span></div>
<div class="container mx-auto px-4 py-6"> <div class="card"><p>Evaluation form for professional criteria. Submit scores via API.</p><pre><?= json_encode($auto_scores, JSON_PRETTY_PRINT) ?></pre></div>
<h1 class="text-2xl font-bold mb-2">Professional Evaluation</h1> \ No newline at end of file
<p class="text-gray-400 mb-6">Contractor: <?= htmlspecialchars($contractor_name) ?> | Month: <?= htmlspecialchars($month) ?></p>
<form method="POST" action="/evaluations/<?= $evaluation['id'] ?>/professional" class="space-y-6">
<?php
$criteria = require ROOT_PATH . '/config/evaluation_criteria.php';
$profCriteria = $criteria['professional'] ?? [];
foreach ($profCriteria as $key => $c):
$autoVal = $auto_scores[$key] ?? null;
?>
<div class="bg-gray-800 rounded-lg p-6">
<div class="flex justify-between items-start mb-3">
<div>
<h3 class="font-semibold"><?= htmlspecialchars($c['name']) ?></h3>
<p class="text-gray-400 text-sm">Weight: <?= ($c['weight'] * 100) ?>%</p>
</div>
<?php if ($autoVal !== null): ?>
<span class="bg-purple-900 text-purple-300 px-3 py-1 rounded text-sm">Auto: <?= number_format((float)$autoVal, 2) ?></span>
<?php endif; ?>
</div>
<?php if ($autoVal !== null): ?>
<input type="hidden" name="scores[<?= $key ?>][auto_value]" value="<?= (float)$autoVal ?>">
<?php endif; ?>
<div class="mb-3">
<label class="text-sm text-gray-400">Score (1.00 - 5.00)</label>
<input type="number" step="0.01" min="1" max="5" name="scores[<?= $key ?>][manual_value]"
value="<?= $autoVal !== null ? number_format((float)$autoVal, 2) : '3.00' ?>"
class="w-full bg-gray-700 rounded p-2 mt-1 text-white" required>
</div>
<div>
<label class="text-sm text-gray-400">Justification</label>
<textarea name="scores[<?= $key ?>][justification]" rows="2"
class="w-full bg-gray-700 rounded p-2 mt-1 text-white"
placeholder="Provide justification for your score"
<?= ($c['auto'] ?? false) ? '' : 'required' ?>></textarea>
</div>
</div>
<?php endforeach; ?>
<button type="submit" class="w-full bg-green-600 hover:bg-green-700 text-white py-3 rounded-lg font-bold">
Submit Professional Evaluation
</button>
</form>
</div>
\ No newline at end of file
<?php $this->layout('layouts/app', ['title' => 'Technical Evaluation']); ?> <?php /** @var array $user */ /** @var array $evaluation */ /** @var string $contractor_name */ /** @var string $month */ /** @var array $auto_scores */ ?>
<div class="dashboard-header"><h1>📊 Technical Evaluation</h1><span><?= htmlspecialchars($contractor_name) ?><?= $month ?></span></div>
<div class="container mx-auto px-4 py-6"> <div class="card"><p>Evaluation form for technical criteria. Submit scores via API.</p><pre><?= json_encode($auto_scores, JSON_PRETTY_PRINT) ?></pre></div>
<h1 class="text-2xl font-bold mb-2">Technical Evaluation</h1> \ No newline at end of file
<p class="text-gray-400 mb-6">Contractor: <?= htmlspecialchars($contractor_name) ?> | Month: <?= htmlspecialchars($month) ?></p>
<form method="POST" action="/evaluations/<?= $evaluation['id'] ?>/technical" class="space-y-6">
<?php
$criteria = require ROOT_PATH . '/config/evaluation_criteria.php';
$techCriteria = $criteria['technical'] ?? [];
foreach ($techCriteria as $key => $c):
$autoVal = $auto_scores[$key] ?? null;
?>
<div class="bg-gray-800 rounded-lg p-6">
<div class="flex justify-between items-start mb-3">
<div>
<h3 class="font-semibold"><?= htmlspecialchars($c['name']) ?></h3>
<p class="text-gray-400 text-sm">Weight: <?= ($c['weight'] * 100) ?>%</p>
</div>
<?php if ($autoVal !== null): ?>
<span class="bg-blue-900 text-blue-300 px-3 py-1 rounded text-sm">Auto: <?= number_format((float)$autoVal, 2) ?></span>
<?php endif; ?>
</div>
<?php if ($autoVal !== null): ?>
<input type="hidden" name="scores[<?= $key ?>][auto_value]" value="<?= (float)$autoVal ?>">
<?php endif; ?>
<div class="mb-3">
<label class="text-sm text-gray-400">Score (1.00 - 5.00)</label>
<input type="number" step="0.01" min="1" max="5" name="scores[<?= $key ?>][manual_value]"
value="<?= $autoVal !== null ? number_format((float)$autoVal, 2) : '3.00' ?>"
class="w-full bg-gray-700 rounded p-2 mt-1 text-white" required>
</div>
<div>
<label class="text-sm text-gray-400">Justification</label>
<textarea name="scores[<?= $key ?>][justification]" rows="2"
class="w-full bg-gray-700 rounded p-2 mt-1 text-white"
placeholder="<?= ($c['auto'] ?? false) ? 'Override justification (optional if keeping auto value)' : 'Required justification' ?>"
<?= ($c['auto'] ?? false) ? '' : 'required' ?>></textarea>
</div>
</div>
<?php endforeach; ?>
<button type="submit" class="w-full bg-green-600 hover:bg-green-700 text-white py-3 rounded-lg font-bold">
Submit Technical Evaluation
</button>
</form>
</div>
\ No newline at end of file
<?php $this->layout('layouts/app', ['title' => 'Meetings']); ?> <?php /** @var array $user */ /** @var array $meetings */ ?>
<div class="dashboard-header"><h1>📅 Meetings</h1></div>
<div class="container mx-auto px-4 py-6"> <div class="card">
<div class="flex justify-between items-center mb-6"> <?php if (empty($meetings)): ?>
<h1 class="text-2xl font-bold">Meetings</h1> <div class="empty-state"><div class="empty-state-icon">📅</div><div>No meetings scheduled</div></div>
<?php if (in_array($user['role'], ['super_admin', 'admin', 'project_leader'])): ?> <?php else: ?>
<button class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded" onclick="document.getElementById('new-meeting').classList.toggle('hidden')"> <?php foreach ($meetings as $m): ?>
+ Schedule Meeting <div class="task-item"><span class="badge badge-info"><?= $m['meeting_date'] ?></span><strong><?= htmlspecialchars($m['title']) ?></strong><span class="task-meta"><?= $m['start_time'] ?><?= $m['end_time'] ?></span></div>
</button> <?php endforeach; ?>
<?php endif; ?> <?php endif; ?>
</div>
<div class="text-gray-400 text-center py-12">
<p>Meeting data loads via API. Use the JSON endpoints for full functionality.</p>
<p class="mt-2"><code class="bg-gray-800 px-2 py-1 rounded">GET /meetings</code></p>
</div>
</div> </div>
\ No newline at end of file
<?php $__engine->extend('layouts/app'); ?> <?php /** @var array $user */ /** @var array $conversations */ ?>
<?php $__engine->section('title'); ?>Messages<?php $__engine->endSection(); ?> <div class="dashboard-header"><h1>💬 Messages</h1></div>
<div class="card">
<div class="container"> <?php if (empty($conversations)): ?>
<div class="page-header"> <div class="empty-state"><div class="empty-state-icon">💬</div><div>No conversations yet</div></div>
<h1>💬 Messages</h1> <?php else: ?>
<button class="btn btn-primary" id="new-conversation-btn">+ New Conversation</button> <?php foreach ($conversations as $c): ?>
</div> <a href="/messages/<?= $c['id'] ?>" class="task-item" style="text-decoration:none;color:inherit;">
<strong><?php foreach ($c['participants'] ?? [] as $p) echo htmlspecialchars($p['full_name_en']) . ' '; ?></strong>
<div class="conversation-list"> <span class="task-title" style="color:var(--text-secondary)"><?= htmlspecialchars(substr($c['last_message'] ?? '', 0, 80)) ?></span>
<?php if (empty($conversations)): ?> <?php if (($c['unread_count'] ?? 0) > 0): ?><span class="badge badge-primary"><?= $c['unread_count'] ?></span><?php endif; ?>
<div class="card"><p class="text-muted">No conversations yet.</p></div>
<?php endif; ?>
<?php foreach ($conversations as $conv): ?>
<a href="/messages/<?= $conv['id'] ?>" class="card conversation-card" style="display:block;margin-bottom:8px;text-decoration:none">
<div style="display:flex;justify-content:space-between">
<div>
<strong>
<?php foreach ($conv['participants'] as $p): ?>
<?= $__engine->e($p['full_name_en']) ?><?php if ($p !== end($conv['participants'])) echo ', '; ?>
<?php endforeach; ?>
</strong>
<?php if ($conv['last_message']): ?>
<p class="text-muted" style="margin:4px 0 0"><?= $__engine->e(substr($conv['last_message'], 0, 80)) ?></p>
<?php endif; ?>
</div>
<div style="text-align:right">
<?php if ($conv['last_message_at']): ?>
<small class="text-muted"><?= date('M j, H:i', strtotime($conv['last_message_at'])) ?></small>
<?php endif; ?>
<?php if ($conv['unread_count'] > 0): ?>
<span class="badge" style="background:var(--primary);color:white;position:static;width:auto;height:auto;padding:2px 8px;font-size:0.75em"><?= $conv['unread_count'] ?></span>
<?php endif; ?>
</div>
</div>
</a> </a>
<?php endforeach; ?> <?php endforeach; ?>
</div> <?php endif; ?>
</div> </div>
\ No newline at end of file
<!DOCTYPE html> <?php /** @var array $user */ /** @var array $notification */ ?>
<html lang="en">
<head> <div style="max-width:600px;margin:40px auto;text-align:center;">
<meta charset="UTF-8"> <div class="card" style="border-color:var(--danger);">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <div style="font-size:3rem;margin-bottom:16px;">🚨</div>
<title>⚠️ Action Required</title> <h2 style="margin-bottom:8px;"><?= htmlspecialchars($notification['title'] ?? 'Action Required') ?></h2>
<link rel="stylesheet" href="/assets/css/app.css"> <p style="color:var(--text-secondary);margin-bottom:24px;"><?= htmlspecialchars($notification['content'] ?? '') ?></p>
</head>
<body class="blocking-page"> <?php if (!empty($notification['link_url'])): ?>
<div class="blocking-overlay"> <a href="<?= htmlspecialchars($notification['link_url']) ?>" class="btn btn-primary btn-lg" style="margin-bottom:12px;">View Details</a>
<div class="blocking-card"> <?php endif; ?>
<div class="blocking-icon">⚠️</div>
<h1><?= htmlspecialchars($notification['title'] ?? 'Action Required') ?></h1> <form method="POST" action="/notifications/<?= $notification['id'] ?>/acknowledge" style="margin-top:12px;">
<div class="blocking-content"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token'] ?? '') ?>">
<?= nl2br(htmlspecialchars($notification['content'] ?? '')) ?> <button type="submit" class="btn btn-success btn-lg btn-block">✅ I Acknowledge</button>
</div> </form>
<form method="POST" action="/notifications/<?= (int)$notification['id'] ?>/acknowledge">
<input type="hidden" name="_csrf_token" value="<?= htmlspecialchars($_COOKIE['csrf_token'] ?? '') ?>">
<p class="blocking-note">Acknowledgment does not mean agreement. You are confirming you have seen this.</p>
<button type="submit" class="btn btn-primary btn-lg">I Acknowledge</button>
</form>
</div>
</div> </div>
</body> </div>
</html> \ No newline at end of file
\ No newline at end of file
<?php $__engine->extend('layouts/app'); ?> <?php /** @var array $user */ /** @var array $notifications */ /** @var int $unread_count */ ?>
<?php $__engine->section('title'); ?>Notifications<?php $__engine->endSection(); ?>
<div class="container"> <div class="dashboard-header">
<div class="page-header"> <h1>🔔 Notifications</h1>
<h1>🔔 Notifications</h1> <?php if ($unread_count > 0): ?>
<form method="POST" action="/notifications/read-all"> <form method="POST" action="/notifications/read-all"><input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token'] ?? '') ?>"><button class="btn btn-sm btn-ghost">Mark All Read</button></form>
<input type="hidden" name="_csrf_token" value="<?= $__engine->e($_COOKIE['csrf_token'] ?? '') ?>"> <?php endif; ?>
<button type="submit" class="btn btn-sm btn-secondary">Mark All as Read</button> </div>
</form>
</div>
<div class="notification-list"> <div class="card">
<?php foreach ($notifications as $notif): ?> <?php if (empty($notifications)): ?>
<div class="notification-item <?= $notif['is_read'] ? 'read' : 'unread' ?> tier-<?= $__engine->e($notif['tier']) ?>"> <div class="empty-state"><div class="empty-state-icon">🔕</div><div>No notifications yet</div></div>
<div class="notif-header"> <?php else: ?>
<strong><?= $__engine->e($notif['title']) ?></strong> <?php foreach ($notifications as $n): ?>
<span class="notif-time"><?= date('M j, H:i', strtotime($notif['created_at'])) ?></span> <div class="task-item" style="<?= !$n['is_read'] ? 'background:var(--accent-primary-bg);padding:10px;border-radius:var(--radius);margin-bottom:4px;' : '' ?>">
<span class="badge <?= $n['tier'] === 'blocking' ? 'badge-danger' : ($n['tier'] === 'important' ? 'badge-warning' : 'badge-muted') ?>"><?= strtoupper($n['tier']) ?></span>
<div style="flex:1">
<strong><?= htmlspecialchars($n['title']) ?></strong>
<div style="font-size:0.8rem;color:var(--text-secondary)"><?= htmlspecialchars($n['content']) ?></div>
</div> </div>
<p><?= $__engine->e($notif['content']) ?></p> <span class="task-meta"><?= date('M j, g:ia', strtotime($n['created_at'])) ?></span>
<?php if ($notif['link_url']): ?>
<a href="<?= $__engine->e($notif['link_url']) ?>" class="btn btn-sm btn-link">View →</a>
<?php endif; ?>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> <?php endif; ?>
</div> </div>
\ No newline at end of file
<?php $this->layout('layouts/app', ['title' => 'PIP Details']); ?> <?php /** @var array $user */ /** @var array $pip */ /** @var array $targets */ /** @var array $checkins */ ?>
<div class="dashboard-header"><h1>📈 PIP #<?= $pip['id'] ?></h1><span class="badge <?= $pip['status']==='passed' ? 'badge-success' : ($pip['status']==='failed' ? 'badge-danger' : 'badge-warning') ?>"><?= ucfirst($pip['status']) ?></span></div>
<div class="container mx-auto px-4 py-6"> <div class="grid grid-2">
<div class="flex justify-between items-center mb-6"> <div class="card">
<h1 class="text-2xl font-bold">PIP: <?= htmlspecialchars($pip['contractor_name']) ?></h1> <h3 class="mb-2">Details</h3>
<a href="/pips" class="text-gray-400 hover:text-white">← Back</a> <div class="stat-row"><span>Contractor</span><strong><?= htmlspecialchars($pip['contractor_name'] ?? '') ?></strong></div>
</div> <div class="stat-row"><span>Duration</span><strong><?= $pip['duration_days'] ?> days</strong></div>
<div class="stat-row"><span>Period</span><strong><?= $pip['start_date'] ?><?= $pip['end_date'] ?></strong></div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div class="stat-row"><span>Success Criteria</span><strong><?= htmlspecialchars($pip['success_criteria'] ?? '') ?></strong></div>
<div class="lg:col-span-2 space-y-6"> <h3 class="mb-1 mt-3">Targets</h3>
<div class="bg-gray-800 rounded-lg p-6"> <?php foreach ($targets as $t): ?><div class="task-item"><span><?= htmlspecialchars($t['description']) ?></span><span class="text-muted"><?= htmlspecialchars($t['target_metric']) ?></span></div><?php endforeach; ?>
<h2 class="font-semibold mb-4">Specific Issues</h2> </div>
<?php $issues = json_decode($pip['specific_issues'], true) ?? []; ?> <div class="card">
<ol class="list-decimal list-inside space-y-2"> <h3 class="mb-2">Check-ins</h3>
<?php foreach ($issues as $issue): ?> <?php foreach ($checkins as $ci): ?>
<li class="text-gray-300"><?= htmlspecialchars($issue) ?></li> <div style="border-bottom:1px solid var(--border-color);padding:8px 0;">
<?php endforeach; ?> <div class="flex justify-between"><strong><?= $ci['scheduled_date'] ?></strong><span class="badge <?= $ci['logged_at'] ? 'badge-success' : 'badge-muted' ?>"><?= $ci['logged_at'] ? 'Logged' : 'Pending' ?></span></div>
</ol> <?php if ($ci['observations']): ?><div style="font-size:0.85rem;color:var(--text-secondary);margin-top:4px;"><?= htmlspecialchars($ci['observations']) ?></div><?php endif; ?>
</div>
<div class="bg-gray-800 rounded-lg p-6">
<h2 class="font-semibold mb-4">Improvement Targets</h2>
<?php foreach ($targets as $t): ?>
<div class="border-b border-gray-700 pb-3 mb-3 last:border-0">
<p class="font-medium"><?= htmlspecialchars($t['description']) ?></p>
<p class="text-gray-400 text-sm">Metric: <?= htmlspecialchars($t['target_metric']) ?></p>
</div>
<?php endforeach; ?>
</div>
<div class="bg-gray-800 rounded-lg p-6">
<h2 class="font-semibold mb-4">Check-ins</h2>
<?php foreach ($checkins as $ci): ?>
<div class="border-b border-gray-700 pb-3 mb-3 last:border-0">
<div class="flex justify-between">
<span class="font-medium"><?= htmlspecialchars($ci['scheduled_date']) ?></span>
<span class="text-sm <?= $ci['logged_at'] ? 'text-green-400' : 'text-gray-500' ?>">
<?= $ci['logged_at'] ? '✅ Logged' : '⏳ Pending' ?>
</span>
</div>
<?php if ($ci['progress_notes']): ?>
<p class="text-gray-300 mt-1 text-sm"><?= nl2br(htmlspecialchars($ci['progress_notes'])) ?></p>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div> </div>
<?php endforeach; ?>
<div class="space-y-6"> </div>
<div class="bg-gray-800 rounded-lg p-6">
<h3 class="font-semibold mb-3">Details</h3>
<dl class="space-y-2 text-sm">
<div class="flex justify-between"><dt class="text-gray-400">Status</dt><dd class="font-bold"><?= ucfirst($pip['status']) ?></dd></div>
<div class="flex justify-between"><dt class="text-gray-400">Duration</dt><dd><?= $pip['duration_days'] ?> days</dd></div>
<div class="flex justify-between"><dt class="text-gray-400">Start</dt><dd><?= $pip['start_date'] ?></dd></div>
<div class="flex justify-between"><dt class="text-gray-400">End</dt><dd><?= $pip['end_date'] ?></dd></div>
<div class="flex justify-between"><dt class="text-gray-400">Check-ins</dt><dd><?= ucfirst($pip['check_in_frequency']) ?></dd></div>
<div class="flex justify-between"><dt class="text-gray-400">Created By</dt><dd><?= htmlspecialchars($pip['created_by_name']) ?></dd></div>
</dl>
</div>
<div class="bg-gray-800 rounded-lg p-6">
<h3 class="font-semibold mb-3">Success Criteria</h3>
<p class="text-gray-300 text-sm"><?= nl2br(htmlspecialchars($pip['success_criteria'])) ?></p>
</div>
<div class="bg-red-900/30 rounded-lg p-6 border border-red-800">
<h3 class="font-semibold mb-3 text-red-400">Consequence of Failure</h3>
<p class="text-gray-300 text-sm"><?= nl2br(htmlspecialchars($pip['consequence_of_failure'])) ?></p>
</div>
<?php if ($pip['contractor_id'] === $user['id'] && $pip['status'] === 'created'): ?>
<form method="POST" action="/pips/<?= $pip['id'] ?>/acknowledge">
<button type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white py-3 rounded-lg font-bold">
I Acknowledge This PIP
</button>
</form>
<?php endif; ?>
<?php if (in_array($user['role'], ['super_admin', 'admin']) && $pip['status'] === 'active'): ?>
<div class="space-y-2">
<form method="POST" action="/pips/<?= $pip['id'] ?>/decide">
<input type="hidden" name="decision" value="passed">
<button type="submit" class="w-full bg-green-600 hover:bg-green-700 text-white py-2 rounded">✅ Mark as Passed</button>
</form>
<form method="POST" action="/pips/<?= $pip['id'] ?>/decide">
<input type="hidden" name="decision" value="failed">
<button type="submit" class="w-full bg-red-600 hover:bg-red-700 text-white py-2 rounded">❌ Mark as Failed</button>
</form>
</div>
<?php endif; ?>
</div>
</div>
</div> </div>
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment