Commit 7d22a012 authored by Administrator's avatar Administrator

Update 30 files via Son of Anton

parent aa7e8d7b
<?php
use Engine\Core\Container;
use Engine\Core\Router;
use Modules\BoardTemplates\Controllers\BoardTemplateController;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class);
return function (Router $router) {
$router->get('/board-templates', [BoardTemplateController::class, 'index']);
$router->post('/board-templates', [BoardTemplateController::class, 'create']);
$router->post('/board-templates/from-board/{boardId}', [BoardTemplateController::class, 'saveFromBoard']);
$router->delete('/board-templates/{templateId}', [BoardTemplateController::class, 'delete']);
$router->get('/board-templates', \Modules\BoardTemplates\Controllers\BoardTemplateController::class, 'index', ['auth', 'blocking']);
$router->post('/board-templates', \Modules\BoardTemplates\Controllers\BoardTemplateController::class, 'create', ['auth', 'blocking']);
$router->post('/board-templates/from-board/{boardId}', \Modules\BoardTemplates\Controllers\BoardTemplateController::class, 'saveFromBoard', ['auth', 'blocking']);
$router->delete('/board-templates/{id}', \Modules\BoardTemplates\Controllers\BoardTemplateController::class, 'delete', ['auth', 'blocking']);
\ No newline at end of file
$router->get('/api/board-templates', [BoardTemplateController::class, 'index']);
$router->post('/api/board-templates', [BoardTemplateController::class, 'create']);
$router->post('/api/board-templates/from-board/{boardId}', [BoardTemplateController::class, 'saveFromBoard']);
$router->delete('/api/board-templates/{templateId}', [BoardTemplateController::class, 'delete']);
};
\ No newline at end of file
<?php
use Engine\Core\Container;
use Engine\Core\Router;
use Modules\CardTemplates\Controllers\CardTemplateController;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class);
return function (Router $router) {
$router->get('/card-templates', [CardTemplateController::class, 'index']);
$router->post('/card-templates', [CardTemplateController::class, 'create']);
$router->put('/card-templates/{templateId}', [CardTemplateController::class, 'update']);
$router->delete('/card-templates/{templateId}', [CardTemplateController::class, 'delete']);
$router->get('/card-templates', \Modules\CardTemplates\Controllers\CardTemplateController::class, 'index', ['auth', 'blocking']);
$router->post('/card-templates', \Modules\CardTemplates\Controllers\CardTemplateController::class, 'create', ['auth', 'blocking']);
$router->put('/card-templates/{id}', \Modules\CardTemplates\Controllers\CardTemplateController::class, 'update', ['auth', 'blocking']);
$router->delete('/card-templates/{id}', \Modules\CardTemplates\Controllers\CardTemplateController::class, 'delete', ['auth', 'blocking']);
\ No newline at end of file
$router->get('/api/card-templates', [CardTemplateController::class, 'index']);
$router->post('/api/card-templates', [CardTemplateController::class, 'create']);
$router->put('/api/card-templates/{templateId}', [CardTemplateController::class, 'update']);
$router->delete('/api/card-templates/{templateId}', [CardTemplateController::class, 'delete']);
};
\ No newline at end of file
......@@ -10,33 +10,47 @@ use Engine\Notifications\NotificationManager;
final class ContractExpiryWarningJob implements JobInterface
{
private Connection $db;
private NotificationManager $notif;
public function key(): string
{
return 'contract_expiry_warnings';
}
public function __construct()
public function shouldRun(): bool
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->notif = $c->resolve(NotificationManager::class);
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 = $this->db->fetchAll(
$expiring = $db->fetchAll(
"SELECT id, full_name_en, contract_end_date FROM users
WHERE contract_end_date = ? AND status = 'active'",
WHERE contract_end_date = ? AND status = 'active' AND is_active = 1",
[$targetDate]
);
foreach ($expiring as $u) {
$admins = $this->db->fetchAll("SELECT id FROM users WHERE role IN ('super_admin','admin') AND is_active = 1");
foreach ($expiring as $user) {
$admins = $db->fetchAll(
"SELECT id FROM users WHERE role IN ('super_admin','admin') AND is_active = 1"
);
foreach ($admins as $a) {
$this->notif->createImportant($a['id'], "Contract Expiring in {$days} Days",
"{$u['full_name_en']}'s contract expires on {$u['contract_end_date']}.",
"/users/{$u['id']}", 'user', $u['id']);
$notif->createImportant($a['id'], "Contract Expiring in {$days} Days",
"{$user['full_name_en']}'s contract expires on {$user['contract_end_date']}.",
"/users/{$user['id']}", 'user', (int)$user['id']);
}
if ($days === 30) {
$notif->createImportant((int)$user['id'], 'Contract Expiring Soon',
"Your contract expires on {$user['contract_end_date']}. Please contact administration.",
'/dashboard');
}
}
}
......
<?php
use Engine\Core\Container;
use Engine\Core\Router;
use Modules\Contracts\Controllers\ContractController;
use Modules\Contracts\Controllers\PolicyController;
use Modules\Contracts\Controllers\NoticeController;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class);
return function (Router $router) {
$router->get('/contracts', [ContractController::class, 'index']);
$router->get('/contracts/{contractId}', [ContractController::class, 'show']);
$router->get('/contracts', \Modules\Contracts\Controllers\ContractController::class, 'index', ['auth', 'blocking']);
$router->get('/contracts/{id}', \Modules\Contracts\Controllers\ContractController::class, 'show', ['auth', 'blocking']);
$router->get('/policies', [PolicyController::class, 'index']);
$router->get('/policies/{policyId}', [PolicyController::class, 'show']);
$router->post('/policies', [PolicyController::class, 'create']);
$router->post('/policies/{policyId}/publish', [PolicyController::class, 'publish']);
$router->post('/policies/versions/{versionId}/acknowledge', [PolicyController::class, 'acknowledge']);
$router->get('/policies', \Modules\Contracts\Controllers\PolicyController::class, 'index', ['auth', 'blocking']);
$router->get('/policies/{id}', \Modules\Contracts\Controllers\PolicyController::class, 'show', ['auth', 'blocking']);
$router->post('/policies', \Modules\Contracts\Controllers\PolicyController::class, 'create', ['auth', 'blocking']);
$router->post('/policies/{id}/publish', \Modules\Contracts\Controllers\PolicyController::class, 'publish', ['auth', 'blocking']);
$router->post('/policies/versions/{versionId}/acknowledge', \Modules\Contracts\Controllers\PolicyController::class, 'acknowledge', ['auth']);
$router->get('/notices', [NoticeController::class, 'index']);
$router->post('/notices', [NoticeController::class, 'create']);
$router->post('/notices/{noticeId}/acknowledge', [NoticeController::class, 'acknowledgeNotice']);
$router->delete('/notices/{noticeId}', [NoticeController::class, 'delete']);
$router->get('/notices', \Modules\Contracts\Controllers\NoticeController::class, 'index', ['auth', 'blocking']);
$router->post('/notices', \Modules\Contracts\Controllers\NoticeController::class, 'create', ['auth', 'blocking']);
$router->post('/notices/{id}/acknowledge', \Modules\Contracts\Controllers\NoticeController::class, 'acknowledgeNotice', ['auth']);
$router->delete('/notices/{id}', \Modules\Contracts\Controllers\NoticeController::class, 'delete', ['auth', 'blocking']);
\ No newline at end of file
$router->get('/api/contracts', [ContractController::class, 'index']);
$router->get('/api/contracts/{contractId}', [ContractController::class, 'show']);
$router->get('/api/policies', [PolicyController::class, 'index']);
$router->get('/api/policies/{policyId}', [PolicyController::class, 'show']);
$router->post('/api/policies', [PolicyController::class, 'create']);
$router->post('/api/policies/{policyId}/publish', [PolicyController::class, 'publish']);
$router->post('/api/policies/versions/{versionId}/acknowledge', [PolicyController::class, 'acknowledge']);
$router->get('/api/notices', [NoticeController::class, 'index']);
$router->post('/api/notices', [NoticeController::class, 'create']);
$router->post('/api/notices/{noticeId}/acknowledge', [NoticeController::class, 'acknowledgeNotice']);
$router->delete('/api/notices/{noticeId}', [NoticeController::class, 'delete']);
};
\ No newline at end of file
<?php
use Engine\Core\Container;
use Engine\Core\Router;
use Modules\DeductionPresets\Controllers\DeductionPresetController;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class);
return function (Router $router) {
$router->get('/deduction-presets', [DeductionPresetController::class, 'index']);
$router->post('/deduction-presets', [DeductionPresetController::class, 'create']);
$router->put('/deduction-presets/{presetId}', [DeductionPresetController::class, 'update']);
$router->delete('/deduction-presets/{presetId}', [DeductionPresetController::class, 'delete']);
$router->get('/deduction-presets', \Modules\DeductionPresets\Controllers\DeductionPresetController::class, 'index', ['auth', 'blocking']);
$router->post('/deduction-presets', \Modules\DeductionPresets\Controllers\DeductionPresetController::class, 'create', ['auth', 'blocking']);
$router->put('/deduction-presets/{id}', \Modules\DeductionPresets\Controllers\DeductionPresetController::class, 'update', ['auth', 'blocking']);
$router->delete('/deduction-presets/{id}', \Modules\DeductionPresets\Controllers\DeductionPresetController::class, 'delete', ['auth', 'blocking']);
\ No newline at end of file
$router->get('/api/deduction-presets', [DeductionPresetController::class, 'index']);
$router->post('/api/deduction-presets', [DeductionPresetController::class, 'create']);
$router->put('/api/deduction-presets/{presetId}', [DeductionPresetController::class, 'update']);
$router->delete('/api/deduction-presets/{presetId}', [DeductionPresetController::class, 'delete']);
};
\ No newline at end of file
......@@ -6,34 +6,78 @@ 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
{
private Connection $db;
public function key(): string
{
return 'auto_apply_deductions';
}
public function __construct()
public function shouldRun(): bool
{
$this->db = Container::getInstance()->resolve(Connection::class);
return true;
}
public function execute(): void
public function run(): void
{
// Apply deductions where response window has expired with no response
$this->db->query(
"UPDATE deductions
SET status = 'applied_no_response',
final_amount = calculated_amount,
applied_at = NOW(),
payroll_month = DATE_FORMAT(NOW(), '%Y-%m')
WHERE status = 'acknowledged'
AND response_deadline IS NOT NULL
AND response_deadline < NOW()
AND deleted_at IS NULL"
$db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class);
$now = date('Y-m-d H:i:s');
$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]
);
foreach ($expired as $deduction) {
$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']);
}
}
public function nextRunAt(): string
private function checkThreshold(Connection $db, NotificationManager $notif, int $contractorId): void
{
return date('Y-m-d H:i:s', strtotime('+1 hour'));
$month = date('Y-m');
$contractor = $db->fetchOne("SELECT actual_salary, full_name_en FROM users WHERE id = ?", [$contractorId]);
if (!$contractor || !$contractor['actual_salary']) return;
$totalDeductions = (float)$db->fetchColumn(
"SELECT COALESCE(SUM(COALESCE(final_amount, calculated_amount)), 0) FROM deductions
WHERE contractor_id = ? AND payroll_month = ?
AND status IN ('applied','applied_no_response','reduced','accepted') AND deleted_at IS NULL",
[$contractorId, $month]
);
$threshold = $contractor['actual_salary'] * 0.40;
if ($totalDeductions >= $threshold) {
$notif->createBlocking($contractorId, 'Critical Deduction Threshold',
'Your deductions have reached 40% of your salary. This is critical.');
$admins = $db->fetchAll("SELECT id FROM users WHERE role = 'super_admin' AND is_active = 1");
foreach ($admins as $a) {
$notif->createImportant($a['id'], '🚨 40% Deduction Threshold',
"{$contractor['full_name_en']} has reached the 40% deduction threshold. PIP recommended.",
"/users/{$contractorId}", 'user', $contractorId);
}
}
}
}
\ No newline at end of file
......@@ -9,65 +9,137 @@ use Engine\Database\Connection;
final class EscalateDeadlineDeductionsJob implements JobInterface
{
private Connection $db;
public function key(): string
{
return 'escalate_deadline_deductions';
}
public function __construct()
public function shouldRun(): bool
{
$this->db = Container::getInstance()->resolve(Connection::class);
return true;
}
public function run(): void
{
$overdueCards = $this->db->fetchAll(
"SELECT c.id, c.card_key, c.deadline, c.board_id,
DATEDIFF(NOW(), c.deadline) as days_late,
ca.user_id as assignee_id
$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
FROM cards c
JOIN card_assignments ca ON ca.card_id = c.id
WHERE c.deadline IS NOT NULL AND c.deadline < NOW()
AND c.done_at IS NULL AND c.is_archived = 0"
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']
);
foreach ($overdueCards as $card) {
$daysLate = (int)$card['days_late'];
if ($daysLate <= 0) continue;
$subCategory = match(true) {
$daysLate >= 15 => 'A4',
$daysLate >= 8 => 'A3',
$daysLate >= 4 => 'A2',
default => 'A1',
};
$existing = $this->db->fetchOne(
"SELECT id, sub_category FROM deductions
WHERE related_card_id = ? AND contractor_id = ? AND category = 'A'
AND status NOT IN ('dismissed','applied','applied_no_response','reduced','accepted')
AND deleted_at IS NULL",
[$card['id'], $card['assignee_id']]
$newSub = $this->determineSubCategory($daysLate);
if (!$newSub) continue;
$assignees = $db->fetchAll(
"SELECT user_id FROM card_assignments WHERE card_id = ?",
[(int)$card['card_id']]
);
if ($existing) {
if ($existing['sub_category'] !== $subCategory) {
$contractor = $this->db->fetchOne("SELECT actual_salary FROM users WHERE id = ?", [$card['assignee_id']]);
$actualSalary = (float)($contractor['actual_salary'] ?? 0);
$dailyRate = $actualSalary > 0 ? round($actualSalary / 22, 2) : 0;
$newAmount = match($subCategory) {
'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,
};
$this->db->update('deductions', [
'sub_category' => $subCategory,
'calculated_amount' => $newAmount,
'description' => "Auto-escalated: Card {$card['card_key']} is {$daysLate} days overdue.",
], 'id = ?', [$existing['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,
};
}
private function getExpectedWorkingDays(Connection $db, int $userId): int
{
$month = date('Y-m');
$startDate = $month . '-01';
$endDate = date('Y-m-t', strtotime($startDate));
$schedule = $db->fetchAll(
"SELECT day_of_week FROM user_schedule_days WHERE user_id = ? AND effective_to IS NULL AND work_mode != 'off'",
[$userId]
);
$workDows = array_column($schedule, 'day_of_week');
if (empty($workDows)) return 22;
$holidays = $db->fetchAll(
"SELECT start_date, end_date FROM holidays WHERE start_date <= ? AND end_date >= ?",
[$endDate, $startDate]
);
$holidayDates = [];
foreach ($holidays as $h) {
$s = strtotime($h['start_date']);
$e = strtotime($h['end_date']);
for ($d = $s; $d <= $e; $d += 86400) {
$holidayDates[date('Y-m-d', $d)] = true;
}
}
$count = 0;
$current = strtotime($startDate);
$end = strtotime($endDate);
while ($current <= $end) {
$dow = (int)date('w', $current);
$dateStr = date('Y-m-d', $current);
if (in_array($dow, $workDows) && !isset($holidayDates[$dateStr])) {
$count++;
}
$current += 86400;
}
return $count ?: 22;
}
}
\ No newline at end of file
......@@ -7,29 +7,47 @@ use Engine\Calculation\CalculatorInterface;
final class OverallScoreCalculator implements CalculatorInterface
{
public function calculate(array $context): mixed
public function calculate(array $context): array
{
$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);
$criteria = require ROOT_PATH . '/config/evaluation_criteria.php';
$techWeight = (float)($criteria['overall_weights']['technical'] ?? 0.5);
$profWeight = (float)($criteria['overall_weights']['professional'] ?? 0.5);
$overallScore = round(($techScore * $techWeight) + ($profScore * $profWeight), 2);
$overall = 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'],
];
$rating = 'adequate';
$ratings = $criteria['ratings'] ?? [];
foreach ($ratings as $r) {
if ($overall >= $r['min'] && $overall <= $r['max']) {
$rating = $r['rating'];
break;
if ($score >= $r['min'] && $score <= $r['max']) {
return $r;
}
}
return [
'overall_score' => $overall,
'rating' => $rating,
];
return ['rating' => 'unacceptable', 'label' => '🔴 Unacceptable'];
}
public function name(): string
{
return 'overall_eval_score';
}
}
\ No newline at end of file
......@@ -9,45 +9,96 @@ use Engine\Database\Connection;
final class ProfessionalAutoScoreCalculator implements CalculatorInterface
{
private Connection $db;
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
public function calculate(array $context): mixed
public function calculate(array $context): array
{
$db = Container::getInstance()->resolve(Connection::class);
$contractorId = (int)$context['contractor_id'];
$month = $context['month'];
$month = $context['month']; // YYYY-MM
$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]
);
$totalReports = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM daily_reports WHERE user_id = ? AND report_date LIKE ?",
[$contractorId, $month . '%']
// 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');
$onTimeReports = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM daily_reports WHERE user_id = ? AND report_date LIKE ? AND is_on_time = 1",
[$contractorId, $month . '%']
$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;
}
}
$reportingCompliance = $totalReports > 0 ? round(($onTimeReports / $totalReports) * 5, 2) : 1.0;
$reportingCompliance = min(5.0, max(1.0, $reportingCompliance));
$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;
}
$violations = (int)$this->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",
$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, min(5.0, 5.0 - ($violations * 0.5)));
$policyCompliance = max(1.0, round(5.0 - ($violations * 0.5), 2));
return [
'reporting_compliance' => $reportingCompliance,
'policy_compliance' => round($policyCompliance, 2),
'total_reports' => $totalReports,
'on_time_reports' => $onTimeReports,
'violations' => $violations,
'policy_compliance' => $policyCompliance,
'reports_on_time' => $reportsOnTime,
'expected_reports' => $totalExpected,
'violations' => $violations,
];
}
public function name(): string
{
return 'professional_auto_score';
}
}
\ No newline at end of file
......@@ -9,62 +9,71 @@ use Engine\Database\Connection;
final class TechnicalAutoScoreCalculator implements CalculatorInterface
{
private Connection $db;
public function __construct()
{
$this->db = Container::getInstance()->resolve(Connection::class);
}
public function calculate(array $context): mixed
public function calculate(array $context): array
{
$db = Container::getInstance()->resolve(Connection::class);
$contractorId = (int)$context['contractor_id'];
$month = $context['month'];
$month = $context['month']; // YYYY-MM
$startDate = $month . '-01';
$endDate = date('Y-m-t', strtotime($startDate));
$cardsAssigned = (int)$this->db->fetchColumn(
// 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 BETWEEN ? AND ?",
[$contractorId, $startDate . ' 00:00:00', $endDate . ' 23:59:59']
WHERE ca.user_id = ? AND ca.created_at <= ?",
[$contractorId, $endDate . ' 23:59:59']
);
$cardsDone = (int)$this->db->fetchColumn(
$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 BETWEEN ? AND ?",
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']
);
$cardsWithDeadline = (int)$this->db->fetchColumn(
$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 BETWEEN ? AND ?",
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)$this->db->fetchColumn(
$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 BETWEEN ? AND ?",
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']
);
$taskCompletionRate = $cardsAssigned > 0 ? round(($cardsDone / $cardsAssigned) * 5, 2) : 3.0;
$deadlineCompliance = $cardsWithDeadline > 0 ? round(($cardsOnTime / $cardsWithDeadline) * 5, 2) : 3.0;
$taskCompletionRate = min(5.0, max(1.0, $taskCompletionRate));
$deadlineCompliance = min(5.0, max(1.0, $deadlineCompliance));
$deadlineCompliance = $cardsWithDeadline > 0
? min(5.0, round(($cardsOnTime / $cardsWithDeadline) * 5, 2))
: 3.0;
return [
'task_completion_rate' => $taskCompletionRate,
'deadline_compliance' => $deadlineCompliance,
'cards_assigned' => $cardsAssigned,
'cards_done' => $cardsDone,
'cards_with_deadline' => $cardsWithDeadline,
'cards_on_time' => $cardsOnTime,
'deadline_compliance' => $deadlineCompliance,
'cards_assigned' => $cardsAssigned,
'cards_done' => $cardsDone,
'cards_with_deadline' => $cardsWithDeadline,
'cards_on_time' => $cardsOnTime,
];
}
public function name(): string
{
return 'technical_auto_score';
}
}
\ No newline at end of file
......@@ -11,98 +11,124 @@ use Engine\Calculation\CalculationEngine;
final class CompileEvaluationsJob implements JobInterface
{
private Connection $db;
private NotificationManager $notif;
private CalculationEngine $calc;
public function key(): string
{
return 'compile_evaluations';
}
public function __construct()
public function shouldRun(): bool
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->notif = $c->resolve(NotificationManager::class);
$this->calc = $c->resolve(CalculationEngine::class);
return true;
}
public function run(): void
{
$cycle = $this->db->fetchOne(
$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"
);
if (!$cycle) return;
$contractors = $this->db->fetchAll(
if (!$activeCycle) {
return;
}
$contractors = $db->fetchAll(
"SELECT DISTINCT contractor_id FROM evaluations WHERE cycle_id = ?",
[$cycle['id']]
[$activeCycle['id']]
);
$compiledCount = 0;
$allCompiled = true;
foreach ($contractors as $c) {
$cid = $c['contractor_id'];
$contractorId = $c['contractor_id'];
$existing = $this->db->fetchOne(
$alreadyCompiled = $db->fetchOne(
"SELECT id FROM compiled_evaluations WHERE cycle_id = ? AND contractor_id = ?",
[$cycle['id'], $cid]
[$activeCycle['id'], $contractorId]
);
if ($existing) continue;
if ($alreadyCompiled) {
continue;
}
$tech = $this->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]
$techEval = $db->fetchOne(
"SELECT * FROM evaluations WHERE cycle_id = ? AND contractor_id = ? AND type = 'technical'",
[$activeCycle['id'], $contractorId]
);
$prof = $this->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]
$profEval = $db->fetchOne(
"SELECT * FROM evaluations WHERE cycle_id = ? AND contractor_id = ? AND type = 'professional'",
[$activeCycle['id'], $contractorId]
);
if (!$tech || !$prof) continue;
if (!$techEval || !$techEval['submitted_at'] || !$profEval || !$profEval['submitted_at']) {
$allCompiled = false;
continue;
}
$result = $this->calc->calculate('overall_eval_score', [
'technical_score' => (float)$tech['total_score'],
'professional_score' => (float)$prof['total_score'],
$techScore = (float)$techEval['total_score'];
$profScore = (float)$profEval['total_score'];
$overallResult = $calc->calculate('overall_eval_score', [
'technical_score' => $techScore,
'professional_score' => $profScore,
'tech_weight' => 0.5,
'prof_weight' => 0.5,
]);
$metrics = [
'technical_score' => (float)$tech['total_score'],
'professional_score' => (float)$prof['total_score'],
'month' => $cycle['month'],
$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,
];
$this->db->insert('compiled_evaluations', [
'cycle_id' => $cycle['id'],
'contractor_id' => $cid,
'technical_score' => (float)$tech['total_score'],
'professional_score' => (float)$prof['total_score'],
'overall_score' => $result['overall_score'],
'rating' => $result['rating'],
'system_metrics_json' => json_encode($metrics),
if ($calc->has('technical_auto_score')) {
$systemMetrics['tech_auto'] = $calc->calculate('technical_auto_score', [
'contractor_id' => $contractorId,
'month' => $activeCycle['month'],
]);
}
if ($calc->has('professional_auto_score')) {
$systemMetrics['prof_auto'] = $calc->calculate('professional_auto_score', [
'contractor_id' => $contractorId,
'month' => $activeCycle['month'],
]);
}
$compiledId = $db->insert('compiled_evaluations', [
'cycle_id' => $activeCycle['id'],
'contractor_id' => $contractorId,
'technical_score' => $techScore,
'professional_score' => $profScore,
'overall_score' => $overallResult['overall_score'],
'rating' => $overallResult['rating'],
'system_metrics_json' => json_encode($systemMetrics),
'compiled_at' => date('Y-m-d H:i:s'),
]);
$this->notif->createBlocking($cid, 'Monthly Evaluation Published',
"Your evaluation for {$cycle['month']} has been compiled. Overall score: {$result['overall_score']}/5.00 ({$result['rating']})",
"/evaluations/compiled/{$cid}", 'compiled_evaluation', $cid);
$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 ($result['overall_score'] < 2.5) {
$admins = $this->db->fetchAll("SELECT id FROM users WHERE role = 'super_admin' AND is_active = 1");
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) {
$this->notif->createImportant($a['id'], 'Low Evaluation Score Alert',
"Contractor ID {$cid} scored {$result['overall_score']} ({$result['rating']}). PIP recommended.",
"/users/{$cid}", 'user', $cid);
$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);
}
}
$compiledCount++;
}
$totalExpected = count($contractors);
$totalCompiled = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM compiled_evaluations WHERE cycle_id = ?", [$cycle['id']]
);
if ($totalCompiled >= $totalExpected && $totalExpected > 0) {
$this->db->update('evaluation_cycles', [
if ($allCompiled) {
$db->update('evaluation_cycles', [
'status' => 'completed',
'completed_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$cycle['id']]);
], 'id = ?', [$activeCycle['id']]);
}
}
}
\ No newline at end of file
......@@ -10,51 +10,71 @@ use Engine\Notifications\NotificationManager;
final class EvaluationReminderJob implements JobInterface
{
private Connection $db;
private NotificationManager $notif;
public function key(): string
{
return 'evaluation_reminders';
}
public function __construct()
public function shouldRun(): bool
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->notif = $c->resolve(NotificationManager::class);
return true;
}
public function run(): void
{
$cycle = $this->db->fetchOne(
$db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class);
$activeCycle = $db->fetchOne(
"SELECT * FROM evaluation_cycles WHERE status IN ('open','technical_phase','professional_phase') LIMIT 1"
);
if (!$cycle) return;
if (!$activeCycle) {
return;
}
$now = time();
$techDeadline = strtotime($cycle['tech_deadline']);
$profDeadline = strtotime($cycle['prof_deadline']);
$techDeadline = strtotime($activeCycle['tech_deadline']);
$profDeadline = strtotime($activeCycle['prof_deadline']);
$daysToTech = (int)ceil(($techDeadline - $now) / 86400);
$daysToProf = (int)ceil(($profDeadline - $now) / 86400);
$daysTilTech = (int)ceil(($techDeadline - $now) / 86400);
$daysTilProf = (int)ceil(($profDeadline - $now) / 86400);
if ($daysToTech <= 2 && $daysToTech >= 0) {
$pending = $this->db->fetchAll(
"SELECT DISTINCT evaluator_id FROM evaluations WHERE cycle_id = ? AND type = 'technical' AND submitted_at IS NULL",
[$cycle['id']]
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 ($pending as $p) {
$this->notif->createImportant($p['evaluator_id'], 'Technical Evaluation Due',
"Technical evaluations for {$cycle['month']} are due in {$daysToTech} day(s). Please submit them.",
'/evaluations/pending', 'evaluation_cycle', $cycle['id']);
foreach ($pendingTech as $e) {
$count = (int)$db->fetchColumn(
"SELECT COUNT(*) FROM evaluations
WHERE cycle_id = ? AND type = 'technical' AND evaluator_id = ? AND submitted_at IS NULL",
[$activeCycle['id'], $e['evaluator_id']]
);
$urgency = $daysTilTech === 0 ? '🚨 DUE TODAY' : "⏰ {$daysTilTech} days remaining";
$notif->createImportant($e['evaluator_id'], "Technical Evaluations {$urgency}",
"You have {$count} technical evaluation(s) pending for {$activeCycle['month']}. Deadline: " . date('M j', $techDeadline),
'/evaluations/pending', 'evaluation_cycle', $activeCycle['id']);
}
}
if ($daysToProf <= 2 && $daysToProf >= 0) {
$pending = $this->db->fetchAll(
"SELECT DISTINCT evaluator_id FROM evaluations WHERE cycle_id = ? AND type = 'professional' AND submitted_at IS NULL",
[$cycle['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 ($pending as $p) {
$this->notif->createImportant($p['evaluator_id'], 'Professional Evaluation Due',
"Professional evaluations for {$cycle['month']} are due in {$daysToProf} day(s). Please submit them.",
'/evaluations/pending', 'evaluation_cycle', $cycle['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']);
}
}
}
......
......@@ -6,56 +6,99 @@ 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
{
private Connection $db;
public function key(): string
{
return 'open_evaluation_cycle';
}
public function __construct()
public function shouldRun(): bool
{
$this->db = Container::getInstance()->resolve(Connection::class);
return (int)date('j') === 1;
}
public function run(): void
{
if ((int)date('j') !== 1) return;
$db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class);
$month = date('Y-m', strtotime('-1 month'));
$exists = $this->db->fetchOne("SELECT id FROM evaluation_cycles WHERE month = ?", [$month]);
if ($exists) return;
$exists = $db->fetchOne("SELECT id FROM evaluation_cycles WHERE month = ?", [$month]);
if ($exists) {
return;
}
$now = date('Y-m-d H:i:s');
$cycleId = $this->db->insert('evaluation_cycles', [
'month' => $month,
'status' => 'open',
'opened_at' => $now,
'tech_deadline' => date('Y-m-d 23:59:59', strtotime('+5 weekdays')),
'prof_deadline' => date('Y-m-d 23:59:59', strtotime('+7 weekdays')),
]);
$contractors = $this->db->fetchAll(
"SELECT u.id, (SELECT bm.user_id FROM board_members bm
JOIN board_members bm2 ON bm2.board_id = bm.board_id
WHERE bm2.user_id = u.id AND bm.role_on_board = 'project_leader' LIMIT 1) as pl_id
FROM users u WHERE u.role = 'contractor' AND u.status IN ('active','on_pip') AND u.is_active = 1"
);
$defaultAdmin = $this->db->fetchOne("SELECT id FROM users WHERE role IN ('admin','super_admin') AND is_active = 1 LIMIT 1");
$defaultAdminId = $defaultAdmin ? $defaultAdmin['id'] : 1;
foreach ($contractors as $c) {
$this->db->insert('evaluations', [
'cycle_id' => $cycleId,
'contractor_id' => $c['id'],
'type' => 'technical',
'evaluator_id' => $c['pl_id'] ?? $defaultAdminId,
]);
$this->db->insert('evaluations', [
'cycle_id' => $cycleId,
'contractor_id' => $c['id'],
'type' => 'professional',
'evaluator_id' => $defaultAdminId,
$techDeadline = date('Y-m-d H:i:s', strtotime('+5 weekdays'));
$profDeadline = date('Y-m-d H:i:s', strtotime('+7 weekdays'));
$db->beginTransaction();
try {
$cycleId = $db->insert('evaluation_cycles', [
'month' => $month,
'status' => 'open',
'opened_at' => $now,
'tech_deadline' => $techDeadline,
'prof_deadline' => $profDeadline,
]);
$contractors = $db->fetchAll(
"SELECT id FROM users WHERE role = 'contractor' AND status IN ('active','on_pip') AND is_active = 1"
);
foreach ($contractors as $contractor) {
$pl = $db->fetchOne(
"SELECT bm.user_id FROM board_members bm
JOIN board_members bm2 ON bm2.board_id = bm.board_id
WHERE bm2.user_id = ? AND bm.role_on_board = 'project_leader' LIMIT 1",
[$contractor['id']]
);
$plId = $pl ? $pl['user_id'] : null;
if (!$plId) {
$sa = $db->fetchOne("SELECT id FROM users WHERE role = 'super_admin' AND is_active = 1 LIMIT 1");
$plId = $sa ? $sa['id'] : 1;
}
$db->insert('evaluations', [
'cycle_id' => $cycleId,
'contractor_id' => $contractor['id'],
'type' => 'technical',
'evaluator_id' => $plId,
]);
$admin = $db->fetchOne(
"SELECT id FROM users WHERE role IN ('admin','super_admin') AND is_active = 1 LIMIT 1"
);
$adminId = $admin ? $admin['id'] : $plId;
$db->insert('evaluations', [
'cycle_id' => $cycleId,
'contractor_id' => $contractor['id'],
'type' => 'professional',
'evaluator_id' => $adminId,
]);
}
$db->commit();
$evaluators = $db->fetchAll(
"SELECT DISTINCT evaluator_id FROM evaluations WHERE cycle_id = ?",
[$cycleId]
);
foreach ($evaluators as $e) {
$notif->createImportant($e['evaluator_id'], 'Evaluation Cycle Opened',
"The evaluation cycle for {$month} is now open. Please submit your evaluations.",
'/evaluations/pending', 'evaluation_cycle', $cycleId);
}
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
}
}
\ No newline at end of file
<?php
use Engine\Core\Container;
use Engine\Core\Router;
use Modules\Evaluations\Controllers\EvaluationController;
use Modules\Evaluations\Controllers\EvaluationCycleController;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class);
return function (Router $router) {
$router->get('/evaluations/mine', [EvaluationController::class, 'myEvaluations']);
$router->get('/evaluations/pending', [EvaluationController::class, 'pending']);
$router->get('/evaluations/compiled/{compiledId}', [EvaluationController::class, 'showCompiled']);
$router->get('/evaluations/{evaluationId}/technical', [EvaluationController::class, 'technicalForm']);
$router->post('/evaluations/{evaluationId}/technical', [EvaluationController::class, 'submitTechnical']);
$router->get('/evaluations/{evaluationId}/professional', [EvaluationController::class, 'professionalForm']);
$router->post('/evaluations/{evaluationId}/professional', [EvaluationController::class, 'submitProfessional']);
$router->post('/evaluations/compiled/{compiledId}/acknowledge', [EvaluationController::class, 'acknowledge']);
$router->post('/evaluations/compiled/{compiledId}/respond', [EvaluationController::class, 'respond']);
$router->get('/evaluations', \Modules\Evaluations\Controllers\EvaluationController::class, 'myEvaluations', ['auth', 'blocking']);
$router->get('/evaluations/pending', \Modules\Evaluations\Controllers\EvaluationController::class, 'pending', ['auth', 'blocking']);
$router->get('/evaluations/compiled/{id}', \Modules\Evaluations\Controllers\EvaluationController::class, 'showCompiled', ['auth', 'blocking']);
$router->post('/evaluations/compiled/{id}/acknowledge', \Modules\Evaluations\Controllers\EvaluationController::class, 'acknowledge', ['auth']);
$router->post('/evaluations/compiled/{id}/respond', \Modules\Evaluations\Controllers\EvaluationController::class, 'respond', ['auth']);
$router->get('/evaluations/{id}/technical', \Modules\Evaluations\Controllers\EvaluationController::class, 'technicalForm', ['auth', 'blocking']);
$router->post('/evaluations/{id}/technical', \Modules\Evaluations\Controllers\EvaluationController::class, 'submitTechnical', ['auth', 'blocking']);
$router->get('/evaluations/{id}/professional', \Modules\Evaluations\Controllers\EvaluationController::class, 'professionalForm', ['auth', 'blocking']);
$router->post('/evaluations/{id}/professional', \Modules\Evaluations\Controllers\EvaluationController::class, 'submitProfessional', ['auth', 'blocking']);
$router->get('/evaluations/cycles', [EvaluationCycleController::class, 'index']);
$router->get('/evaluations/cycles/{cycleId}', [EvaluationCycleController::class, 'show']);
$router->post('/evaluations/cycles', [EvaluationCycleController::class, 'create']);
$router->get('/evaluations/cycles', \Modules\Evaluations\Controllers\EvaluationCycleController::class, 'index', ['auth', 'blocking']);
$router->get('/evaluations/cycles/{id}', \Modules\Evaluations\Controllers\EvaluationCycleController::class, 'show', ['auth', 'blocking']);
$router->post('/evaluations/cycles', \Modules\Evaluations\Controllers\EvaluationCycleController::class, 'create', ['auth', 'blocking']);
\ No newline at end of file
// API mirrors
$router->get('/api/evaluations', [EvaluationController::class, 'myEvaluations']);
$router->get('/api/evaluations/pending', [EvaluationController::class, 'pending']);
$router->get('/api/evaluations/compiled/{compiledId}', [EvaluationController::class, 'showCompiled']);
$router->post('/api/evaluations/{evaluationId}/technical', [EvaluationController::class, 'submitTechnical']);
$router->post('/api/evaluations/{evaluationId}/professional', [EvaluationController::class, 'submitProfessional']);
$router->post('/api/evaluations/compiled/{compiledId}/acknowledge', [EvaluationController::class, 'acknowledge']);
$router->post('/api/evaluations/compiled/{compiledId}/respond', [EvaluationController::class, 'respond']);
$router->get('/api/evaluations/cycles', [EvaluationCycleController::class, 'index']);
$router->get('/api/evaluations/cycles/{cycleId}', [EvaluationCycleController::class, 'show']);
$router->post('/api/evaluations/cycles', [EvaluationCycleController::class, 'create']);
};
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Router;
use Modules\Holidays\Controllers\HolidayController;
use Engine\Core\Container;
return function (Router $router) {
$router->get('/holidays', [HolidayController::class, 'index']);
$router->post('/holidays', [HolidayController::class, 'create']);
$router->put('/holidays/{id}', [HolidayController::class, 'update']);
$router->delete('/holidays/{id}', [HolidayController::class, 'delete']);
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->group([
'prefix' => '/api/holidays',
'middleware' => [Middleware\AuthenticationMiddleware::class, Middleware\CSRFMiddleware::class]
], function ($router) {
$router->get('/', Modules\Holidays\Controllers\HolidayController::class, 'index');
$router->post('/', Modules\Holidays\Controllers\HolidayController::class, 'create');
$router->post('/{id}', Modules\Holidays\Controllers\HolidayController::class, 'update');
$router->post('/{id}/delete', Modules\Holidays\Controllers\HolidayController::class, 'delete');
});
\ No newline at end of file
$router->get('/api/holidays', [HolidayController::class, 'index']);
$router->post('/api/holidays', [HolidayController::class, 'create']);
$router->put('/api/holidays/{id}', [HolidayController::class, 'update']);
$router->delete('/api/holidays/{id}', [HolidayController::class, 'delete']);
};
\ No newline at end of file
......@@ -10,39 +10,81 @@ use Engine\Notifications\NotificationManager;
final class LearningGoalReminderJob implements JobInterface
{
private Connection $db;
private NotificationManager $notif;
public function key(): string
{
return 'learning_goal_reminders';
}
public function __construct()
public function shouldRun(): bool
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->notif = $c->resolve(NotificationManager::class);
return true;
}
public function run(): void
{
$db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class);
$today = date('Y-m-d');
$reminderDays = [14, 7, 2, 0];
foreach ($reminderDays as $days) {
$targetDate = date('Y-m-d', strtotime("+{$days} days"));
$goals = $this->db->fetchAll(
"SELECT * FROM learning_goals WHERE deadline = ? AND status IN ('active','extended') AND deleted_at IS NULL",
[$targetDate]
);
foreach ($goals as $g) {
$msg = $days === 0
? "Learning goal \"{$g['title']}\" is due TODAY."
: "Learning goal \"{$g['title']}\" is due in {$days} days.";
$this->notif->createImportant($g['contractor_id'], '⏰ Learning Goal Deadline', $msg,
'/learning-goals', 'learning_goal', $g['id']);
}
}
$this->db->query(
"UPDATE learning_goals SET status = 'overdue' WHERE deadline < ? AND status = 'active' AND deleted_at IS NULL",
[$today]
$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"
);
foreach ($goals as $goal) {
$daysRemaining = (int)((strtotime($goal['deadline']) - strtotime($today)) / 86400);
if (in_array($daysRemaining, [14, 7, 2, 0])) {
$urgencyMap = [
14 => '14 days remaining',
7 => '7 days remaining',
2 => '2 days remaining ⚠️',
0 => 'DUE TODAY 🚨',
];
$urgency = $urgencyMap[$daysRemaining];
$notif->createImportant($goal['contractor_id'], "Learning Goal: {$urgency}",
"Your learning goal \"{$goal['title']}\" ({$goal['competency_name']}) is due {$goal['deadline']}. {$urgency}",
'/learning-goals', 'learning_goal', (int)$goal['id']);
}
if ($daysRemaining < 0 && $goal['status'] === 'active') {
$db->update('learning_goals', ['status' => 'overdue'], 'id = ?', [(int)$goal['id']]);
$notif->createImportant($goal['contractor_id'], 'Learning Goal Overdue',
"Your learning goal \"{$goal['title']}\" is now overdue.",
'/learning-goals', 'learning_goal', (int)$goal['id']);
$recipients = [];
if ($goal['assigned_pl_id']) {
$recipients[] = $goal['assigned_pl_id'];
}
$admins = $db->fetchAll("SELECT id FROM users WHERE role IN ('super_admin','admin') AND is_active = 1");
foreach ($admins as $a) {
$recipients[] = $a['id'];
}
foreach (array_unique($recipients) as $rid) {
$notif->createImportant($rid, 'Learning Goal Overdue',
"{$goal['contractor_name']}'s learning goal \"{$goal['title']}\" is overdue.",
"/users/{$goal['contractor_id']}", 'learning_goal', (int)$goal['id']);
}
$daysOverdue = abs($daysRemaining);
$initialDeadlineDays = $goal['is_auto_generated'] ? 45 : null;
if ($initialDeadlineDays && $daysOverdue >= $initialDeadlineDays) {
$admins = $db->fetchAll("SELECT id FROM users WHERE role = 'super_admin' AND is_active = 1");
foreach ($admins as $a) {
$notif->createImportant($a['id'], '🚨 Learning Goal — Double Deadline Exceeded',
"{$goal['contractor_name']}'s auto-generated learning goal \"{$goal['title']}\" has exceeded double the original deadline. Termination review required.",
"/users/{$goal['contractor_id']}", 'user', (int)$goal['contractor_id']);
}
}
}
}
}
}
\ No newline at end of file
<?php
use Engine\Core\Container;
use Engine\Core\Router;
use Modules\LearningGoals\Controllers\LearningGoalController;
use Modules\LearningGoals\Controllers\CompetencyController;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class);
return function (Router $router) {
$router->get('/learning-goals', [LearningGoalController::class, 'index']);
$router->post('/learning-goals', [LearningGoalController::class, 'create']);
$router->put('/learning-goals/{goalId}', [LearningGoalController::class, 'update']);
$router->post('/learning-goals/{goalId}/assess', [LearningGoalController::class, 'assess']);
$router->delete('/learning-goals/{goalId}', [LearningGoalController::class, 'delete']);
$router->get('/learning-goals', \Modules\LearningGoals\Controllers\LearningGoalController::class, 'index', ['auth', 'blocking']);
$router->post('/learning-goals', \Modules\LearningGoals\Controllers\LearningGoalController::class, 'create', ['auth', 'blocking']);
$router->put('/learning-goals/{id}', \Modules\LearningGoals\Controllers\LearningGoalController::class, 'update', ['auth', 'blocking']);
$router->post('/learning-goals/{id}/assess', \Modules\LearningGoals\Controllers\LearningGoalController::class, 'assess', ['auth', 'blocking']);
$router->delete('/learning-goals/{id}', \Modules\LearningGoals\Controllers\LearningGoalController::class, 'delete', ['auth', 'blocking']);
$router->get('/competency/areas', [CompetencyController::class, 'areas']);
$router->get('/competency/profile/{userId}', [CompetencyController::class, 'profile']);
$router->post('/competency/assess/{userId}', [CompetencyController::class, 'submitAssessment']);
$router->get('/competency/areas', \Modules\LearningGoals\Controllers\CompetencyController::class, 'areas', ['auth']);
$router->get('/competency/profile/{userId}', \Modules\LearningGoals\Controllers\CompetencyController::class, 'profile', ['auth', 'blocking']);
$router->post('/competency/assess/{userId}', \Modules\LearningGoals\Controllers\CompetencyController::class, 'submitAssessment', ['auth', 'blocking']);
\ No newline at end of file
$router->get('/api/learning-goals', [LearningGoalController::class, 'index']);
$router->post('/api/learning-goals', [LearningGoalController::class, 'create']);
$router->put('/api/learning-goals/{goalId}', [LearningGoalController::class, 'update']);
$router->put('/api/learning-goals/{goalId}/assess', [LearningGoalController::class, 'assess']);
$router->delete('/api/learning-goals/{goalId}', [LearningGoalController::class, 'delete']);
$router->get('/api/competency/areas', [CompetencyController::class, 'areas']);
$router->get('/api/competency/profile/{userId}', [CompetencyController::class, 'profile']);
$router->post('/api/competency/assess/{userId}', [CompetencyController::class, 'submitAssessment']);
};
\ No newline at end of file
......@@ -10,50 +10,80 @@ use Engine\Notifications\NotificationManager;
final class MeetingReminderJob implements JobInterface
{
private Connection $db;
private NotificationManager $notif;
public function key(): string
{
return 'meeting_reminders';
}
public function __construct()
public function shouldRun(): bool
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->notif = $c->resolve(NotificationManager::class);
return true;
}
public function run(): void
{
$db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class);
$now = time();
$oneHour = date('Y-m-d H:i:s', $now + 3600);
$oneHourAgo = date('Y-m-d H:i:s', $now + 3000);
$today = date('Y-m-d');
$tomorrow = date('Y-m-d', strtotime('+1 day'));
$currentTime = date('H:i:s');
$meetings = $this->db->fetchAll(
"SELECT m.* FROM meetings m
WHERE m.status = 'scheduled'
AND CONCAT(m.meeting_date, ' ', m.start_time) BETWEEN ? AND ?",
[$oneHourAgo, $oneHour]
// 1-day reminders: meetings tomorrow
$tomorrowMeetings = $db->fetchAll(
"SELECT m.*, u.full_name_en as creator_name FROM meetings m
JOIN users u ON u.id = m.created_by_id
WHERE m.meeting_date = ? AND m.status = 'scheduled'",
[$tomorrow]
);
foreach ($meetings as $m) {
$invitees = $this->db->fetchAll("SELECT user_id FROM meeting_invitees WHERE meeting_id = ?", [$m['id']]);
foreach ($tomorrowMeetings as $m) {
$invitees = $db->fetchAll(
"SELECT user_id FROM meeting_invitees WHERE meeting_id = ?",
[$m['id']]
);
foreach ($invitees as $inv) {
$this->notif->createImportant($inv['user_id'], '⏰ Meeting in 1 Hour',
"Meeting: \"{$m['title']}\" starts in about 1 hour.",
"/meetings/{$m['id']}", 'meeting', $m['id']);
$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']);
}
}
}
$tomorrow = date('Y-m-d', strtotime('+1 day'));
$tomorrowMeetings = $this->db->fetchAll(
"SELECT * FROM meetings WHERE meeting_date = ? AND status = 'scheduled'",
[$tomorrow]
// 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 ($tomorrowMeetings as $m) {
$invitees = $this->db->fetchAll("SELECT user_id FROM meeting_invitees WHERE meeting_id = ?", [$m['id']]);
foreach ($soonMeetings as $m) {
$invitees = $db->fetchAll(
"SELECT user_id FROM meeting_invitees WHERE meeting_id = ?",
[$m['id']]
);
foreach ($invitees as $inv) {
$this->notif->createImportant($inv['user_id'], 'Meeting Tomorrow',
"Meeting: \"{$m['title']}\" is scheduled for tomorrow at {$m['start_time']}.",
"/meetings/{$m['id']}", 'meeting', $m['id']);
$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']);
}
}
}
}
......
<?php
use Engine\Core\Container;
use Engine\Core\Router;
use Modules\Meetings\Controllers\MeetingController;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class);
return function (Router $router) {
$router->get('/meetings', [MeetingController::class, 'index']);
$router->get('/meetings/{meetingId}', [MeetingController::class, 'show']);
$router->post('/meetings', [MeetingController::class, 'create']);
$router->put('/meetings/{meetingId}', [MeetingController::class, 'update']);
$router->post('/meetings/{meetingId}/notes', [MeetingController::class, 'addNotes']);
$router->delete('/meetings/{meetingId}', [MeetingController::class, 'delete']);
$router->get('/meetings', \Modules\Meetings\Controllers\MeetingController::class, 'index', ['auth', 'blocking']);
$router->get('/meetings/{id}', \Modules\Meetings\Controllers\MeetingController::class, 'show', ['auth', 'blocking']);
$router->post('/meetings', \Modules\Meetings\Controllers\MeetingController::class, 'create', ['auth', 'blocking']);
$router->put('/meetings/{id}', \Modules\Meetings\Controllers\MeetingController::class, 'update', ['auth', 'blocking']);
$router->post('/meetings/{id}/notes', \Modules\Meetings\Controllers\MeetingController::class, 'addNotes', ['auth', 'blocking']);
$router->delete('/meetings/{id}', \Modules\Meetings\Controllers\MeetingController::class, 'delete', ['auth', 'blocking']);
\ No newline at end of file
$router->get('/api/meetings', [MeetingController::class, 'index']);
$router->get('/api/meetings/{meetingId}', [MeetingController::class, 'show']);
$router->post('/api/meetings', [MeetingController::class, 'create']);
$router->put('/api/meetings/{meetingId}', [MeetingController::class, 'update']);
$router->post('/api/meetings/{meetingId}/notes', [MeetingController::class, 'addNotes']);
$router->delete('/api/meetings/{meetingId}', [MeetingController::class, 'delete']);
};
\ No newline at end of file
<?php
use Engine\Core\Container;
use Engine\Core\Router;
use Modules\Offboarding\Controllers\OffboardingController;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class);
return function (Router $router) {
$router->post('/offboarding/initiate', [OffboardingController::class, 'initiate']);
$router->get('/offboarding/settlement/{userId}', [OffboardingController::class, 'calculateFinalSettlement']);
$router->post('/offboarding/initiate', \Modules\Offboarding\Controllers\OffboardingController::class, 'initiate', ['auth', 'blocking']);
$router->get('/offboarding/settlement/{userId}', \Modules\Offboarding\Controllers\OffboardingController::class, 'calculateFinalSettlement', ['auth', 'blocking']);
\ No newline at end of file
$router->post('/api/offboarding/initiate', [OffboardingController::class, 'initiate']);
$router->get('/api/offboarding/settlement/{userId}', [OffboardingController::class, 'calculateFinalSettlement']);
};
\ No newline at end of file
......@@ -10,32 +10,78 @@ use Engine\Notifications\NotificationManager;
final class PIPCheckinReminderJob implements JobInterface
{
private Connection $db;
private NotificationManager $notif;
public function key(): string
{
return 'pip_checkin_reminders';
}
public function __construct()
public function shouldRun(): bool
{
$c = Container::getInstance();
$this->db = $c->resolve(Connection::class);
$this->notif = $c->resolve(NotificationManager::class);
return true;
}
public function run(): void
{
$db = Container::getInstance()->resolve(Connection::class);
$notif = Container::getInstance()->resolve(NotificationManager::class);
$today = date('Y-m-d');
$checkins = $this->db->fetchAll(
"SELECT pc.*, p.contractor_id, p.created_by_id FROM pip_checkins pc
$tomorrow = date('Y-m-d', strtotime('+1 day'));
$checkins = $db->fetchAll(
"SELECT pc.*, p.contractor_id, p.created_by_id, u.full_name_en as contractor_name,
u.assigned_pl_id
FROM pip_checkins pc
JOIN pips p ON p.id = pc.pip_id
JOIN users u ON u.id = p.contractor_id
WHERE pc.scheduled_date IN (?, ?)
AND pc.logged_at IS NULL
AND p.status = 'active'
AND p.deleted_at IS NULL",
[$today, $tomorrow]
);
foreach ($checkins as $checkin) {
$isToday = $checkin['scheduled_date'] === $today;
$urgency = $isToday ? 'TODAY' : 'tomorrow';
$notif->createImportant($checkin['contractor_id'], "PIP Check-in {$urgency}",
"You have a PIP check-in scheduled for {$urgency}.",
"/pips/{$checkin['pip_id']}", 'pip', (int)$checkin['pip_id']);
$notif->createImportant($checkin['created_by_id'], "PIP Check-in {$urgency}",
"PIP check-in for {$checkin['contractor_name']} is scheduled {$urgency}.",
"/pips/{$checkin['pip_id']}", 'pip', (int)$checkin['pip_id']);
if ($checkin['assigned_pl_id'] && $checkin['assigned_pl_id'] !== $checkin['created_by_id']) {
$notif->createImportant($checkin['assigned_pl_id'], "PIP Check-in {$urgency}",
"PIP check-in for {$checkin['contractor_name']} is scheduled {$urgency}.",
"/pips/{$checkin['pip_id']}", 'pip', (int)$checkin['pip_id']);
}
}
$missedCheckins = $db->fetchAll(
"SELECT pc.*, p.contractor_id, u.full_name_en as contractor_name
FROM pip_checkins pc
JOIN pips p ON p.id = pc.pip_id
WHERE pc.scheduled_date = ? AND pc.logged_at IS NULL AND p.status = 'active' AND p.deleted_at IS NULL",
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 ($checkins as $ci) {
$this->notif->createImportant($ci['contractor_id'], 'PIP Check-in Today',
'You have a PIP check-in scheduled for today.', "/pips/{$ci['pip_id']}", 'pip', (int)$ci['pip_id']);
$this->notif->createImportant($ci['created_by_id'], 'PIP Check-in Due',
"PIP check-in for contractor ID {$ci['contractor_id']} is scheduled today.",
"/pips/{$ci['pip_id']}", 'pip', (int)$ci['pip_id']);
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']);
}
}
}
}
}
\ No newline at end of file
<?php
use Engine\Core\Container;
use Engine\Core\Router;
use Modules\PIPs\Controllers\PIPController;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class);
return function (Router $router) {
$router->get('/pips', [PIPController::class, 'index']);
$router->get('/pips/{pipId}', [PIPController::class, 'show']);
$router->post('/pips', [PIPController::class, 'create']);
$router->post('/pips/{pipId}/acknowledge', [PIPController::class, 'acknowledge']);
$router->post('/pips/{pipId}/checkins/{checkinId}', [PIPController::class, 'logCheckin']);
$router->post('/pips/{pipId}/decide', [PIPController::class, 'decide']);
$router->delete('/pips/{pipId}', [PIPController::class, 'delete']);
$router->get('/pips', \Modules\PIPs\Controllers\PIPController::class, 'index', ['auth', 'blocking']);
$router->get('/pips/{id}', \Modules\PIPs\Controllers\PIPController::class, 'show', ['auth', 'blocking']);
$router->post('/pips', \Modules\PIPs\Controllers\PIPController::class, 'create', ['auth', 'blocking']);
$router->post('/pips/{id}/acknowledge', \Modules\PIPs\Controllers\PIPController::class, 'acknowledge', ['auth']);
$router->post('/pips/{id}/checkins/{checkinId}', \Modules\PIPs\Controllers\PIPController::class, 'logCheckin', ['auth', 'blocking']);
$router->post('/pips/{id}/decide', \Modules\PIPs\Controllers\PIPController::class, 'decide', ['auth', 'blocking']);
$router->delete('/pips/{id}', \Modules\PIPs\Controllers\PIPController::class, 'delete', ['auth', 'blocking']);
\ No newline at end of file
$router->get('/api/pips', [PIPController::class, 'index']);
$router->get('/api/pips/{pipId}', [PIPController::class, 'show']);
$router->post('/api/pips', [PIPController::class, 'create']);
$router->post('/api/pips/{pipId}/acknowledge', [PIPController::class, 'acknowledge']);
$router->post('/api/pips/{pipId}/checkin', [PIPController::class, 'logCheckin']);
$router->put('/api/pips/{pipId}/result', [PIPController::class, 'decide']);
$router->delete('/api/pips/{pipId}', [PIPController::class, 'delete']);
};
\ No newline at end of file
......@@ -6,84 +6,161 @@ 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
{
private Connection $db;
public function key(): string
{
return 'create_recurring_cards';
}
public function __construct()
public function shouldRun(): bool
{
$this->db = Container::getInstance()->resolve(Connection::class);
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 = $this->db->fetchAll(
"SELECT * FROM recurring_card_definitions WHERE is_active = 1 AND (next_creation_at IS NULL OR next_creation_at <= ?)",
$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) {
$template = json_decode($def['card_template_json'], true);
if (!$template) continue;
$db->beginTransaction();
try {
$template = json_decode($def['card_template_json'], true) ?? [];
$board = $this->db->fetchOne("SELECT * FROM boards WHERE id = ? AND is_archived = 0", [$def['board_id']]);
if (!$board) continue;
$db->query("UPDATE boards SET card_sequence = card_sequence + 1 WHERE id = ?", [(int)$def['board_id']]);
$updatedBoard = $db->fetchOne("SELECT card_sequence FROM boards WHERE id = ?", [(int)$def['board_id']]);
$cardKey = $def['board_key'] . '-' . $updatedBoard['card_sequence'];
$backlogCol = $this->db->fetchOne(
"SELECT id FROM board_columns WHERE board_id = ? AND slug = 'backlog'", [$board['id']]
);
if (!$backlogCol) continue;
$backlogCol = $db->fetchOne(
"SELECT id FROM board_columns WHERE board_id = ? AND slug = 'backlog'",
[(int)$def['board_id']]
);
$this->db->transaction(function () use ($def, $template, $board, $backlogCol) {
$this->db->query("UPDATE boards SET card_sequence = card_sequence + 1 WHERE id = ?", [$board['id']]);
$updated = $this->db->fetchOne("SELECT card_sequence FROM boards WHERE id = ?", [$board['id']]);
$cardKey = $board['board_key'] . '-' . $updated['card_sequence'];
if (!$backlogCol) {
$db->rollBack();
continue;
}
$title = ($template['title'] ?? 'Recurring Task') . ' — ' . date('M j, Y');
$dateStr = date('M j, Y');
$title = ($template['title'] ?? 'Recurring Task') . " — {$dateStr}";
$cardId = $this->db->insert('cards', [
'board_id' => $board['id'],
$cardId = $db->insert('cards', [
'board_id' => (int)$def['board_id'],
'column_id' => $backlogCol['id'],
'card_number' => $updated['card_sequence'],
'card_number' => $updatedBoard['card_sequence'],
'card_key' => $cardKey,
'title' => $title,
'description' => $template['description'] ?? null,
'priority' => $template['priority'] ?? 'none',
'estimated_hours' => $template['estimated_hours'] ?? null,
'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' => $def['created_by_id'],
'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,
]);
}
}
}
}
$assignees = $def['assignees_json'] ? json_decode($def['assignees_json'], true) : [];
foreach ($assignees as $uid) {
$this->db->insert('card_assignments', [
foreach ($assignees as $assigneeId) {
$db->insert('card_assignments', [
'card_id' => $cardId,
'user_id' => (int)$uid,
'assigned_by_id' => $def['created_by_id'],
'user_id' => (int)$assigneeId,
'assigned_by_id' => (int)$def['created_by_id'],
]);
$notif->createImportant((int)$assigneeId, 'Recurring Task Created',
"Recurring card {$cardKey}: {$title} has been created.",
"/cards/{$cardId}", 'card', $cardId);
}
$nextCreation = $this->calculateNextCreation($def);
$this->db->update('recurring_card_definitions', [
'last_created_at' => date('Y-m-d H:i:s'),
$db->update('recurring_card_definitions', [
'last_created_at' => $now,
'next_creation_at' => $nextCreation,
], 'id = ?', [$def['id']]);
});
], 'id = ?', [(int)$def['id']]);
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
error_log("CreateRecurringCardsJob error for def {$def['id']}: " . $e->getMessage());
}
}
}
private function calculateNextCreation(array $def): string
{
$now = time();
return match($def['frequency']) {
'daily' => date('Y-m-d 04:00:00', strtotime('+1 day', $now)),
'weekly' => date('Y-m-d 04:00:00', strtotime('+1 week', $now)),
'biweekly' => date('Y-m-d 04:00:00', strtotime('+2 weeks', $now)),
'monthly' => date('Y-m-d 04:00:00', strtotime('+1 month', $now)),
'custom' => date('Y-m-d 04:00:00', strtotime('+' . (int)$def['frequency_days'] . ' days', $now)),
default => date('Y-m-d 04:00:00', strtotime('+1 week', $now)),
};
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));
}
}
}
\ No newline at end of file
<?php
use Engine\Core\Container;
use Engine\Core\Router;
use Modules\RecurringCards\Controllers\RecurringCardController;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class);
return function (Router $router) {
$router->get('/recurring-cards', [RecurringCardController::class, 'index']);
$router->post('/recurring-cards', [RecurringCardController::class, 'create']);
$router->put('/recurring-cards/{defId}', [RecurringCardController::class, 'update']);
$router->delete('/recurring-cards/{defId}', [RecurringCardController::class, 'delete']);
$router->get('/recurring-cards', \Modules\RecurringCards\Controllers\RecurringCardController::class, 'index', ['auth', 'blocking']);
$router->post('/recurring-cards', \Modules\RecurringCards\Controllers\RecurringCardController::class, 'create', ['auth', 'blocking']);
$router->put('/recurring-cards/{id}', \Modules\RecurringCards\Controllers\RecurringCardController::class, 'update', ['auth', 'blocking']);
$router->delete('/recurring-cards/{id}', \Modules\RecurringCards\Controllers\RecurringCardController::class, 'delete', ['auth', 'blocking']);
\ No newline at end of file
$router->get('/api/recurring-cards', [RecurringCardController::class, 'index']);
$router->post('/api/recurring-cards', [RecurringCardController::class, 'create']);
$router->put('/api/recurring-cards/{defId}', [RecurringCardController::class, 'update']);
$router->delete('/api/recurring-cards/{defId}', [RecurringCardController::class, 'delete']);
};
\ No newline at end of file
<?php
declare(strict_types=1);
// Salary routes are served via the Dashboard HUD and API endpoints
// Additional salary-specific routes will be added in Phase 2+
\ No newline at end of file
use Engine\Core\Router;
use Modules\Dashboard\Controllers\DashboardController;
return function (Router $router) {
// HUD data is served via the Dashboard controller's getHudData
// and SSE stream. Salary module routes for API access:
$router->get('/api/users/{userId}/hud', function (\Engine\Core\Request $request, string $userId) {
$user = $request->user();
$db = \Engine\Core\Container::getInstance()->resolve(\Engine\Database\Connection::class);
$targetId = (int)$userId;
if ($user['role'] === 'contractor' && $user['id'] !== $targetId) {
return \Engine\Core\Response::json(['error' => 'Forbidden'], 403);
}
$target = $db->fetchOne("SELECT * FROM users WHERE id = ?", [$targetId]);
if (!$target) {
return \Engine\Core\Response::json(['error' => 'User not found'], 404);
}
$month = date('Y-m');
$actualSalary = (float)($target['actual_salary'] ?? 0);
$totalBounties = (float)$db->fetchColumn(
"SELECT COALESCE(SUM(amount), 0) FROM bounty_payouts WHERE recipient_id = ? AND payroll_month = ?",
[$targetId, $month]
);
$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",
[$targetId, $month]
);
$totalPosAdj = (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",
[$targetId, $month]
);
$totalNegAdj = (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",
[$targetId, $month]
);
$liveSalary = $actualSalary + $totalBounties + $totalPosAdj - $totalDeductions - $totalNegAdj;
$retentionPct = $actualSalary > 0 ? ($liveSalary / $actualSalary) * 100 : 100;
return \Engine\Core\Response::json([
'actual_salary' => $actualSalary,
'live_salary' => round($liveSalary, 2),
'total_bounties' => $totalBounties,
'total_deductions' => $totalDeductions,
'total_pos_adj' => $totalPosAdj,
'total_neg_adj' => $totalNegAdj,
'retention_pct' => round($retentionPct, 2),
'month' => $month,
]);
});
};
\ No newline at end of file
<?php
use Engine\Core\Container;
use Engine\Core\Router;
use Modules\SavedFilters\Controllers\SavedFilterController;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class);
return function (Router $router) {
$router->get('/saved-filters', [SavedFilterController::class, 'index']);
$router->post('/saved-filters', [SavedFilterController::class, 'create']);
$router->delete('/saved-filters/{filterId}', [SavedFilterController::class, 'delete']);
$router->get('/saved-filters', \Modules\SavedFilters\Controllers\SavedFilterController::class, 'index', ['auth']);
$router->post('/saved-filters', \Modules\SavedFilters\Controllers\SavedFilterController::class, 'create', ['auth']);
$router->delete('/saved-filters/{id}', \Modules\SavedFilters\Controllers\SavedFilterController::class, 'delete', ['auth']);
\ No newline at end of file
$router->get('/api/saved-filters', [SavedFilterController::class, 'index']);
$router->post('/api/saved-filters', [SavedFilterController::class, 'create']);
$router->delete('/api/saved-filters/{filterId}', [SavedFilterController::class, 'delete']);
};
\ No newline at end of file
<?php
use Engine\Core\Container;
use Engine\Core\Router;
use Modules\Schedules\Controllers\ScheduleController;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class);
return function (Router $router) {
$router->get('/schedules/user/{userId}', [ScheduleController::class, 'currentSchedule']);
$router->get('/schedules/requests', [ScheduleController::class, 'requests']);
$router->post('/schedules/requests', [ScheduleController::class, 'submitRequest']);
$router->post('/schedules/requests/{requestId}/review', [ScheduleController::class, 'reviewRequest']);
$router->put('/schedules/user/{userId}/direct', [ScheduleController::class, 'directEdit']);
$router->get('/schedules/users/{userId}', \Modules\Schedules\Controllers\ScheduleController::class, 'currentSchedule', ['auth', 'blocking']);
$router->get('/schedules/requests', \Modules\Schedules\Controllers\ScheduleController::class, 'requests', ['auth', 'blocking']);
$router->post('/schedules/requests', \Modules\Schedules\Controllers\ScheduleController::class, 'submitRequest', ['auth', 'blocking']);
$router->post('/schedules/requests/{id}/review', \Modules\Schedules\Controllers\ScheduleController::class, 'reviewRequest', ['auth', 'blocking']);
$router->post('/schedules/users/{userId}/edit', \Modules\Schedules\Controllers\ScheduleController::class, 'directEdit', ['auth', 'blocking']);
\ No newline at end of file
$router->get('/api/users/{userId}/schedule', [ScheduleController::class, 'currentSchedule']);
$router->get('/api/schedule-requests', [ScheduleController::class, 'requests']);
$router->post('/api/schedule-requests', [ScheduleController::class, 'submitRequest']);
$router->post('/api/schedule-requests/{requestId}/review', [ScheduleController::class, 'reviewRequest']);
$router->put('/api/users/{userId}/schedule', [ScheduleController::class, 'directEdit']);
};
\ No newline at end of file
<?php
use Engine\Core\Container;
use Engine\Core\Router;
use Modules\TeamAvailability\Controllers\TeamAvailabilityController;
$router = Container::getInstance()->resolve(\Engine\Core\Router::class);
$router->get('/team-availability', \Modules\TeamAvailability\Controllers\TeamAvailabilityController::class, 'index', ['auth', 'blocking']);
\ No newline at end of file
return function (Router $router) {
$router->get('/team-availability', [TeamAvailabilityController::class, 'index']);
$router->get('/api/team-availability', [TeamAvailabilityController::class, 'index']);
};
\ No newline at end of file
<?php
declare(strict_types=1);
use Engine\Core\Router;
use Modules\Unavailability\Controllers\UnavailabilityController;
use Engine\Core\Container;
return function (Router $router) {
$router->get('/unavailability', [UnavailabilityController::class, 'index']);
$router->post('/unavailability', [UnavailabilityController::class, 'create']);
$router->put('/unavailability/{id}', [UnavailabilityController::class, 'update']);
$router->delete('/unavailability/{id}', [UnavailabilityController::class, 'delete']);
$router = Container::getInstance()->resolve(Engine\Core\Router::class);
$router->group([
'prefix' => '/api/unavailability',
'middleware' => [Middleware\AuthenticationMiddleware::class, Middleware\CSRFMiddleware::class]
], function ($router) {
$router->get('/', Modules\Unavailability\Controllers\UnavailabilityController::class, 'index');
$router->post('/', Modules\Unavailability\Controllers\UnavailabilityController::class, 'create');
$router->post('/{id}', Modules\Unavailability\Controllers\UnavailabilityController::class, 'update');
$router->post('/{id}/delete', Modules\Unavailability\Controllers\UnavailabilityController::class, 'delete');
});
\ No newline at end of file
$router->get('/api/unavailability', [UnavailabilityController::class, 'index']);
$router->post('/api/unavailability', [UnavailabilityController::class, 'create']);
$router->put('/api/unavailability/{id}', [UnavailabilityController::class, 'update']);
$router->delete('/api/unavailability/{id}', [UnavailabilityController::class, 'delete']);
};
\ No newline at end of file
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