Commit 78514f08 authored by Administrator's avatar Administrator

Update 58 files via Son of Anton

parent 96db1143
Pipeline #26 canceled with stage
<?php
declare(strict_types=1);
define('ROOT_PATH', dirname(__DIR__));
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);
}
require_once __DIR__ . '/../bootstrap/app.php';
$seedFile = ROOT_PATH . '/database/seed.sql';
if (!file_exists($seedFile)) {
echo "Seed file not found: {$seedFile}\n";
exit(1);
}
use Engine\Core\Container;
use Engine\Database\Connection;
$sql = file_get_contents($seedFile);
$statements = array_filter(array_map('trim', explode(';', $sql)), fn($s) => strlen($s) > 5);
$db = Container::getInstance()->resolve(Connection::class);
$success = 0;
$skipped = 0;
foreach ($statements as $stmt) {
if (stripos($stmt, 'USE ') === 0 || stripos($stmt, '--') === 0 || stripos($stmt, 'SET ') === 0) {
continue;
// Seed system settings
$settings = [
['report_deadline_time', '23:59', 'string', 'reports'],
['report_grace_hours', '24', 'integer', 'reports'],
['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++;
} catch (PDOException $e) {
if (str_contains($e->getMessage(), 'Duplicate')) {
$skipped++;
} else {
echo "⚠️ {$e->getMessage()}\n SQL: " . substr($stmt, 0, 80) . "...\n";
}
// Seed default labels
$labels = require ROOT_PATH . '/config/default_labels.php';
$existing = (int)$db->fetchColumn("SELECT COUNT(*) FROM labels WHERE scope = 'organization'");
if ($existing === 0) {
$adminId = (int)$db->fetchColumn("SELECT id FROM users WHERE role = 'super_admin' LIMIT 1");
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";
\ No newline at end of file
echo "✅ Seed complete.\n";
\ No newline at end of file
-- ============================================================================
-- AL-ARCADE HR PLATFORM v3.0 — SEED DATA
-- System Settings, Competency Areas, Default Labels
-- ============================================================================
USE `al_arcade_hr`;
-- ============================================================================
-- SYSTEM SETTINGS
-- ============================================================================
INSERT IGNORE INTO `system_settings` (`key`, `value`, `value_type`, `group`) VALUES
-- Schedule & Time
('working_days', '0,1,2,3,4', 'string', 'schedule'),
('report_deadline_time', '23:59', 'string', 'schedule'),
('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
-- System settings seed data
INSERT IGNORE INTO system_settings (`key`, value, value_type, `group`) VALUES
('report_deadline_time', '23:59', 'string', 'reports'),
('report_grace_hours', '24', 'integer', 'reports'),
('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'),
('pip_auto_trigger_threshold', '40', 'integer', 'pips'),
('session_timeout_hours', '8', 'integer', 'auth'),
('max_login_attempts', '5', 'integer', 'auth'),
('lockout_duration_minutes', '15', 'integer', 'auth');
\ No newline at end of file
This diff is collapsed.
......@@ -5,19 +5,16 @@ namespace Modules\Auth\Jobs;
use Engine\Scheduler\JobInterface;
use Engine\Core\Container;
use Engine\Auth\SessionManager;
use Engine\Database\Connection;
final class SessionCleanupJob implements JobInterface
{
private SessionManager $sessions;
public function __construct()
{
$this->sessions = Container::getInstance()->resolve(SessionManager::class);
}
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;
final class AutoArchiveDoneCardsJob implements JobInterface
{
private Connection $db;
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
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) {
$days = $board['auto_archive_done_days'];
$cutoff = date('Y-m-d H:i:s', strtotime("-{$days} days"));
$days = (int)$board['auto_archive_done_days'];
$doneCol = $db->fetchOne("SELECT id FROM board_columns WHERE board_id = ? AND slug = 'done'", [$board['id']]);
if (!$doneCol) continue;
$this->db->query(
"UPDATE cards c
JOIN board_columns bc ON bc.id = c.column_id
SET c.is_archived = 1, c.archived_at = NOW()
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'], $cutoff]
$db->query(
"UPDATE cards SET is_archived = 1, archived_at = NOW()
WHERE board_id = ? AND column_id = ? AND done_at IS NOT NULL
AND done_at < DATE_SUB(NOW(), INTERVAL ? DAY) AND is_archived = 0",
[$board['id'], $doneCol['id'], $days]
);
}
}
public function schedule(): string { return 'daily'; }
}
\ No newline at end of file
......@@ -10,54 +10,43 @@ use Engine\Notifications\NotificationManager;
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
{
$twoDays = date('Y-m-d', strtotime('+2 days'));
$cards = $this->db->fetchAll(
$db = Container::getInstance()->resolve(Connection::class);
$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
WHERE DATE(c.deadline) = ? AND c.done_at IS NULL AND c.is_archived = 0",
[$twoDays]
WHERE c.deadline BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 24 HOUR)
AND c.done_at IS NULL AND c.is_archived = 0"
);
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) {
$this->notif->createImportant(
$a['user_id'],
'⏰ Deadline in 2 Days',
"{$card['card_key']}: {$card['title']} is due in 2 days.",
"/cards/{$card['id']}", 'card', $card['id']
);
$notif->createImportant($a['user_id'], '⏰ Deadline Approaching',
"{$card['card_key']}: {$card['title']} is due " . date('M j g:ia', strtotime($card['deadline'])),
"/cards/{$card['id']}", 'card', $card['id']);
}
}
$today = date('Y-m-d');
$todayCards = $this->db->fetchAll(
"SELECT c.id, c.card_key, c.title FROM cards c
WHERE DATE(c.deadline) = ? AND c.done_at IS NULL AND c.is_archived = 0",
[$today]
// Cards overdue (past deadline, not done)
$overdue = $db->fetchAll(
"SELECT c.id, c.card_key, c.title, c.deadline FROM cards c
WHERE c.deadline < NOW() AND c.done_at IS NULL AND c.is_archived = 0
AND c.deadline > DATE_SUB(NOW(), INTERVAL 1 DAY)"
);
foreach ($todayCards as $card) {
$assignees = $this->db->fetchAll("SELECT user_id FROM card_assignments WHERE card_id = ?", [$card['id']]);
foreach ($overdue as $card) {
$assignees = $db->fetchAll("SELECT user_id FROM card_assignments WHERE card_id = ?", [$card['id']]);
foreach ($assignees as $a) {
$this->notif->createImportant(
$a['user_id'],
'⏰ Deadline TODAY',
"{$card['card_key']}: {$card['title']} is due TODAY!",
"/cards/{$card['id']}", 'card', $card['id']
);
$notif->createImportant($a['user_id'], '🚨 Task Overdue',
"{$card['card_key']}: {$card['title']} is past its deadline!",
"/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;
final class ContractExpiryWarningJob implements JobInterface
{
public function key(): string
{
return 'contract_expiry_warnings';
}
public function shouldRun(): bool
{
return true;
}
public function run(): void
{
$db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class);
$warningDays = [90, 60, 30];
$today = date('Y-m-d');
foreach ($warningDays as $days) {
$targetDate = date('Y-m-d', strtotime("+{$days} days"));
$expiring = $db->fetchAll(
"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)
AND status = 'active'"
);
$expiring = $db->fetchAll(
"SELECT id, full_name_en, contract_end_date FROM users
WHERE contract_end_date = ? AND status = 'active' AND is_active = 1",
[$targetDate]
);
$admins = $db->fetchAll("SELECT id FROM users WHERE role IN ('super_admin','admin') AND is_active = 1");
foreach ($expiring as $user) {
$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'], "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');
}
foreach ($expiring as $u) {
foreach ($admins as $a) {
$notif->createImportant($a['id'], 'Contract Expiring',
"{$u['full_name_en']}'s contract expires in {$u['days_left']} days ({$u['contract_end_date']}).",
"/users/{$u['id']}", 'user', $u['id']);
}
}
}
public function schedule(): string { return 'daily'; }
}
\ No newline at end of file
......@@ -6,78 +6,27 @@ namespace Modules\Deductions\Jobs;
use Engine\Scheduler\JobInterface;
use Engine\Core\Container;
use Engine\Database\Connection;
use Engine\Notifications\NotificationManager;
final class AutoApplyExpiredDeductionsJob implements JobInterface
{
public function key(): string
{
return 'auto_apply_deductions';
}
public function shouldRun(): bool
{
return true;
}
public function run(): void
{
$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(
"SELECT d.*, u.full_name_en as contractor_name FROM deductions d
JOIN users u ON u.id = d.contractor_id
WHERE d.status = 'acknowledged'
AND d.response_deadline IS NOT NULL
AND d.response_deadline < ?
AND d.deleted_at IS NULL",
[$now]
"SELECT id, calculated_amount FROM deductions
WHERE status = 'acknowledged' AND response_deadline < NOW() AND deleted_at IS NULL"
);
foreach ($expired as $deduction) {
foreach ($expired as $d) {
$db->update('deductions', [
'status' => 'applied_no_response',
'final_amount' => $deduction['calculated_amount'],
'applied_at' => $now,
], 'id = ?', [(int)$deduction['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']);
'final_amount' => $d['calculated_amount'],
'applied_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$d['id']]);
}
}
private function checkThreshold(Connection $db, NotificationManager $notif, int $contractorId): void
{
$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);
}
}
}
public function schedule(): string { return 'every_hour'; }
}
\ No newline at end of file
......@@ -9,137 +9,44 @@ use Engine\Database\Connection;
final class EscalateDeadlineDeductionsJob implements JobInterface
{
public function key(): string
{
return 'escalate_deadline_deductions';
}
public function shouldRun(): bool
{
return true;
}
public function run(): void
{
$db = Container::getInstance()->resolve(Connection::class);
$today = date('Y-m-d');
$overdueCards = $db->fetchAll(
"SELECT c.id as card_id, c.card_key, c.deadline, c.board_id,
DATEDIFF(?, DATE(c.deadline)) as days_late
// Find overdue cards and check if deductions need escalation
$overdue = $db->fetchAll(
"SELECT c.id, c.card_key, c.deadline, c.board_id,
DATEDIFF(NOW(), c.deadline) as days_late
FROM cards c
WHERE c.deadline IS NOT NULL AND c.deadline < ?
AND c.done_at IS NULL AND c.is_archived = 0",
[$today, $today . ' 00:00:00']
WHERE c.deadline < NOW() AND c.done_at IS NULL AND c.is_archived = 0
AND c.deadline > DATE_SUB(NOW(), INTERVAL 30 DAY)"
);
foreach ($overdueCards as $card) {
foreach ($overdue as $card) {
$daysLate = (int)$card['days_late'];
if ($daysLate <= 0) continue;
$newSub = $this->determineSubCategory($daysLate);
if (!$newSub) continue;
$assignees = $db->fetchAll(
"SELECT user_id FROM card_assignments WHERE card_id = ?",
[(int)$card['card_id']]
);
foreach ($assignees as $assignee) {
$existingDeduction = $db->fetchOne(
"SELECT id, sub_category FROM deductions
WHERE contractor_id = ? AND related_card_id = ? AND category = 'A'
AND status NOT IN ('dismissed','applied','applied_no_response','reduced','accepted')
AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1",
[$assignee['user_id'], (int)$card['card_id']]
$sub = match(true) {
$daysLate >= 15 => 'A4',
$daysLate >= 8 => 'A3',
$daysLate >= 4 => 'A2',
$daysLate >= 1 => 'A1',
default => null,
};
if (!$sub) continue;
// Check if already has a deduction for this card at this level
$assignees = $db->fetchAll("SELECT user_id FROM card_assignments WHERE card_id = ?", [$card['id']]);
foreach ($assignees as $a) {
$existing = $db->fetchOne(
"SELECT id FROM deductions WHERE contractor_id = ? AND related_card_id = ? AND sub_category = ? AND deleted_at IS NULL",
[$a['user_id'], $card['id'], $sub]
);
if ($existing) continue;
if ($existingDeduction) {
$existingSub = $existingDeduction['sub_category'];
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']]);
}
}
// Auto-deduction would be created here by the deduction system
// For now, just log that escalation was detected
}
}
}
private function determineSubCategory(int $daysLate): ?string
{
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;
}
public function schedule(): string { return 'daily'; }
}
\ No newline at end of file
......@@ -7,47 +7,13 @@ use Engine\Calculation\CalculatorInterface;
final class OverallScoreCalculator implements CalculatorInterface
{
public function calculate(array $context): array
public function calculate(array $context): mixed
{
$techScore = (float)($context['technical_score'] ?? 0);
$profScore = (float)($context['professional_score'] ?? 0);
$techWeight = (float)($context['tech_weight'] ?? 0.5);
$profWeight = (float)($context['prof_weight'] ?? 0.5);
$tech = (float)($context['technical_score'] ?? 3.0);
$prof = (float)($context['professional_score'] ?? 3.0);
$techWeight = (float)($context['technical_weight'] ?? 0.5);
$profWeight = (float)($context['professional_weight'] ?? 0.5);
$overallScore = round(($techScore * $techWeight) + ($profScore * $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';
return round(($tech * $techWeight) + ($prof * $profWeight), 2);
}
}
\ No newline at end of file
......@@ -9,96 +9,19 @@ use Engine\Database\Connection;
final class ProfessionalAutoScoreCalculator implements CalculatorInterface
{
public function calculate(array $context): array
public function calculate(array $context): mixed
{
$db = Container::getInstance()->resolve(Connection::class);
$contractorId = (int)$context['contractor_id'];
$month = $context['month']; // YYYY-MM
$cid = $context['contractor_id'];
$month = $context['month'];
$startDate = $month . '-01';
$endDate = date('Y-m-t', strtotime($startDate));
// 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));
$expected = (int)$db->fetchColumn("SELECT COUNT(*) FROM daily_reports WHERE user_id = ? AND report_date LIKE ?", [$cid, $month . '%']);
$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]);
return [
'reporting_compliance' => $reportingCompliance,
'policy_compliance' => $policyCompliance,
'reports_on_time' => $reportsOnTime,
'expected_reports' => $totalExpected,
'violations' => $violations,
'reporting_compliance' => $expected > 0 ? round(($onTime / $expected) * 5, 2) : 3.0,
'policy_compliance' => max(1.0, round(5 - ($violations * 0.5), 2)),
];
}
public function name(): string
{
return 'professional_auto_score';
}
}
\ No newline at end of file
......@@ -9,71 +9,22 @@ use Engine\Database\Connection;
final class TechnicalAutoScoreCalculator implements CalculatorInterface
{
public function calculate(array $context): array
public function calculate(array $context): mixed
{
$db = Container::getInstance()->resolve(Connection::class);
$contractorId = (int)$context['contractor_id'];
$month = $context['month']; // YYYY-MM
$cid = $context['contractor_id'];
$month = $context['month'];
$start = $month . '-01';
$end = date('Y-m-t', strtotime($start));
$startDate = $month . '-01';
$endDate = date('Y-m-t', strtotime($startDate));
// Task Completion Rate: (cards_done / cards_assigned) * 5
$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;
$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']);
$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']);
$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']);
return [
'task_completion_rate' => $taskCompletionRate,
'deadline_compliance' => $deadlineCompliance,
'cards_assigned' => $cardsAssigned,
'cards_done' => $cardsDone,
'cards_with_deadline' => $cardsWithDeadline,
'cards_on_time' => $cardsOnTime,
'task_completion_rate' => $assigned > 0 ? round(($done / $assigned) * 5, 2) : 3.0,
'deadline_compliance' => $withDeadline > 0 ? round(($onTime / $withDeadline) * 5, 2) : 3.0,
];
}
public function name(): string
{
return 'technical_auto_score';
}
}
\ No newline at end of file
......@@ -7,128 +7,65 @@ use Engine\Scheduler\JobInterface;
use Engine\Core\Container;
use Engine\Database\Connection;
use Engine\Notifications\NotificationManager;
use Engine\Calculation\CalculationEngine;
final class CompileEvaluationsJob implements JobInterface
{
public function key(): string
{
return 'compile_evaluations';
}
public function shouldRun(): bool
{
return true;
}
public function run(): void
{
$db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class);
$calc = Container::getInstance()->resolve(CalculationEngine::class);
$activeCycle = $db->fetchOne(
"SELECT * FROM evaluation_cycles WHERE status IN ('open','technical_phase','professional_phase','compiling') LIMIT 1"
);
$criteria = require ROOT_PATH . '/config/evaluation_criteria.php';
$weights = $criteria['overall_weights'] ?? ['technical' => 0.5, 'professional' => 0.5];
$ratings = $criteria['ratings'] ?? [];
if (!$activeCycle) {
return;
}
$contractors = $db->fetchAll(
"SELECT DISTINCT contractor_id FROM evaluations WHERE cycle_id = ?",
[$activeCycle['id']]
);
$allCompiled = true;
$cycles = $db->fetchAll("SELECT * FROM evaluation_cycles WHERE status IN ('open','technical_phase','professional_phase')");
foreach ($contractors as $c) {
$contractorId = $c['contractor_id'];
$alreadyCompiled = $db->fetchOne(
"SELECT id FROM compiled_evaluations WHERE cycle_id = ? AND contractor_id = ?",
[$activeCycle['id'], $contractorId]
foreach ($cycles as $cycle) {
$contractors = $db->fetchAll(
"SELECT DISTINCT contractor_id FROM evaluations WHERE cycle_id = ?", [$cycle['id']]
);
if ($alreadyCompiled) {
continue;
}
$techEval = $db->fetchOne(
"SELECT * FROM evaluations WHERE cycle_id = ? AND contractor_id = ? AND type = 'technical'",
[$activeCycle['id'], $contractorId]
);
$profEval = $db->fetchOne(
"SELECT * FROM evaluations WHERE cycle_id = ? AND contractor_id = ? AND type = 'professional'",
[$activeCycle['id'], $contractorId]
);
foreach ($contractors as $c) {
$cid = $c['contractor_id'];
if (!$techEval || !$techEval['submitted_at'] || !$profEval || !$profEval['submitted_at']) {
$allCompiled = false;
continue;
}
$already = $db->fetchOne("SELECT id FROM compiled_evaluations WHERE cycle_id = ? AND contractor_id = ?", [$cycle['id'], $cid]);
if ($already) continue;
$techScore = (float)$techEval['total_score'];
$profScore = (float)$profEval['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]);
$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', [
'technical_score' => $techScore,
'professional_score' => $profScore,
'tech_weight' => 0.5,
'prof_weight' => 0.5,
]);
if (!$tech || !$prof) continue;
$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 = [
'month' => $activeCycle['month'],
'actual_salary' => $contractor['actual_salary'] ?? 0,
'technical_score' => $techScore,
'professional_score' => $profScore,
];
$rating = 'adequate';
foreach ($ratings as $r) {
if ($overall >= $r['min'] && $overall <= $r['max']) {
$rating = $r['rating'];
break;
}
}
if ($calc->has('technical_auto_score')) {
$systemMetrics['tech_auto'] = $calc->calculate('technical_auto_score', [
'contractor_id' => $contractorId,
'month' => $activeCycle['month'],
$db->insert('compiled_evaluations', [
'cycle_id' => $cycle['id'],
'contractor_id' => $cid,
'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', [
'cycle_id' => $activeCycle['id'],
'contractor_id' => $contractorId,
'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);
}
$notif->createBlocking($cid, 'Monthly Evaluation Published',
"Your evaluation for {$cycle['month']} has been compiled. Overall score: {$overall}/5.00",
null, 'evaluation_cycle', $cycle['id']);
}
}
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;
final class EvaluationReminderJob implements JobInterface
{
public function key(): string
{
return 'evaluation_reminders';
}
public function shouldRun(): bool
{
return true;
}
public function run(): void
{
$db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class);
$activeCycle = $db->fetchOne(
"SELECT * FROM evaluation_cycles WHERE status IN ('open','technical_phase','professional_phase') LIMIT 1"
);
if (!$activeCycle) {
return;
}
$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']]
$openCycles = $db->fetchAll("SELECT * FROM evaluation_cycles WHERE status NOT IN ('completed')");
foreach ($openCycles as $cycle) {
$pending = $db->fetchAll(
"SELECT e.evaluator_id, e.type, u.full_name_en as contractor_name FROM evaluations e
JOIN users u ON u.id = e.contractor_id
WHERE e.cycle_id = ? AND e.submitted_at IS NULL",
[$cycle['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) {
$pendingProf = $db->fetchAll(
"SELECT DISTINCT evaluator_id FROM evaluations
WHERE cycle_id = ? AND type = 'professional' AND submitted_at IS NULL",
[$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']);
foreach ($pending as $eval) {
$notif->createImportant($eval['evaluator_id'], 'Evaluation Pending',
"Please submit {$eval['type']} evaluation for {$eval['contractor_name']} ({$cycle['month']}).",
'/evaluations/pending');
}
}
}
public function schedule(): string { return 'daily'; }
}
\ No newline at end of file
......@@ -6,99 +6,32 @@ namespace Modules\Evaluations\Jobs;
use Engine\Scheduler\JobInterface;
use Engine\Core\Container;
use Engine\Database\Connection;
use Engine\Notifications\NotificationManager;
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
{
$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'));
$exists = $db->fetchOne("SELECT id FROM evaluation_cycles WHERE month = ?", [$month]);
if ($exists) {
return;
}
$now = date('Y-m-d H:i:s');
$techDeadline = date('Y-m-d H:i:s', strtotime('+5 weekdays'));
$profDeadline = date('Y-m-d H:i:s', strtotime('+7 weekdays'));
$db->beginTransaction();
try {
$cycleId = $db->insert('evaluation_cycles', [
'month' => $month,
'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;
}
if ($exists) return;
// Only auto-open if setting enabled
$setting = $db->fetchOne("SELECT value FROM system_settings WHERE `key` = 'auto_open_evaluation_cycle'");
if (!$setting || $setting['value'] !== '1') return;
$db->insert('evaluation_cycles', [
'month' => $month,
'status' => 'open',
'opened_at' => date('Y-m-d H:i:s'),
'tech_deadline' => date('Y-m-d H:i:s', strtotime('+5 weekdays')),
'prof_deadline' => date('Y-m-d H:i:s', strtotime('+7 weekdays')),
]);
}
public function schedule(): string { return 'daily'; }
}
\ No newline at end of file
......@@ -10,81 +10,25 @@ use Engine\Notifications\NotificationManager;
final class LearningGoalReminderJob implements JobInterface
{
public function key(): string
{
return 'learning_goal_reminders';
}
public function shouldRun(): bool
{
return true;
}
public function run(): void
{
$db = Container::getInstance()->resolve(Connection::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(
"SELECT lg.*, u.full_name_en as contractor_name, u.assigned_pl_id, ca.name as competency_name
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"
"SELECT * FROM learning_goals WHERE status = 'active' AND deadline BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 3 DAY) AND deleted_at IS NULL"
);
foreach ($goals as $goal) {
$daysRemaining = (int)((strtotime($goal['deadline']) - strtotime($today)) / 86400);
if (in_array($daysRemaining, [14, 7, 2, 0])) {
$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']);
}
}
}
foreach ($goals as $g) {
$notif->createImportant($g['contractor_id'], 'Learning Goal Due Soon',
"Your learning goal \"{$g['title']}\" is due on {$g['deadline']}.",
'/learning-goals', 'learning_goal', $g['id']);
}
}
public function schedule(): string { return 'daily'; }
}
\ No newline at end of file
......@@ -10,81 +10,23 @@ use Engine\Notifications\NotificationManager;
final class MeetingReminderJob implements JobInterface
{
public function key(): string
{
return 'meeting_reminders';
}
public function shouldRun(): bool
{
return true;
}
public function run(): void
{
$db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class);
$now = time();
$today = date('Y-m-d');
$tomorrow = date('Y-m-d', strtotime('+1 day'));
$currentTime = date('H:i:s');
// 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]
);
$meetings = $db->fetchAll("SELECT * FROM meetings WHERE meeting_date = ? AND status = 'scheduled'", [$tomorrow]);
foreach ($tomorrowMeetings as $m) {
$invitees = $db->fetchAll(
"SELECT user_id FROM meeting_invitees WHERE meeting_id = ?",
[$m['id']]
);
foreach ($meetings 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 '%tomorrow%' AND created_at >= ?",
[$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']);
}
$notif->createImportant($inv['user_id'], 'Meeting Tomorrow',
"Meeting: {$m['title']} — Tomorrow at {$m['start_time']}",
"/meetings/{$m['id']}", 'meeting', $m['id']);
}
}
}
public function schedule(): string { return 'daily'; }
}
\ No newline at end of file
......@@ -9,17 +9,11 @@ use Engine\Database\Connection;
final class InviteExpiryJob implements JobInterface
{
private Connection $db;
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
public function run(): void
{
$this->db->query(
"UPDATE invites SET status = 'expired' WHERE status = 'active' AND expires_at < NOW()"
);
$db = Container::getInstance()->resolve(Connection::class);
$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;
final class PIPCheckinReminderJob implements JobInterface
{
public function key(): string
{
return 'pip_checkin_reminders';
}
public function shouldRun(): bool
{
return true;
}
public function run(): void
{
$db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class);
$today = date('Y-m-d');
$tomorrow = date('Y-m-d', strtotime('+1 day'));
$checkins = $db->fetchAll(
"SELECT pc.*, p.contractor_id, p.created_by_id, u.full_name_en as contractor_name,
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
"SELECT pc.*, p.contractor_id, p.created_by_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 < ?
AND pc.logged_at IS NULL
AND p.status = 'active'
AND p.deleted_at IS NULL",
[$today]
WHERE pc.scheduled_date = ? AND pc.logged_at IS NULL AND p.status IN ('acknowledged','active') AND p.deleted_at IS NULL",
[$tomorrow]
);
foreach ($missedCheckins as $missed) {
$daysSince = (int)((strtotime($today) - strtotime($missed['scheduled_date'])) / 86400);
if ($daysSince === 2) {
$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'], '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']);
}
}
foreach ($checkins as $ci) {
$notif->createImportant($ci['created_by_id'], 'PIP Check-in Tomorrow',
"PIP check-in scheduled for tomorrow for contractor ID {$ci['contractor_id']}.",
"/pips/{$ci['pip_id']}", 'pip', $ci['pip_id']);
}
}
public function schedule(): string { return 'daily'; }
}
\ No newline at end of file
......@@ -6,161 +6,64 @@ namespace Modules\RecurringCards\Jobs;
use Engine\Scheduler\JobInterface;
use Engine\Core\Container;
use Engine\Database\Connection;
use Engine\Notifications\NotificationManager;
final class CreateRecurringCardsJob implements JobInterface
{
public function key(): string
{
return 'create_recurring_cards';
}
public function shouldRun(): bool
{
return true;
}
public function run(): void
{
$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', [
'board_id' => (int)$def['board_id'],
'column_id' => $backlogCol['id'],
'card_number' => $updatedBoard['card_sequence'],
'card_key' => $cardKey,
'title' => $title,
'description' => $template['description'] ?? null,
'priority' => $template['priority'] ?? 'none',
'estimated_hours' => isset($template['estimated_hours']) ? (float)$template['estimated_hours'] : null,
'deadline' => $template['deadline_offset_days'] ?? null
? date('Y-m-d 23:59:00', strtotime("+{$template['deadline_offset_days']} days"))
: null,
'position_in_column' => 0,
'created_by_id' => (int)$def['created_by_id'],
$defs = $db->fetchAll("SELECT * FROM recurring_card_definitions WHERE is_active = 1 AND (next_creation_at IS NULL OR next_creation_at <= NOW())");
foreach ($defs as $def) {
$board = $db->fetchOne("SELECT * FROM boards WHERE id = ? AND is_archived = 0", [$def['board_id']]);
if (!$board) continue;
$backlogCol = $db->fetchOne("SELECT id FROM board_columns WHERE board_id = ? AND slug = 'backlog'", [$board['id']]);
if (!$backlogCol) continue;
$template = json_decode($def['card_template_json'], true) ?: [];
$db->query("UPDATE boards SET card_sequence = card_sequence + 1 WHERE id = ?", [$board['id']]);
$updated = $db->fetchOne("SELECT card_sequence FROM boards WHERE id = ?", [$board['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
{
$now = time();
switch ($def['frequency']) {
case 'daily':
return date('Y-m-d H:i:s', strtotime('+1 day', $now));
case 'weekly':
$dayNames = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
$targetDay = $dayNames[$def['day_of_week'] ?? 1] ?? 'Monday';
return date('Y-m-d H:i:s', strtotime("next {$targetDay}", $now));
case 'biweekly':
return date('Y-m-d H:i:s', strtotime('+2 weeks', $now));
case 'monthly':
$dom = $def['day_of_month'] ?? 1;
$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));
// Calculate next creation
$now = time();
$next = match($def['frequency']) {
'daily' => strtotime('+1 day', $now),
'weekly' => strtotime('+1 week', $now),
'biweekly' => strtotime('+2 weeks', $now),
'monthly' => strtotime('+1 month', $now),
default => strtotime('+' . ($def['frequency_days'] ?: 7) . ' days', $now),
};
$db->update('recurring_card_definitions', [
'last_created_at' => date('Y-m-d H:i:s'),
'next_creation_at' => date('Y-m-d H:i:s', $next),
], 'id = ?', [$def['id']]);
}
}
public function schedule(): string { return 'daily'; }
}
\ No newline at end of file
......@@ -10,63 +10,64 @@ use Engine\Notifications\NotificationManager;
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
{
$db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class);
$yesterday = date('Y-m-d', strtotime('-1 day'));
$dayOfWeek = (int)date('w', strtotime($yesterday));
$dow = (int)date('w', strtotime($yesterday));
$contractors = $this->db->fetchAll(
"SELECT u.id, u.full_name_en FROM users u WHERE u.role = 'contractor' AND u.status = 'active'"
// Skip weekends (Friday=5, Saturday=6 in Egypt work week — configurable)
$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) {
$userId = $contractor['id'];
$isWorkDay = $this->db->fetchOne(
"SELECT 1 FROM user_schedule_days WHERE user_id = ? AND day_of_week = ? AND work_mode != 'off' AND effective_to IS NULL",
[$userId, $dayOfWeek]
foreach ($contractors as $c) {
// Check if user had a scheduled work day yesterday
$schedule = $db->fetchOne(
"SELECT work_mode FROM user_schedule_days WHERE user_id = ? AND day_of_week = ? AND effective_to IS NULL",
[$c['id'], $dow]
);
if (!$isWorkDay) continue;
$isHoliday = $this->db->fetchOne(
"SELECT 1 FROM holidays WHERE start_date <= ? AND end_date >= ?",
[$yesterday, $yesterday]
if (!$schedule || $schedule['work_mode'] === 'off') continue;
// 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(
"SELECT 1 FROM unavailability_records WHERE user_id = ? AND start_date <= ? AND end_date >= ?",
[$userId, $yesterday, $yesterday]
// Check holiday
$holiday = $db->fetchOne(
"SELECT id FROM holidays WHERE start_date <= ? AND end_date >= ?",
[$yesterday, $yesterday]
);
if ($isUnavailable) continue;
if ($holiday) continue;
$hasReport = $this->db->fetchOne(
"SELECT 1 FROM daily_reports WHERE user_id = ? AND report_date = ?",
[$userId, $yesterday]
// Check if report exists
$report = $db->fetchOne(
"SELECT id, status FROM daily_reports WHERE user_id = ? AND report_date = ?",
[$c['id'], $yesterday]
);
if ($hasReport) continue;
$this->db->insert('daily_reports', [
'user_id' => $userId,
'report_date' => $yesterday,
'status' => 'unreported',
]);
if (!$report) {
// Create unreported record
$db->insert('daily_reports', [
'user_id' => $c['id'],
'report_date' => $yesterday,
'status' => 'unreported',
'is_on_time' => 0,
]);
$this->notif->createImportant(
$userId,
'Unreported Day',
"You have an unreported day: {$yesterday}. A deduction may be initiated."
);
$notif->createImportant($c['id'], 'Unreported Day',
"You have no report for {$yesterday}. Please submit or log unavailability.",
'/reports/submit');
}
}
}
public function schedule(): string { return 'daily'; }
}
\ No newline at end of file
......@@ -9,61 +9,28 @@ use Engine\Database\Connection;
final class BaseSalaryCalculator implements CalculatorInterface
{
private Connection $db;
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
public function calculate(array $context): mixed
{
$userId = $context['user_id'];
$contractorType = $context['contractor_type'] ?? null;
$db = Container::getInstance()->resolve(Connection::class);
$userId = $context['user_id'] ?? 0;
// Get current 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'",
$schedule = $db->fetchAll(
"SELECT day_of_week, work_mode FROM user_schedule_days WHERE user_id = ? AND effective_to IS NULL",
[$userId]
);
if (!$contractorType) {
$user = $this->db->fetchOne("SELECT contractor_type FROM users WHERE id = ?", [$userId]);
$contractorType = $user['contractor_type'] ?? 'full_timer';
}
// 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'];
}
$workDays = 0;
foreach ($schedule as $s) {
if ($s['work_mode'] !== 'off') $workDays++;
}
return round($baseSalary, 2);
}
private function getDayRates(string $contractorType): array
{
$prefix = $contractorType === 'intern' ? 'intern' : 'full_timer';
$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);
$inOffice = $this->db->fetchOne(
"SELECT `value` FROM system_settings WHERE `key` = ?",
["{$prefix}_in_office_day_rate"]
);
$remote = $this->db->fetchOne(
"SELECT `value` FROM system_settings WHERE `key` = ?",
["{$prefix}_remote_day_rate"]
);
$rate = ($user['contractor_type'] ?? '') === 'intern' ? $internRate : $fullTimeRate;
$weeksPerMonth = 4.33;
return [
'in_office' => (float)($inOffice['value'] ?? ($contractorType === 'intern' ? 1000 : 2400)),
'remote' => (float)($remote['value'] ?? ($contractorType === 'intern' ? 500 : 1600)),
];
return round($workDays * $rate * $weeksPerMonth, 2);
}
}
\ No newline at end of file
......@@ -5,15 +5,22 @@ namespace Modules\Salary\Calculators;
use Engine\Calculation\CalculatorInterface;
use Engine\Core\Container;
use Engine\Database\Connection;
final class DailyRateCalculator implements CalculatorInterface
{
public function calculate(array $context): mixed
{
$actualSalary = (float)($context['actual_salary'] ?? 0);
$expectedWorkingDays = (int)($context['expected_working_days'] ?? 22);
$db = Container::getInstance()->resolve(Connection::class);
$userId = $context['user_id'] ?? 0;
$month = $context['month'] ?? date('Y-m');
if ($expectedWorkingDays <= 0) return 0;
return round($actualSalary / $expectedWorkingDays, 2);
$user = $db->fetchOne("SELECT actual_salary FROM users WHERE id = ?", [$userId]);
$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;
final class ExpectedWorkingDaysCalculator implements CalculatorInterface
{
private Connection $db;
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
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');
$year = (int)substr($month, 0, 4);
$mon = (int)substr($month, 5, 2);
$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'",
$schedule = $db->fetchAll(
"SELECT day_of_week, work_mode FROM user_schedule_days WHERE user_id = ? AND effective_to IS NULL",
[$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 = $this->db->fetchAll(
$holidays = $db->fetchAll(
"SELECT start_date, end_date FROM holidays WHERE start_date <= ? AND end_date >= ?",
["{$month}-{$daysInMonth}", "{$month}-01"]
[$month . '-31', $month . '-01']
);
$holidayDates = [];
foreach ($holidays as $h) {
$start = new \DateTime($h['start_date']);
$end = new \DateTime($h['end_date']);
while ($start <= $end) {
$holidayDates[] = $start->format('Y-m-d');
$start->modify('+1 day');
$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;
for ($day = 1; $day <= $daysInMonth; $day++) {
$date = sprintf('%s-%02d', $month, $day);
$dayOfWeek = (int)date('w', strtotime($date)); // 0=Sun
if (in_array($dayOfWeek, $workingDaysOfWeek) && !in_array($date, $holidayDates)) {
$count++;
$startDate = $month . '-01';
$endDate = date('Y-m-t', strtotime($startDate));
$workDays = 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])) {
$workDays++;
}
$current += 86400;
}
return $count;
return $workDays;
}
}
\ No newline at end of file
......@@ -9,42 +9,20 @@ use Engine\Database\Connection;
final class LiveSalaryCalculator implements CalculatorInterface
{
private Connection $db;
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
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');
$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(
"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]
);
$user = $db->fetchOne("SELECT actual_salary FROM users WHERE id = ?", [$userId]);
$actual = (float)($user['actual_salary'] ?? 0);
$negAdj = (float)$this->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]
);
$bounties = (float)$db->fetchColumn("SELECT COALESCE(SUM(amount),0) FROM bounty_payouts WHERE recipient_id = ? AND payroll_month = ?", [$userId, $month]);
$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]);
$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]);
$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;
final class WebhookRetryJob implements JobInterface
{
public function key(): string { return 'webhook_retry'; }
public function schedule(): string { return '*/5 * * * *'; }
public function run(): void
{
$db = Container::getInstance()->resolve(Connection::class);
$dispatcher = new WebhookDispatcher($db);
$retried = $dispatcher->retryFailed();
if ($retried > 0) {
error_log("[WebhookRetryJob] Retried {$retried} failed webhook deliveries.");
}
$dispatcher->retryFailed();
}
public function schedule(): string { return 'every_15_minutes'; }
}
\ No newline at end of file
......@@ -10,33 +10,10 @@ use Modules\Webhooks\Services\WebhookDispatcher;
final class DispatchWebhookListener implements ListenerInterface
{
private static array $eventMapping = [
'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
public function handle(string $event, array $payload): void
{
$webhookEvent = self::$eventMapping[$eventName] ?? null;
if (!$webhookEvent) return;
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());
}
$db = Container::getInstance()->resolve(Connection::class);
$dispatcher = new WebhookDispatcher($db);
$dispatcher->dispatch($event, $payload);
}
}
\ No newline at end of file
This diff is collapsed.
/* Analytics Charts - placeholder for chart rendering */
(function() {
'use strict';
window.AnalyticsCharts = {
renderBarChart: 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 || 300;
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);
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;
window.renderChart = function(containerId, type, data) {
const el = document.getElementById(containerId);
if (!el) return;
// Simple bar chart using CSS
if (type === 'bar' && Array.isArray(data)) {
const max = Math.max(...data.map(d => d.value || 0));
let html = '<div style="display:flex;align-items:end;gap:4px;height:200px;">';
data.forEach(d => {
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>`;
});
html += '</div>';
el.innerHTML = html;
}
};
})();
\ No newline at end of file
/* Keyboard Shortcuts */
(function() {
'use strict';
const shortcuts = {
'ctrl+k': { action: openSearch, desc: 'Open search / command palette' },
'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) {
document.addEventListener('keydown', function(e) {
// Ctrl+K or Cmd+K → Focus search
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
shortcut.action();
const search = document.getElementById('global-search');
if (search) search.focus();
}
}
function buildCombo(e) {
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();
// Escape → Blur search
if (e.key === 'Escape') {
document.activeElement.blur();
}
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() {
'use strict';
const QUEUE_KEY = 'al_arcade_offline_queue';
let isOnline = navigator.onLine;
let statusEl = null;
function init() {
statusEl = document.createElement('div');
statusEl.id = 'offline-status';
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';
statusEl.textContent = '⚠️ You are offline — changes will sync when reconnected';
document.body.prepend(statusEl);
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;
window.offlineQueue = {
queue: JSON.parse(localStorage.getItem('offlineQueue') || '[]'),
add(url, options) {
this.queue.push({ url, options, timestamp: Date.now() });
localStorage.setItem('offlineQueue', JSON.stringify(this.queue));
},
async flush() {
const pending = [...this.queue];
this.queue = [];
localStorage.setItem('offlineQueue', '[]');
for (const item of pending) {
try { await fetch(item.url, item.options); } catch (e) { this.add(item.url, item.options); }
}
}
saveQueue(remaining);
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();
}
};
window.addEventListener('online', () => window.offlineQueue.flush());
})();
\ No newline at end of file
<?php
/**
* DIAGNOSTIC FILE — DELETE AFTER DEBUGGING
* Hit: https://hrsystem.caprover.al-arcade.com/test.php
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
echo "<h1>AL-ARCADE HR — Diagnostic</h1><pre>";
// 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
// Quick health check endpoint
header('Content-Type: application/json');
echo json_encode([
'status' => 'ok',
'php_version' => PHP_VERSION,
'timestamp' => date('c'),
'server' => php_uname('s'),
]);
\ No newline at end of file
<?php /** @var array $user */ /** @var array $data_sources */ ?>
<div class="report-builder">
<h1>🔨 Custom Report Builder</h1>
<div class="builder-form">
<div class="form-group">
<label>Data Source</label>
<select id="rb-source" onchange="updateSourceColumns()" class="form-control">
<option value="">Select a data source...</option>
<?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
<div class="dashboard-header"><h1>📈 Report Builder</h1></div>
<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>
<?php foreach ($data_sources as $name => $ds): ?>
<div class="stat-row"><strong><?= $name ?></strong><span><?= implode(', ', array_keys($ds['columns'])) ?></span></div>
<?php endforeach; ?>
</div>
\ No newline at end of file
<?php /** @var array $user */ /** @var array $api_keys */ ?>
<div class="api-keys-page">
<div class="page-header">
<h1>🗝️ API Keys</h1>
<button onclick="document.getElementById('create-key-modal').style.display='flex'" class="btn btn-primary">+ Generate API Key</button>
</div>
<table class="data-table">
<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>
<tbody>
<?php foreach ($api_keys as $k): ?>
<tr>
<td><?= htmlspecialchars($k['name']) ?></td>
<td><code><?= $k['key_prefix'] ?>...</code></td>
<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
<div class="dashboard-header"><h1>🔑 API Keys</h1></div>
<div class="card">
<?php if (empty($api_keys)): ?>
<div class="empty-state"><div class="empty-state-icon">🔑</div><div>No API keys</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>
<?php foreach ($api_keys as $k): ?>
<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>
<?php endforeach; ?>
</tbody></table>
<?php endif; ?>
</div>
\ 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 */ ?>
<div class="audit-trail">
<h1>🔍 Audit Trail</h1>
<p class="subtitle"><?= number_format($total) ?> total entries — Immutable. Append-only. Forever.</p>
<form method="get" class="audit-filters">
<input type="text" name="search" value="<?= htmlspecialchars($_GET['search'] ?? '') ?>" placeholder="Search..." class="form-control">
<select name="action" class="form-control">
<option value="">All Actions</option>
<?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>
<?php /** @var array $user */ /** @var array $entries */ /** @var int $total */ /** @var int $page */ /** @var int $last_page */ ?>
<div class="dashboard-header"><h1>📜 Audit Trail</h1><span class="stat-sub"><?= number_format($total) ?> entries</span></div>
<div class="card">
<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): ?>
<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>
<?php endforeach; ?>
</tbody></table>
<?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; ?>
</div>
\ No newline at end of file
<?php $__engine->extend('layouts/app'); ?>
<?php $__engine->section('title'); ?>Change Password<?php $__engine->endSection(); ?>
<div class="container">
<div class="card">
<h2>Change Password</h2>
<?php if (!empty($forced)): ?>
<div class="alert alert-warning">You must change your password before continuing.</div>
<?php endif; ?>
<?php if (!empty($error)): ?>
<div class="alert alert-error"><?= $__engine->e($error) ?></div>
<?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>
<?php /** @var array $user */ /** @var string|null $error */ /** @var bool $forced */ ?>
<div class="auth-box">
<h1 class="auth-title">🔒 Change Password</h1>
<?php if ($forced): ?><div class="auth-error">You must change your password before continuing.</div><?php endif; ?>
<?php if ($error): ?><div class="auth-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<form method="POST" action="/password/change">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token'] ?? '') ?>">
<div class="form-group"><label class="form-label">Current Password</label><input type="password" name="current_password" class="form-control" required></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>
<div class="form-group"><label class="form-label">Confirm New Password</label><input type="password" name="confirm_password" class="form-control" required></div>
<button type="submit" class="btn btn-primary btn-block btn-lg">Change Password</button>
</form>
</div>
\ No newline at end of file
<?php $__engine->extend('layouts/app'); ?>
<?php $__engine->section('title'); ?>Boards<?php $__engine->endSection(); ?>
<?php /** @var array $user */ /** @var array $boards */ ?>
<div class="container">
<div class="page-header">
<h1>📋 Boards</h1>
<?php if (in_array($user['role'], ['super_admin', 'admin'])): ?>
<button class="btn btn-primary" onclick="document.getElementById('create-board-modal').style.display='block'">+ New Board</button>
<?php endif; ?>
</div>
<div class="dashboard-header">
<h1>📋 Boards</h1>
<?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>
<?php endif; ?>
</div>
<div class="board-grid">
<?php foreach ($boards as $board): ?>
<a href="/boards/<?= $board['id'] ?>" class="card board-card">
<h3><?= $__engine->e($board['name']) ?></h3>
<p class="text-muted"><?= $__engine->e($board['description'] ?? '') ?></p>
<div class="board-meta">
<span>🔑 <?= $__engine->e($board['board_key']) ?></span>
<span>👥 <?= $board['member_count'] ?> members</span>
<span>🃏 <?= $board['card_count'] ?> cards</span>
<div class="grid grid-3">
<?php foreach ($boards as $b): ?>
<a href="/boards/<?= $b['id'] ?>" class="card" style="text-decoration:none;">
<h3><?= htmlspecialchars($b['name']) ?></h3>
<div class="mono" style="color:var(--text-muted);font-size:0.8rem;"><?= htmlspecialchars($b['board_key']) ?></div>
<div style="margin-top:12px;display:flex;gap:16px;font-size:0.85rem;color:var(--text-secondary);">
<span>👥 <?= $b['member_count'] ?? 0 ?></span>
<span>🃏 <?= $b['card_count'] ?? 0 ?></span>
</div>
</a>
<?php endforeach; ?>
</div>
</div>
\ No newline at end of file
<?php endforeach; ?>
</div>
<?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 $__engine->section('title'); ?><?= $__engine->e($board['name']) ?><?php $__engine->endSection(); ?>
<?php /** @var array $user */ /** @var array $board */ /** @var array $columns */ /** @var array $cards_by_column */ /** @var array $members */ /** @var array $labels */ ?>
<div class="board-page">
<div class="board-header">
<h1><?= $__engine->e($board['name']) ?></h1>
<span class="board-key"><?= $__engine->e($board['board_key']) ?></span>
<span class="text-muted"><?= count($members) ?> members</span>
<div class="dashboard-header">
<h1><?= htmlspecialchars($board['name']) ?></h1>
<div class="flex gap-1">
<span class="badge badge-muted"><?= htmlspecialchars($board['board_key']) ?></span>
<span class="badge badge-info"><?= count($members) ?> members</span>
</div>
</div>
<div class="kanban-board" id="kanban">
<?php foreach ($columns as $column): ?>
<div class="kanban-column" data-column-id="<?= $column['id'] ?>" data-slug="<?= $__engine->e($column['slug']) ?>">
<div class="column-header">
<span class="column-icon"><?= $__engine->e($column['icon'] ?? '') ?></span>
<span class="column-name"><?= $__engine->e($column['name']) ?></span>
<span class="column-count"><?= count($cards_by_column[$column['id']] ?? []) ?></span>
<div style="display:flex;gap:16px;overflow-x:auto;padding-bottom:20px;min-height:60vh;">
<?php foreach ($columns as $col): ?>
<div style="min-width:280px;max-width:320px;flex-shrink:0;">
<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><?= $col['icon'] ?? '' ?> <?= htmlspecialchars($col['name']) ?></span>
<span class="badge badge-muted"><?= count($cards_by_column[$col['id']] ?? []) ?></span>
</div>
<div class="column-cards" data-column-id="<?= $column['id'] ?>">
<?php foreach (($cards_by_column[$column['id']] ?? []) as $card): ?>
<div class="kanban-card" data-card-id="<?= $card['id'] ?>" onclick="window.location='/cards/<?= $card['id'] ?>'">
<?php if (!empty($card['labels'])): ?>
<div class="card-labels">
<?php foreach (array_slice($card['labels'], 0, 3) as $label): ?>
<span class="label-pill" style="background:<?= $__engine->e($label['bg_color']) ?>;color:<?= $__engine->e($label['text_color']) ?>"><?= $__engine->e($label['text']) ?></span>
<?php endforeach; ?>
</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">
<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[$col['id']] ?? []) as $card): ?>
<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)'">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<span class="priority-dot priority-<?= $card['priority'] ?? 'none' ?>"></span>
<span class="mono" style="font-size:0.75rem;color:var(--text-muted)"><?= htmlspecialchars($card['card_key']) ?></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; ?>
</div>
<div style="font-size:0.85rem;"><?= htmlspecialchars($card['title']) ?></div>
<?php if ($card['deadline']): ?>
<span class="<?= strtotime($card['deadline']) < time() && !$card['done_at'] ? 'overdue' : '' ?>">
<?= date('M j', strtotime($card['deadline'])) ?>
</span>
<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>
<?php endif; ?>
<?php if ($card['bounty_amount']): ?>
<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>
</a>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
\ No newline at end of file
<?php $__engine->extend('layouts/app'); ?>
<?php $__engine->section('title'); ?><?= $__engine->e($card['card_key']) ?><?= $__engine->e($card['title']) ?><?php $__engine->endSection(); ?>
<?php /** @var array $user */ /** @var array $card */ /** @var array $board */ /** @var array $column */ /** @var array $columns */ ?>
<div class="card-detail">
<div class="card-detail-header">
<a href="/boards/<?= $card['board_id'] ?>" class="btn btn-sm btn-secondary"><?= $__engine->e($board['name']) ?></a>
<h1><?= $__engine->e($card['card_key']) ?>: <?= $__engine->e($card['title']) ?></h1>
<span class="badge badge-<?= $__engine->e($column['slug']) ?>"><?= $__engine->e($column['name']) ?></span>
<div class="dashboard-header">
<h1><span class="mono text-primary"><?= htmlspecialchars($card['card_key']) ?></span> <?= htmlspecialchars($card['title']) ?></h1>
<div class="flex gap-1">
<span class="badge badge-muted"><?= $column['name'] ?? '' ?></span>
<span class="badge badge-<?= $card['priority']==='critical'?'danger':($card['priority']==='high'?'warning':'info') ?>"><?= ucfirst($card['priority']) ?></span>
</div>
</div>
<div class="card-detail-body">
<div class="card-main">
<?php if ($card['description']): ?>
<div class="card-description">
<h3>Description</h3>
<div class="rich-text"><?= $card['description'] ?></div>
</div>
<?php endif; ?>
<div class="grid grid-2">
<div>
<div class="card mb-3">
<h3 class="mb-2">Description</h3>
<div style="color:var(--text-secondary)"><?= $card['description'] ? nl2br(htmlspecialchars($card['description'])) : '<em>No description</em>' ?></div>
</div>
<?php foreach ($card['checklists'] as $cl): ?>
<div class="checklist">
<h3>☑️ <?= $__engine->e($cl['name']) ?></h3>
<?php $total = count($cl['items']); $done = count(array_filter($cl['items'], fn($i) => $i['is_checked'])); ?>
<div class="progress-bar"><div style="width:<?= $total > 0 ? round(($done/$total)*100) : 0 ?>%"></div></div>
<p class="text-muted"><?= $done ?>/<?= $total ?> complete</p>
<?php foreach ($cl['items'] as $item): ?>
<label class="checklist-item">
<input type="checkbox" <?= $item['is_checked'] ? 'checked' : '' ?>
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>
<!-- Checklists -->
<?php foreach ($card['checklists'] ?? [] as $cl): ?>
<div class="card mb-3">
<h3 class="mb-1"><?= htmlspecialchars($cl['name']) ?></h3>
<?php foreach ($cl['items'] ?? [] as $item): ?>
<div class="task-item">
<span style="font-size:1.2rem;"><?= $item['is_checked'] ? '✅' : '⬜' ?></span>
<span style="<?= $item['is_checked'] ? 'text-decoration:line-through;color:var(--text-muted)' : '' ?>"><?= htmlspecialchars($item['text']) ?></span>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
<?php endforeach; ?>
<div class="card-comments">
<h3>💬 Comments & Activity</h3>
<?php foreach ($card['comments'] as $comment): ?>
<div class="comment">
<strong><?= $__engine->e($comment['author_name']) ?></strong>
<span class="text-muted"><?= date('M j, H:i', strtotime($comment['created_at'])) ?></span>
<?php if ($comment['edited_at']): ?><span class="text-muted">(edited)</span><?php endif; ?>
<div class="comment-content"><?= nl2br($__engine->e($comment['content'])) ?></div>
<!-- Comments -->
<div class="card">
<h3 class="mb-2">💬 Comments (<?= count($card['comments'] ?? []) ?>)</h3>
<?php foreach ($card['comments'] ?? [] as $comment): ?>
<div style="border-bottom:1px solid var(--border-color);padding:10px 0;">
<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>
<div style="margin-top:4px;color:var(--text-secondary)"><?= nl2br(htmlspecialchars($comment['content'])) ?></div>
</div>
<?php endforeach; ?>
<?php endforeach; ?>
</div>
</div>
<form method="POST" action="/cards/<?= $card['id'] ?>/comments" class="comment-form">
<input type="hidden" name="_csrf_token" value="<?= $__engine->e($_COOKIE['csrf_token'] ?? '') ?>">
<textarea name="content" placeholder="Add a comment..." rows="3" required></textarea>
<button type="submit" class="btn btn-primary btn-sm">Comment</button>
</form>
</div>
<div>
<div class="card mb-3">
<h3 class="mb-2">Details</h3>
<div class="stat-row"><span>Board</span><strong><a href="/boards/<?= $board['id'] ?>"><?= htmlspecialchars($board['name']) ?></a></strong></div>
<div class="stat-row"><span>Column</span><strong><?= htmlspecialchars($column['name'] ?? '') ?></strong></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 class="card-sidebar">
<div class="sidebar-section">
<h4>Assignees</h4>
<?php foreach ($card['assignees'] as $a): ?>
<div class="assignee-row"><?= $__engine->e($a['full_name_en']) ?></div>
<?php endforeach; ?>
</div>
<div class="sidebar-section">
<h4>Labels</h4>
<?php foreach ($card['labels'] as $l): ?>
<span class="label-pill" style="background:<?= $__engine->e($l['bg_color']) ?>;color:<?= $__engine->e($l['text_color']) ?>"><?= $__engine->e($l['text']) ?></span>
<div class="card mb-3">
<h3 class="mb-2">👥 Assignees</h3>
<?php foreach ($card['assignees'] ?? [] as $a): ?>
<div class="task-item"><a href="/users/<?= $a['id'] ?>"><?= htmlspecialchars($a['full_name_en']) ?></a></div>
<?php endforeach; ?>
<?php if (empty($card['assignees'])): ?><div class="text-muted">No assignees</div><?php endif; ?>
</div>
<div class="card mb-3">
<h3 class="mb-2">🏷️ Labels</h3>
<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; ?>
</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>
\ 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 */ ?>
<div class="entity-manager">
<div class="page-header">
<h1><?= htmlspecialchars($config['label']) ?> Management</h1>
<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>
<?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="dashboard-header"><h1>⚙️ <?= $config['label'] ?? ucfirst($entity) ?></h1><a href="/control-panel" class="btn btn-ghost btn-sm">← Back</a></div>
<div class="card mb-2">
<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>
</div>
<script>
function toggleSelectAll(el) {
document.querySelectorAll('.row-checkbox').forEach(cb => cb.checked = el.checked);
updateBulkBar();
}
document.querySelectorAll('.row-checkbox').forEach(cb => cb.addEventListener('change', updateBulkBar));
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
<div class="card">
<table class="data-table"><thead><tr><?php foreach ($config['columns'] as $col): ?><th><?= $col ?></th><?php endforeach; ?></tr></thead><tbody>
<?php foreach ($rows as $row): ?><tr><?php foreach ($config['columns'] as $col): ?><td><?= htmlspecialchars((string)($row[$col] ?? '')) ?></td><?php endforeach; ?></tr><?php endforeach; ?>
</tbody></table>
<?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>
\ No newline at end of file
<?php /** @var array $user */ /** @var array $counts */ ?>
<div class="control-panel">
<div class="page-header">
<h1>🎮 Super Admin Control Panel</h1>
<p class="subtitle">God mode. Every entity. Full CRUD. No limits.</p>
</div>
<div class="entity-grid">
<?php
$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 class="dashboard-header"><h1>⚙️ Control Panel</h1></div>
<div class="grid grid-4">
<?php foreach ($counts as $entity => $count): ?>
<a href="/control-panel/<?= $entity ?>" class="stat-card stat-primary" style="text-decoration:none;">
<span class="stat-label"><?= ucfirst(str_replace('_',' ',$entity)) ?></span>
<span class="stat-value"><?= number_format($count) ?></span>
</a>
<?php endforeach; ?>
</div>
\ No newline at end of file
<?php $__engine->extend('layouts/app'); ?>
<?php $__engine->section('title'); ?>Dashboard<?php $__engine->endSection(); ?>
<?php
/** @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">
<h1>Welcome, <?= $__engine->e($user['full_name_en']) ?>!</h1>
<div class="dashboard-grid">
<!-- 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>
<div class="dashboard-header">
<h1>📊 My Dashboard</h1>
<span class="dashboard-date"><?= date('l, F j, Y') ?></span>
</div>
<!-- Upcoming Deadlines -->
<div class="card">
<h3>⏰ Upcoming Deadlines</h3>
<?php if (empty($upcoming_deadlines)): ?>
<p class="text-muted">No upcoming deadlines.</p>
<?php else: ?>
<?php foreach (array_slice($upcoming_deadlines, 0, 5) as $task): ?>
<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; ?>
<!-- HUD -->
<?php if (!empty($hud) && ($hud['actual_salary'] ?? 0) > 0): ?>
<div class="hud-container">
<div class="hud-top">
<div>
<div class="hud-salary-label">Live Salary — <?= $hud['month_label'] ?? $month ?></div>
<div class="hud-salary-value <?= $hud['color_class'] ?? '' ?>" id="hud-live-salary"><?= number_format($hud['live_salary'] ?? 0, 2) ?></div>
</div>
<!-- Month Stats -->
<div class="card">
<h3>📊 This Month</h3>
<div class="stat-row">
<span>Reports Submitted</span>
<strong><?= $reports_submitted ?? 0 ?></strong>
</div>
<div>
<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>
</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 -->
<?php if (!empty($learning_goals)): ?>
<div class="card">
<h3>📚 Learning Goals</h3>
<?php foreach ($learning_goals as $goal): ?>
<div class="goal-item">
<strong><?= $__engine->e($goal['title']) ?></strong>
<br><small><?= $__engine->e($goal['competency_name']) ?> · Due: <?= date('M j', strtotime($goal['deadline'])) ?></small>
<span class="badge badge-<?= $goal['status'] ?>"><?= ucfirst($goal['status']) ?></span>
</div>
<?php endforeach; ?>
<!-- Today's Report -->
<div class="card mb-3">
<div class="card-header">
<span class="card-title">📝 Today's Report</span>
<?php if (!$today_report || in_array($today_report['status'] ?? '', ['draft','unreported',''])): ?>
<a href="/reports/submit" class="btn btn-primary btn-sm">Submit Report</a>
<?php else: ?>
<span class="badge badge-success">✅ Submitted</span>
<?php endif; ?>
</div>
<?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>
<?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; ?>
</div>
<!-- Recent Notifications -->
<div class="card">
<h3>🔔 Recent Notifications</h3>
<?php foreach ($notifications ?? [] as $notif): ?>
<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>
<!-- Upcoming Deadlines -->
<div class="card">
<div class="card-header">
<span class="card-title">⏰ Upcoming Deadlines</span>
</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>
\ No newline at end of file
<!DOCTYPE html>
<html><head><title>404 Not Found</title><link rel="stylesheet" href="/assets/css/app.css"></head>
<!DOCTYPE html><html><head><title>404</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>
\ No newline at end of file
<?php $this->layout('layouts/app', ['title' => 'Evaluation Details']); ?>
<div class="container mx-auto px-4 py-6">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">Evaluation: <?= htmlspecialchars($compiled['month']) ?></h1>
<a href="/evaluations" class="text-gray-400 hover:text-white">← Back</a>
</div>
<div class="bg-gray-800 rounded-lg p-6 mb-6">
<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; ?>
<?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="card mb-3" style="text-align:center;">
<div style="font-size:3rem;font-weight:800;color:var(--accent-primary)"><?= $compiled['overall_score'] ?>/5.00</div>
<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>
</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="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>
\ No newline at end of file
<?php $this->layout('layouts/app', ['title' => 'My Evaluations']); ?>
<div class="container mx-auto px-4 py-6">
<h1 class="text-2xl font-bold mb-6">My Evaluations</h1>
<?php if (empty($evaluations)): ?>
<div class="bg-gray-800 rounded-lg p-8 text-center text-gray-400">
<p>No evaluations yet. They are compiled at the end of each month.</p>
</div>
<?php else: ?>
<div class="space-y-4">
<?php foreach ($evaluations as $eval): ?>
<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; ?>
<?php /** @var array $user */ /** @var array $evaluations */ ?>
<div class="dashboard-header"><h1>📊 My Evaluations</h1></div>
<div class="card">
<?php if (empty($evaluations)): ?>
<div class="empty-state"><div class="empty-state-icon">📊</div><div>No evaluations yet</div></div>
<?php else: ?>
<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>
<?php foreach ($evaluations as $e): ?>
<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 endforeach; ?>
</tbody></table>
<?php endif; ?>
</div>
\ No newline at end of file
<?php $this->layout('layouts/app', ['title' => 'Professional Evaluation']); ?>
<div class="container mx-auto px-4 py-6">
<h1 class="text-2xl font-bold mb-2">Professional Evaluation</h1>
<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 /** @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="card"><p>Evaluation form for professional criteria. Submit scores via API.</p><pre><?= json_encode($auto_scores, JSON_PRETTY_PRINT) ?></pre></div>
\ No newline at end of file
<?php $this->layout('layouts/app', ['title' => 'Technical Evaluation']); ?>
<div class="container mx-auto px-4 py-6">
<h1 class="text-2xl font-bold mb-2">Technical Evaluation</h1>
<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 /** @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="card"><p>Evaluation form for technical criteria. Submit scores via API.</p><pre><?= json_encode($auto_scores, JSON_PRETTY_PRINT) ?></pre></div>
\ No newline at end of file
<?php $this->layout('layouts/app', ['title' => 'Meetings']); ?>
<div class="container mx-auto px-4 py-6">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">Meetings</h1>
<?php if (in_array($user['role'], ['super_admin', 'admin', 'project_leader'])): ?>
<button class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded" onclick="document.getElementById('new-meeting').classList.toggle('hidden')">
+ Schedule Meeting
</button>
<?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>
<?php /** @var array $user */ /** @var array $meetings */ ?>
<div class="dashboard-header"><h1>📅 Meetings</h1></div>
<div class="card">
<?php if (empty($meetings)): ?>
<div class="empty-state"><div class="empty-state-icon">📅</div><div>No meetings scheduled</div></div>
<?php else: ?>
<?php foreach ($meetings as $m): ?>
<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>
<?php endforeach; ?>
<?php endif; ?>
</div>
\ No newline at end of file
<?php $__engine->extend('layouts/app'); ?>
<?php $__engine->section('title'); ?>Messages<?php $__engine->endSection(); ?>
<div class="container">
<div class="page-header">
<h1>💬 Messages</h1>
<button class="btn btn-primary" id="new-conversation-btn">+ New Conversation</button>
</div>
<div class="conversation-list">
<?php if (empty($conversations)): ?>
<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>
<?php /** @var array $user */ /** @var array $conversations */ ?>
<div class="dashboard-header"><h1>💬 Messages</h1></div>
<div class="card">
<?php if (empty($conversations)): ?>
<div class="empty-state"><div class="empty-state-icon">💬</div><div>No conversations yet</div></div>
<?php else: ?>
<?php foreach ($conversations as $c): ?>
<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>
<span class="task-title" style="color:var(--text-secondary)"><?= htmlspecialchars(substr($c['last_message'] ?? '', 0, 80)) ?></span>
<?php if (($c['unread_count'] ?? 0) > 0): ?><span class="badge badge-primary"><?= $c['unread_count'] ?></span><?php endif; ?>
</a>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
\ No newline at end of file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>⚠️ Action Required</title>
<link rel="stylesheet" href="/assets/css/app.css">
</head>
<body class="blocking-page">
<div class="blocking-overlay">
<div class="blocking-card">
<div class="blocking-icon">⚠️</div>
<h1><?= htmlspecialchars($notification['title'] ?? 'Action Required') ?></h1>
<div class="blocking-content">
<?= nl2br(htmlspecialchars($notification['content'] ?? '')) ?>
</div>
<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>
<?php /** @var array $user */ /** @var array $notification */ ?>
<div style="max-width:600px;margin:40px auto;text-align:center;">
<div class="card" style="border-color:var(--danger);">
<div style="font-size:3rem;margin-bottom:16px;">🚨</div>
<h2 style="margin-bottom:8px;"><?= htmlspecialchars($notification['title'] ?? 'Action Required') ?></h2>
<p style="color:var(--text-secondary);margin-bottom:24px;"><?= htmlspecialchars($notification['content'] ?? '') ?></p>
<?php if (!empty($notification['link_url'])): ?>
<a href="<?= htmlspecialchars($notification['link_url']) ?>" class="btn btn-primary btn-lg" style="margin-bottom:12px;">View Details</a>
<?php endif; ?>
<form method="POST" action="/notifications/<?= $notification['id'] ?>/acknowledge" style="margin-top:12px;">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token'] ?? '') ?>">
<button type="submit" class="btn btn-success btn-lg btn-block">✅ I Acknowledge</button>
</form>
</div>
</body>
</html>
\ No newline at end of file
</div>
\ No newline at end of file
<?php $__engine->extend('layouts/app'); ?>
<?php $__engine->section('title'); ?>Notifications<?php $__engine->endSection(); ?>
<?php /** @var array $user */ /** @var array $notifications */ /** @var int $unread_count */ ?>
<div class="container">
<div class="page-header">
<h1>🔔 Notifications</h1>
<form method="POST" action="/notifications/read-all">
<input type="hidden" name="_csrf_token" value="<?= $__engine->e($_COOKIE['csrf_token'] ?? '') ?>">
<button type="submit" class="btn btn-sm btn-secondary">Mark All as Read</button>
</form>
</div>
<div class="dashboard-header">
<h1>🔔 Notifications</h1>
<?php if ($unread_count > 0): ?>
<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>
<?php endif; ?>
</div>
<div class="notification-list">
<?php foreach ($notifications as $notif): ?>
<div class="notification-item <?= $notif['is_read'] ? 'read' : 'unread' ?> tier-<?= $__engine->e($notif['tier']) ?>">
<div class="notif-header">
<strong><?= $__engine->e($notif['title']) ?></strong>
<span class="notif-time"><?= date('M j, H:i', strtotime($notif['created_at'])) ?></span>
<div class="card">
<?php if (empty($notifications)): ?>
<div class="empty-state"><div class="empty-state-icon">🔕</div><div>No notifications yet</div></div>
<?php else: ?>
<?php foreach ($notifications as $n): ?>
<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>
<p><?= $__engine->e($notif['content']) ?></p>
<?php if ($notif['link_url']): ?>
<a href="<?= $__engine->e($notif['link_url']) ?>" class="btn btn-sm btn-link">View →</a>
<?php endif; ?>
<span class="task-meta"><?= date('M j, g:ia', strtotime($n['created_at'])) ?></span>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
\ No newline at end of file
<?php $this->layout('layouts/app', ['title' => 'PIP Details']); ?>
<div class="container mx-auto px-4 py-6">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">PIP: <?= htmlspecialchars($pip['contractor_name']) ?></h1>
<a href="/pips" class="text-gray-400 hover:text-white">← Back</a>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 space-y-6">
<div class="bg-gray-800 rounded-lg p-6">
<h2 class="font-semibold mb-4">Specific Issues</h2>
<?php $issues = json_decode($pip['specific_issues'], true) ?? []; ?>
<ol class="list-decimal list-inside space-y-2">
<?php foreach ($issues as $issue): ?>
<li class="text-gray-300"><?= htmlspecialchars($issue) ?></li>
<?php endforeach; ?>
</ol>
</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>
<?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="grid grid-2">
<div class="card">
<h3 class="mb-2">Details</h3>
<div class="stat-row"><span>Contractor</span><strong><?= htmlspecialchars($pip['contractor_name'] ?? '') ?></strong></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="stat-row"><span>Success Criteria</span><strong><?= htmlspecialchars($pip['success_criteria'] ?? '') ?></strong></div>
<h3 class="mb-1 mt-3">Targets</h3>
<?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; ?>
</div>
<div class="card">
<h3 class="mb-2">Check-ins</h3>
<?php foreach ($checkins as $ci): ?>
<div style="border-bottom:1px solid var(--border-color);padding:8px 0;">
<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>
<?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="space-y-6">
<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>
<?php endforeach; ?>
</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