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;
$db = Container::getInstance()->resolve(Connection::class);
// 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'],
];
$sql = file_get_contents($seedFile);
$statements = array_filter(array_map('trim', explode(';', $sql)), fn($s) => strlen($s) > 5);
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";
}
}
$success = 0;
$skipped = 0;
foreach ($statements as $stmt) {
if (stripos($stmt, 'USE ') === 0 || stripos($stmt, '--') === 0 || stripos($stmt, 'SET ') === 0) {
continue;
// 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,
]);
}
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";
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 FROM users
WHERE contract_end_date = ? AND status = 'active' AND is_active = 1",
[$targetDate]
"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'"
);
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']);
}
$admins = $db->fetchAll("SELECT id FROM users WHERE role IN ('super_admin','admin') AND is_active = 1");
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']]
);
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']]);
}
}
}
}
}
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,
$sub = match(true) {
$daysLate >= 15 => 'A4',
$daysLate >= 8 => 'A3',
$daysLate >= 4 => 'A2',
$daysLate >= 1 => 'A1',
default => null,
};
}
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]
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]
);
$workDows = array_column($schedule, 'day_of_week');
if (empty($workDows)) return 22;
if ($existing) continue;
$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;
}
// Auto-deduction would be created here by the deduction system
// For now, just log that escalation was detected
}
$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;
}
$cycles = $db->fetchAll("SELECT * FROM evaluation_cycles WHERE status IN ('open','technical_phase','professional_phase')");
foreach ($cycles as $cycle) {
$contractors = $db->fetchAll(
"SELECT DISTINCT contractor_id FROM evaluations WHERE cycle_id = ?",
[$activeCycle['id']]
"SELECT DISTINCT contractor_id FROM evaluations WHERE cycle_id = ?", [$cycle['id']]
);
$allCompiled = true;
foreach ($contractors as $c) {
$contractorId = $c['contractor_id'];
$cid = $c['contractor_id'];
$alreadyCompiled = $db->fetchOne(
"SELECT id FROM compiled_evaluations WHERE cycle_id = ? AND contractor_id = ?",
[$activeCycle['id'], $contractorId]
);
if ($alreadyCompiled) {
continue;
}
$already = $db->fetchOne("SELECT id FROM compiled_evaluations WHERE cycle_id = ? AND contractor_id = ?", [$cycle['id'], $cid]);
if ($already) 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]
);
$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]);
if (!$techEval || !$techEval['submitted_at'] || !$profEval || !$profEval['submitted_at']) {
$allCompiled = false;
continue;
}
if (!$tech || !$prof) continue;
$techScore = (float)$techEval['total_score'];
$profScore = (float)$profEval['total_score'];
$techScore = (float)$tech['total_score'];
$profScore = (float)$prof['total_score'];
$overall = round(($techScore * $weights['technical']) + ($profScore * $weights['professional']), 2);
$overallResult = $calc->calculate('overall_eval_score', [
'technical_score' => $techScore,
'professional_score' => $profScore,
'tech_weight' => 0.5,
'prof_weight' => 0.5,
]);
$contractor = $db->fetchOne("SELECT * FROM users WHERE id = ?", [$contractorId]);
$systemMetrics = [
'month' => $activeCycle['month'],
'actual_salary' => $contractor['actual_salary'] ?? 0,
'technical_score' => $techScore,
'professional_score' => $profScore,
];
if ($calc->has('technical_auto_score')) {
$systemMetrics['tech_auto'] = $calc->calculate('technical_auto_score', [
'contractor_id' => $contractorId,
'month' => $activeCycle['month'],
]);
$rating = 'adequate';
foreach ($ratings as $r) {
if ($overall >= $r['min'] && $overall <= $r['max']) {
$rating = $r['rating'];
break;
}
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,
$db->insert('compiled_evaluations', [
'cycle_id' => $cycle['id'],
'contractor_id' => $cid,
'technical_score' => $techScore,
'professional_score' => $profScore,
'overall_score' => $overallResult['overall_score'],
'rating' => $overallResult['rating'],
'system_metrics_json' => json_encode($systemMetrics),
'overall_score' => $overall,
'rating' => $rating,
'system_metrics_json' => json_encode(['tech' => $techScore, 'prof' => $profScore]),
'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"
$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']]
);
if (!$activeCycle) {
return;
foreach ($pending as $eval) {
$notif->createImportant($eval['evaluator_id'], 'Evaluation Pending',
"Please submit {$eval['type']} evaluation for {$eval['contractor_name']} ({$cycle['month']}).",
'/evaluations/pending');
}
$now = time();
$techDeadline = strtotime($activeCycle['tech_deadline']);
$profDeadline = strtotime($activeCycle['prof_deadline']);
$daysTilTech = (int)ceil(($techDeadline - $now) / 86400);
$daysTilProf = (int)ceil(($profDeadline - $now) / 86400);
if ($daysTilTech <= 2 && $daysTilTech >= 0) {
$pendingTech = $db->fetchAll(
"SELECT DISTINCT evaluator_id FROM evaluations
WHERE cycle_id = ? AND type = 'technical' AND submitted_at IS NULL",
[$activeCycle['id']]
);
foreach ($pendingTech as $e) {
$count = (int)$db->fetchColumn(
"SELECT COUNT(*) FROM evaluations
WHERE cycle_id = ? AND type = 'technical' AND evaluator_id = ? AND submitted_at IS NULL",
[$activeCycle['id'], $e['evaluator_id']]
);
$urgency = $daysTilTech === 0 ? '🚨 DUE TODAY' : "⏰ {$daysTilTech} days remaining";
$notif->createImportant($e['evaluator_id'], "Technical Evaluations {$urgency}",
"You have {$count} technical evaluation(s) pending for {$activeCycle['month']}. Deadline: " . date('M j', $techDeadline),
'/evaluations/pending', 'evaluation_cycle', $activeCycle['id']);
}
}
if ($daysTilProf <= 2 && $daysTilProf >= 0) {
$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']);
}
}
}
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;
}
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'));
// 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->beginTransaction();
try {
$cycleId = $db->insert('evaluation_cycles', [
$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,
'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')),
]);
}
$db->commit();
$evaluators = $db->fetchAll(
"SELECT DISTINCT evaluator_id FROM evaluations WHERE cycle_id = ?",
[$cycleId]
);
foreach ($evaluators as $e) {
$notif->createImportant($e['evaluator_id'], 'Evaluation Cycle Opened',
"The evaluation cycle for {$month} is now open. Please submit your evaluations.",
'/evaluations/pending', 'evaluation_cycle', $cycleId);
}
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
}
public function schedule(): string { return 'daily'; }
}
\ No newline at end of file
......@@ -10,81 +10,25 @@ use Engine\Notifications\NotificationManager;
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']);
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']);
}
if ($daysRemaining < 0 && $goal['status'] === 'active') {
$db->update('learning_goals', ['status' => 'overdue'], 'id = ?', [(int)$goal['id']]);
$notif->createImportant($goal['contractor_id'], 'Learning Goal Overdue',
"Your learning goal \"{$goal['title']}\" is now overdue.",
'/learning-goals', 'learning_goal', (int)$goal['id']);
$recipients = [];
if ($goal['assigned_pl_id']) {
$recipients[] = $goal['assigned_pl_id'];
}
$admins = $db->fetchAll("SELECT id FROM users WHERE role IN ('super_admin','admin') AND is_active = 1");
foreach ($admins as $a) {
$recipients[] = $a['id'];
}
foreach (array_unique($recipients) as $rid) {
$notif->createImportant($rid, 'Learning Goal Overdue',
"{$goal['contractor_name']}'s learning goal \"{$goal['title']}\" is overdue.",
"/users/{$goal['contractor_id']}", 'learning_goal', (int)$goal['id']);
}
$daysOverdue = abs($daysRemaining);
$initialDeadlineDays = $goal['is_auto_generated'] ? 45 : null;
if ($initialDeadlineDays && $daysOverdue >= $initialDeadlineDays) {
$admins = $db->fetchAll("SELECT id FROM users WHERE role = 'super_admin' AND is_active = 1");
foreach ($admins as $a) {
$notif->createImportant($a['id'], '🚨 Learning Goal — Double Deadline Exceeded',
"{$goal['contractor_name']}'s auto-generated learning goal \"{$goal['title']}\" has exceeded double the original deadline. Termination review required.",
"/users/{$goal['contractor_id']}", 'user', (int)$goal['contractor_id']);
}
}
}
}
}
public function schedule(): string { return 'daily'; }
}
\ No newline at end of file
......@@ -10,81 +10,23 @@ use Engine\Notifications\NotificationManager;
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']);
$notif->createImportant($inv['user_id'], 'Meeting Tomorrow',
"Meeting: {$m['title']} — Tomorrow at {$m['start_time']}",
"/meetings/{$m['id']}", 'meeting', $m['id']);
}
}
}
// 1-hour reminders: meetings today within the next hour
$oneHourFromNow = date('H:i:s', strtotime('+1 hour'));
$soonMeetings = $db->fetchAll(
"SELECT m.* FROM meetings m
WHERE m.meeting_date = ? AND m.status = 'scheduled'
AND m.start_time > ? AND m.start_time <= ?",
[$today, $currentTime, $oneHourFromNow]
);
foreach ($soonMeetings as $m) {
$invitees = $db->fetchAll(
"SELECT user_id FROM meeting_invitees WHERE meeting_id = ?",
[$m['id']]
);
foreach ($invitees as $inv) {
$alreadySent = $db->fetchOne(
"SELECT id FROM notifications WHERE user_id = ? AND link_entity_type = 'meeting'
AND link_entity_id = ? AND title LIKE '%1 hour%' AND created_at >= ?",
[$inv['user_id'], $m['id'], date('Y-m-d 00:00:00')]
);
if (!$alreadySent) {
$notif->createImportant($inv['user_id'], "Meeting in 1 hour: {$m['title']}",
"Starting at {$m['start_time']}." .
($m['location'] ? " Location: {$m['location']}" : ''),
"/meetings/{$m['id']}", 'meeting', (int)$m['id']);
}
}
}
}
public function schedule(): string { return 'daily'; }
}
\ No newline at end of file
......@@ -9,17 +9,11 @@ use Engine\Database\Connection;
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
"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 IN (?, ?)
AND pc.logged_at IS NULL
AND p.status = 'active'
AND p.deleted_at IS NULL",
[$today, $tomorrow]
WHERE pc.scheduled_date = ? AND pc.logged_at IS NULL AND p.status IN ('acknowledged','active') AND p.deleted_at IS NULL",
[$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']);
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']);
}
}
$missedCheckins = $db->fetchAll(
"SELECT pc.*, p.contractor_id, u.full_name_en as contractor_name
FROM pip_checkins pc
JOIN pips p ON p.id = pc.pip_id
JOIN 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]
);
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']);
}
}
}
}
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) ?? [];
$defs = $db->fetchAll("SELECT * FROM recurring_card_definitions WHERE is_active = 1 AND (next_creation_at IS NULL OR next_creation_at <= NOW())");
$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'];
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'",
[(int)$def['board_id']]
);
$backlogCol = $db->fetchOne("SELECT id FROM board_columns WHERE board_id = ? AND slug = 'backlog'", [$board['id']]);
if (!$backlogCol) continue;
if (!$backlogCol) {
$db->rollBack();
continue;
}
$template = json_decode($def['card_template_json'], true) ?: [];
$dateStr = date('M j, Y');
$title = ($template['title'] ?? 'Recurring Task') . " — {$dateStr}";
$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' => (int)$def['board_id'],
'board_id' => $board['id'],
'column_id' => $backlogCol['id'],
'card_number' => $updatedBoard['card_sequence'],
'card_number' => $updated['card_sequence'],
'card_key' => $cardKey,
'title' => $title,
'title' => $template['title'] ?? 'Recurring Task',
'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'],
]);
$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,
'created_by_id' => $def['created_by_id'],
]);
}
}
}
}
$assignees = $def['assignees_json'] ? json_decode($def['assignees_json'], true) : [];
foreach ($assignees as $assigneeId) {
// Assign users
$assignees = json_decode($def['assignees_json'] ?? '[]', true) ?: [];
foreach ($assignees as $uid) {
$db->insert('card_assignments', [
'card_id' => $cardId,
'user_id' => (int)$assigneeId,
'assigned_by_id' => (int)$def['created_by_id'],
'card_id' => $cardId, 'user_id' => (int)$uid, 'assigned_by_id' => $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();
// 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),
};
} catch (\Throwable $e) {
$db->rollBack();
error_log("CreateRecurringCardsJob error for def {$def['id']}: " . $e->getMessage());
}
$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']]);
}
}
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));
}
}
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,
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';
$workDays = 0;
foreach ($schedule as $s) {
if ($s['work_mode'] !== 'off') $workDays++;
}
// Get day rates from system settings
$rates = $this->getDayRates($contractorType);
$baseSalary = 0.00;
foreach ($schedule as $day) {
if ($day['work_mode'] === 'in_office') {
$baseSalary += $rates['in_office'];
} elseif ($day['work_mode'] === 'remote') {
$baseSalary += $rates['remote'];
}
}
$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);
return round($baseSalary, 2);
}
private function getDayRates(string $contractorType): array
{
$prefix = $contractorType === 'intern' ? 'intern' : 'full_timer';
$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());
}
$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();
}
}
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('+');
const search = document.getElementById('global-search');
if (search) search.focus();
}
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,
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));
},
body: action.body ? JSON.stringify(action.body) : undefined,
credentials: 'same-origin',
});
if (!response.ok && response.status >= 500) {
remaining.push(action);
}
} catch (e) {
remaining.push(action);
break;
}
}
saveQueue(remaining);
if (remaining.length > 0 && remaining.length < queue.length) {
setTimeout(processQueue, 5000);
}
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); }
}
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 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
<script>
const dataSources = <?= json_encode($data_sources) ?>;
let lastResults = [];
function updateSourceColumns() {
const source = document.getElementById('rb-source').value;
if (!source) { document.getElementById('rb-columns-section').style.display = 'none'; return; }
document.getElementById('rb-columns-section').style.display = 'block';
const ds = dataSources[source];
const cols = Object.keys(ds.columns);
document.getElementById('rb-columns').innerHTML = cols.map(c => '<label class="checkbox-label"><input type="checkbox" value="' + c + '" checked> ' + c + '</label>').join('');
const opts = cols.map(c => '<option value="' + c + '">' + c + '</option>').join('');
document.getElementById('rb-groupby').innerHTML = '<option value="">None</option>' + opts;
document.getElementById('rb-sortby').innerHTML = opts;
document.getElementById('rb-aggcol').innerHTML = (ds.aggregatable || []).map(c => '<option value="' + c + '">' + c + '</option>').join('');
}
function addFilter() {
const source = document.getElementById('rb-source').value;
if (!source) return;
const cols = Object.keys(dataSources[source].columns);
const div = document.createElement('div');
div.className = 'filter-row';
div.innerHTML = '<select class="f-col form-control">' + cols.map(c => '<option>' + c + '</option>').join('') + '</select>' +
'<select class="f-op form-control"><option>=</option><option>!=</option><option>></option><option><</option><option>>=</option><option><=</option><option>LIKE</option><option>IS NULL</option><option>IS NOT NULL</option></select>' +
'<input class="f-val form-control" placeholder="value">' +
'<button onclick="this.parentElement.remove()" class="btn btn-xs btn-danger">×</button>';
document.getElementById('rb-filters').appendChild(div);
}
function executeReport() {
const source = document.getElementById('rb-source').value;
const columns = Array.from(document.querySelectorAll('#rb-columns input:checked')).map(cb => cb.value);
const filters = Array.from(document.querySelectorAll('.filter-row')).map(row => ({
column: row.querySelector('.f-col').value,
operator: row.querySelector('.f-op').value,
value: row.querySelector('.f-val').value,
}));
fetch('/analytics/report-builder/execute', {
method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]')?.content || ''},
body: JSON.stringify({
source, columns, filters,
group_by: document.getElementById('rb-groupby').value,
aggregation: document.getElementById('rb-aggregation').value,
agg_column: document.getElementById('rb-aggcol').value,
sort_by: document.getElementById('rb-sortby').value,
sort_dir: document.getElementById('rb-sortdir').value,
limit: parseInt(document.getElementById('rb-limit').value),
}), credentials: 'same-origin'
}).then(r => r.json()).then(data => {
lastResults = data.rows || [];
document.getElementById('rb-results').style.display = 'block';
document.getElementById('rb-count').textContent = '(' + data.count + ' rows)';
const table = document.getElementById('rb-results-table');
if (lastResults.length === 0) { table.innerHTML = '<tr><td>No results.</td></tr>'; return; }
const headers = Object.keys(lastResults[0]);
table.innerHTML = '<thead><tr>' + headers.map(h => '<th>' + h + '</th>').join('') + '</tr></thead><tbody>' +
lastResults.map(row => '<tr>' + headers.map(h => '<td>' + (row[h] ?? '') + '</td>').join('') + '</tr>').join('') + '</tbody>';
});
}
function exportResults() {
if (!lastResults.length) return;
const headers = Object.keys(lastResults[0]);
let csv = headers.join(',') + '\n';
lastResults.forEach(row => { csv += headers.map(h => '"' + String(row[h] ?? '').replace(/"/g, '""') + '"').join(',') + '\n'; });
const blob = new Blob([csv], {type: 'text/csv'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'report_' + new Date().toISOString().slice(0, 10) + '.csv';
a.click();
}
</script>
\ No newline at end of file
<?php /** @var array $user */ /** @var array $api_keys */ ?>
<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>
<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><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>
<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>
</tbody></table>
<?php endif; ?>
</div>
\ No newline at end of file
<div id="create-key-modal" class="modal-overlay" style="display:none">
<div class="modal-content">
<h3>Generate API Key</h3>
<form onsubmit="createKey(event)">
<div class="form-group"><label>Name</label><input type="text" name="name" required class="form-control" placeholder="My Integration"></div>
<div class="form-group"><label>Scope</label>
<select name="scope" class="form-control">
<option value="read_only">Read Only</option>
<option value="read_write">Read/Write</option>
<option value="admin">Admin</option>
</select>
</div>
<div class="form-group"><label>Rate Limit (per hour)</label><input type="number" name="rate_limit" value="1000" class="form-control"></div>
<button type="submit" class="btn btn-primary">Generate</button>
<button type="button" onclick="this.closest('.modal-overlay').style.display='none'" class="btn btn-secondary">Cancel</button>
</form>
</div>
</div>
<script>
function createKey(e) {
e.preventDefault();
const form = e.target;
fetch('/api-keys', {
method: 'POST', headers: {'Content-Type':'application/json','X-CSRF-Token': document.querySelector('meta[name=csrf-token]')?.content||''},
body: JSON.stringify({name: form.name.value, scope: form.scope.value, rate_limit_per_hour: parseInt(form.rate_limit.value)}),
credentials: 'same-origin'
}).then(r => r.json()).then(data => {
if (data.api_key) {
prompt('Your API Key (COPY NOW — shown once only):', data.api_key);
}
location.reload();
});
}
function revokeKey(id) {
if (!confirm('Revoke this API key? It will stop working immediately.')) return;
fetch('/api-keys/' + id + '/revoke', {method:'POST', headers:{'X-CSRF-Token': document.querySelector('meta[name=csrf-token]')?.content||''}, credentials:'same-origin'}).then(() => location.reload());
}
function deleteKey(id) {
if (!confirm('Permanently delete this API key record?')) return;
fetch('/api-keys/' + id, {method:'DELETE', headers:{'X-CSRF-Token': document.querySelector('meta[name=csrf-token]')?.content||''}, credentials:'same-origin'}).then(() => location.reload());
}
</script>
\ No newline at end of file
<?php /** @var array $user */ /** @var array $entries */ /** @var int $total */ /** @var int $page */ /** @var int $last_page */ /** @var array $actions */ /** @var array $entity_types */ /** @var array $modules */ ?>
<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; ?>
<?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="<?= $__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>
<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>
</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">
<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='block'">+ New Board</button>
<?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>
<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>
<?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>
<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="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 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>
<?php endif; ?>
<?php if ($card['bounty_amount']): ?>
<span class="bounty-badge">💰 <?= number_format($card['bounty_amount'], 0) ?></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; ?>
</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>
</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 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 endif; ?>
<?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; ?>
<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; ?>
<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-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="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="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>
<?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 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>
</div>
</div>
......
<?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>
<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
<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
<?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>
<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>
<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>
<?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-header">
<h1>📊 My Dashboard</h1>
<span class="dashboard-date"><?= date('l, F j, Y') ?></span>
</div>
<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; ?>
<!-- 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>
<!-- 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>
<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>
<?php endforeach; ?>
</div>
<?php endif; ?>
<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; ?>
<!-- Upcoming Deadlines -->
<div class="card">
<h3>⏰ Upcoming Deadlines</h3>
<?php if (empty($upcoming_deadlines)): ?>
<p class="text-muted">No upcoming deadlines.</p>
<!-- 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: ?>
<?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; ?>
<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>
<!-- Month Stats -->
<div class="card">
<h3>📊 This Month</h3>
<div class="stat-row">
<span>Reports Submitted</span>
<strong><?= $reports_submitted ?? 0 ?></strong>
</div>
</div>
<!-- Learning Goals -->
<?php if (!empty($learning_goals)): ?>
<div class="grid grid-2">
<!-- My Tasks -->
<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 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; ?>
</div>
</ul>
<?php else: ?>
<div class="empty-state"><div class="empty-state-icon">🎉</div><div>No active tasks</div></div>
<?php endif; ?>
</div>
<!-- Recent Notifications -->
<!-- Upcoming Deadlines -->
<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 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; ?>
<a href="/notifications" class="btn btn-link">View All</a>
</div>
</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 /** @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; ?>
</div>
<?php endif; ?>
</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 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>
<?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>
</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">
<div class="dashboard-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>
<?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 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 /** @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 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 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>
<?php endforeach; ?>
</div>
</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>
</div>
</div>
\ No newline at end of file
<?php $this->layout('layouts/app', ['title' => 'Performance Improvement Plans']); ?>
<div class="container mx-auto px-4 py-6">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">Performance Improvement Plans</h1>
<?php if (in_array($user['role'], ['super_admin', 'admin'])): ?>
<a href="#" onclick="document.getElementById('create-pip-modal').classList.toggle('hidden')" class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded">+ Create PIP</a>
<?php endif; ?>
</div>
<?php if (empty($pips)): ?>
<div class="bg-gray-800 rounded-lg p-8 text-center text-gray-400">
<p>No PIPs found.</p>
</div>
<?php else: ?>
<div class="space-y-4">
<?php foreach ($pips as $pip): ?>
<a href="/pips/<?= $pip['id'] ?>" class="block bg-gray-800 rounded-lg p-6 border border-gray-700 hover:border-gray-500 transition">
<div class="flex justify-between items-center">
<div>
<h3 class="font-semibold"><?= htmlspecialchars($pip['contractor_name'] ?? 'Your PIP') ?></h3>
<p class="text-gray-400 text-sm"><?= htmlspecialchars($pip['start_date']) ?> to <?= htmlspecialchars($pip['end_date']) ?> (<?= $pip['duration_days'] ?> days)</p>
</div>
<span class="px-3 py-1 rounded-full text-sm <?= match($pip['status']) {
'created' => 'bg-gray-600 text-white',
'acknowledged','active' => 'bg-yellow-600 text-black',
'passed' => 'bg-green-600 text-white',
'failed' => 'bg-red-600 text-white',
default => 'bg-gray-600 text-white',
} ?>">
<?= ucfirst($pip['status']) ?>
</span>
</div>
</a>
<?php /** @var array $user */ /** @var array $pips */ ?>
<div class="dashboard-header"><h1>📈 Performance Improvement Plans</h1></div>
<div class="card">
<?php if (empty($pips)): ?>
<div class="empty-state"><div class="empty-state-icon">📈</div><div>No PIPs</div></div>
<?php else: ?>
<table class="data-table"><thead><tr><th>ID</th><th>Contractor</th><th>Status</th><th>Duration</th><th>Period</th><th></th></tr></thead><tbody>
<?php foreach ($pips as $p): ?>
<tr><td>#<?= $p['id'] ?></td><td><?= htmlspecialchars($p['contractor_name'] ?? $p['created_by_name'] ?? '') ?></td><td><span class="badge <?= $p['status']==='passed' ? 'badge-success' : ($p['status']==='failed' ? 'badge-danger' : 'badge-warning') ?>"><?= ucfirst($p['status']) ?></span></td><td><?= $p['duration_days'] ?> days</td><td><?= $p['start_date'] ?><?= $p['end_date'] ?></td><td><a href="/pips/<?= $p['id'] ?>" class="btn btn-sm btn-ghost">View</a></td></tr>
<?php endforeach; ?>
</div>
<?php endif; ?>
</tbody></table>
<?php endif; ?>
</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.
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